diff --git a/crates/application/tests/layout_usecases.rs b/crates/application/tests/layout_usecases.rs index 7e4bc7ab..2d0ad564 100644 --- a/crates/application/tests/layout_usecases.rs +++ b/crates/application/tests/layout_usecases.rs @@ -940,6 +940,16 @@ async fn mutate_set_cell_conversation_missing_leaf_is_not_found() { #[tokio::test] async fn mutate_set_cell_preferred_view_persists_chat_then_tui_default() { let env = mut_env(pid(54)).await; + let original = LeafCell { + id: nid(1), + session: Some(sid(54)), + agent: Some(aid(54)), + conversation_id: Some("conversation-54".to_owned()), + engine_session_id: Some("engine-session-54".to_owned()), + agent_was_running: true, + preferred_view: PreferredView::Tui, + }; + seed_layouts(&env.fs, lid(1), &LayoutTree::single(original.clone())); env.mutate .execute(MutateLayoutInput { @@ -955,6 +965,16 @@ async fn mutate_set_cell_preferred_view_persists_chat_then_tui_default() { let tree_json = active_tree_json(&env.fs); assert_eq!(tree_json["root"]["node"]["preferredView"], "chat"); + assert_eq!( + tree_json["root"]["node"]["conversationId"], + "conversation-54" + ); + assert_eq!( + tree_json["root"]["node"]["engineSessionId"], + "engine-session-54" + ); + assert_eq!(tree_json["root"]["node"]["session"], sid(54).to_string()); + assert_eq!(tree_json["root"]["node"]["agentWasRunning"], true); let out = env .mutate @@ -970,7 +990,13 @@ async fn mutate_set_cell_preferred_view_persists_chat_then_tui_default() { .expect("set_cell_preferred_view restores tui"); match &out.layout.root { - LayoutNode::Leaf(l) => assert_eq!(l.preferred_view, PreferredView::Tui), + LayoutNode::Leaf(l) => { + assert_eq!(l.preferred_view, PreferredView::Tui); + assert_eq!(l.session, original.session); + assert_eq!(l.conversation_id, original.conversation_id); + assert_eq!(l.engine_session_id, original.engine_session_id); + assert_eq!(l.agent_was_running, original.agent_was_running); + } _ => panic!("expected leaf root"), } let tree_json = active_tree_json(&env.fs); diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 250ba853..c56c1238 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -1050,6 +1050,9 @@ export interface ProjectMcpToolPermissions { /** Split direction: `row` = columns (left→right), `column` = rows (top→bottom). */ export type Direction = "row" | "column"; +/** Preferred live surface persisted on an agent layout cell. */ +export type PreferredView = "tui" | "chat"; + /** A terminal-hosting leaf cell. `session` is the hosted SessionId, if any. * `agent` is the agent id if an agent is pinned to this cell (absent = plain terminal). * `conversationId` is the persistent CLI conversation id (survives PTY close/reopen, @@ -1062,6 +1065,7 @@ export interface LeafCell { agent?: string; conversationId?: string; agentWasRunning?: boolean; + preferredView?: PreferredView; } /** A weighted child of a split. `weight` is a relative (`> 0`) share. */ @@ -1133,6 +1137,7 @@ export type LayoutOperation = | { type: "setSession"; target: string; session?: string | null } | { type: "setCellAgent"; target: string; agent: string | null } | { type: "setCellConversation"; target: string; conversationId: string | null } + | { type: "setCellPreferredView"; target: string; preferredView: PreferredView } | { type: "setAgentRunning"; target: string; running: boolean } | { type: "setPluginLayoutState"; target: string; state: unknown }; diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index e5aa2ec7..3048df8e 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -864,6 +864,66 @@ describe("CustomAgentChatView", () => { await waitFor(() => expect(composer.value).toBe("remember me")); }); + it("persists an accepted text prompt before invoking the agent", async () => { + const historyKey = "idea.custom-chat-history.project-1.layout-1.node-1.agent-1"; + let historyAtSend: string[] = []; + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [] })), + sendAgentChat: vi.fn( + async (_sessionId: string, _prompt: string, onChunk: (chunk: ReplyChunk) => void) => { + historyAtSend = JSON.parse(window.localStorage.getItem(historyKey) ?? "[]"); + onChunk({ kind: "final", content: "done" }); + }, + ), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + render( + null) } } as unknown as Gateways}> + + , + ); + + const composer = await screen.findByLabelText(/message CLI custom/); + fireEvent.change(composer, { target: { value: "persist before send" } }); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + + await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledOnce()); + expect(historyAtSend).toEqual(["persist before send"]); + }); + + it("keeps sending when prompt-history persistence fails and reports it discreetly", async () => { + const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("storage unavailable"); + }); + try { + const agent = renderHistoryChat(); + const composer = await screen.findByLabelText(/message CLI custom/); + fireEvent.change(composer, { target: { value: "still send" } }); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + + await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledOnce()); + expect(screen.getByRole("status").textContent).toContain( + "Historique des prompts non sauvegardé.", + ); + } finally { + setItem.mockRestore(); + } + }); + it("repaints attachment labels from user prompt scrollback", async () => { const agent = { launchAgentChat: vi.fn(), diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 29544975..6620805d 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -472,15 +472,16 @@ function readComposerHistory(storageKey: string): string[] { } } -function writeComposerHistory(storageKey: string, history: string[]): void { - if (typeof window === "undefined") return; +function writeComposerHistory(storageKey: string, history: string[]): boolean { + if (typeof window === "undefined") return true; try { window.localStorage.setItem( storageKey, JSON.stringify(history.slice(-COMPOSER_HISTORY_LIMIT)), ); + return true; } catch { - /* local history only */ + return false; } } @@ -1151,6 +1152,8 @@ export function CustomAgentChatView({ const [composerHistory, setComposerHistory] = useState(() => readComposerHistory(composerHistoryKey), ); + const composerHistoryRef = useRef(composerHistory); + const [composerHistoryWarning, setComposerHistoryWarning] = useState(null); const [composerHistoryIndex, setComposerHistoryIndex] = useState(null); const displayTurns = turns; @@ -1304,15 +1307,13 @@ export function CustomAgentChatView({ useEffect(() => { const restored = readComposerHistory(composerHistoryKey); + composerHistoryRef.current = restored; setComposerHistory(restored); + setComposerHistoryWarning(null); setComposerHistoryIndex(null); draftBeforeHistoryNavigationRef.current = ""; }, [composerHistoryKey]); - useEffect(() => { - writeComposerHistory(composerHistoryKey, composerHistory); - }, [composerHistory, composerHistoryKey]); - // Attachments can be large, so keep draft bytes only in this renderer's // bounded lifetime instead of serialising base64 into localStorage. The // project/layout/node scope mirrors the persisted text draft contract. @@ -2246,6 +2247,17 @@ export function CustomAgentChatView({ async function send() { const text = draft.trim(); if (!canSend || !agent.sendAgentChat) return; + if (text) { + const nextHistory = appendComposerHistoryEntry(composerHistoryRef.current, text); + composerHistoryRef.current = nextHistory; + const persisted = writeComposerHistory(composerHistoryKey, nextHistory); + setComposerHistory(nextHistory); + setComposerHistoryWarning( + persisted ? null : "Historique des prompts non sauvegardé.", + ); + setComposerHistoryIndex(null); + draftBeforeHistoryNavigationRef.current = ""; + } const slashInvocation = slashCommandInvocation(text); if (slashInvocation) { await executeSlashCommand(slashInvocation); @@ -2263,11 +2275,6 @@ export function CustomAgentChatView({ markProjectCliInteractionFinished(projectId, interactionId); activeInteractionIdRef.current = null; }; - if (text) { - setComposerHistory((prev) => appendComposerHistoryEntry(prev, text)); - setComposerHistoryIndex(null); - draftBeforeHistoryNavigationRef.current = ""; - } setDraft(""); setAttachments([]); imageTokenSeqRef.current = 0; @@ -2705,6 +2712,11 @@ export function CustomAgentChatView({ })} )} + {composerHistoryWarning && ( +

+ {composerHistoryWarning} +

+ )}