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} +

+ )}