diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index f61ffbeb..aa87633f 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -140,6 +140,7 @@ function setMeasuredTextOverflow( describe("CustomAgentChatView", () => { beforeEach(() => { vi.clearAllMocks(); + window.localStorage.clear(); __clearCustomAgentChatViewCacheForTests(); __resetProjectCliInteractionActivityForTests(); }); @@ -236,6 +237,144 @@ describe("CustomAgentChatView", () => { expect(screen.queryByRole("button", { name: "Ouvrir en TUI native" })).toBeNull(); }); + it("recalls previously sent prompts with arrow up and down", async () => { + 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) => { + onChunk({ kind: "final", content: `answer ${prompt}` }); + }, + ), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + const composer = await screen.findByLabelText(/message CLI custom/) as HTMLTextAreaElement; + await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalled()); + + fireEvent.change(composer, { target: { value: "first prompt" } }); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledWith( + "chat-session-1", + "first prompt", + expect.any(Function), + )); + + fireEvent.change(composer, { target: { value: "second prompt" } }); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledWith( + "chat-session-1", + "second prompt", + expect.any(Function), + )); + + fireEvent.keyDown(composer, { key: "ArrowUp" }); + await waitFor(() => expect(composer.value).toBe("second prompt")); + fireEvent.keyDown(composer, { key: "ArrowUp" }); + await waitFor(() => expect(composer.value).toBe("first prompt")); + fireEvent.keyDown(composer, { key: "ArrowDown" }); + await waitFor(() => expect(composer.value).toBe("second prompt")); + + fireEvent.change(composer, { target: { value: "draft in progress" } }); + fireEvent.keyDown(composer, { key: "ArrowUp" }); + await waitFor(() => expect(composer.value).toBe("second prompt")); + fireEvent.keyDown(composer, { key: "ArrowDown" }); + await waitFor(() => expect(composer.value).toBe("draft in progress")); + }); + + it("persists prompt history across a custom chat remount", async () => { + 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) => { + onChunk({ kind: "final", content: `answer ${prompt}` }); + }, + ), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + const props = { + projectId: "project-1", + agentId: "agent-1", + agentName: "Worker", + profile, + cwd: "/repo", + layoutId: "layout-1", + nodeId: "node-1", + sessionId: "chat-session-1", + conversationId: "conversation-1", + onSessionId: vi.fn(), + onConversationId: vi.fn(), + }; + + const view = render( + null) }, + } as unknown as Gateways} + > + + , + ); + + let composer = await screen.findByLabelText(/message CLI custom/) as HTMLTextAreaElement; + fireEvent.change(composer, { target: { value: "remember me" } }); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledWith( + "chat-session-1", + "remember me", + expect.any(Function), + )); + + view.unmount(); + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + composer = await screen.findByLabelText(/message CLI custom/) as HTMLTextAreaElement; + fireEvent.keyDown(composer, { key: "ArrowUp" }); + await waitFor(() => expect(composer.value).toBe("remember me")); + }); + 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 d3884eab..00554054 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -83,6 +83,7 @@ const chatConversationViewCache = new Map(); const EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse."; const COMPOSER_RESIZE_STEP = 12; const FALLBACK_COMPOSER_HEIGHT = 64; +const COMPOSER_HISTORY_LIMIT = 100; const MIN_REASONABLE_CHAT_TIMESTAMP_MS = Date.UTC(2020, 0, 1); const MAX_REASONABLE_CHAT_TIMESTAMP_MS = Date.UTC(2100, 0, 1); const MIN_REASONABLE_CHAT_TIMESTAMP_SECONDS = Math.floor( @@ -416,6 +417,55 @@ function conversationCacheKey( return conversationId ? `${projectId}::${agentId}::${conversationId}` : null; } +function composerHistoryStorageKey( + projectId: string, + agentId: string, + layoutId: string | undefined, + nodeId: string | undefined, +): string { + return [ + "idea.custom-chat-history", + projectId, + layoutId ?? "default", + nodeId ?? "agent", + agentId, + ].join("."); +} + +function readComposerHistory(storageKey: string): string[] { + if (typeof window === "undefined") return []; + try { + const parsed: unknown = JSON.parse(window.localStorage.getItem(storageKey) ?? "[]"); + if (!Array.isArray(parsed)) return []; + return parsed + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter(Boolean) + .slice(-COMPOSER_HISTORY_LIMIT); + } catch { + return []; + } +} + +function writeComposerHistory(storageKey: string, history: string[]): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + storageKey, + JSON.stringify(history.slice(-COMPOSER_HISTORY_LIMIT)), + ); + } catch { + /* local history only */ + } +} + +function appendComposerHistoryEntry(history: string[], text: string): string[] { + const entry = text.trim(); + if (!entry) return history; + const withoutDuplicateTail = history.at(-1) === entry ? history.slice(0, -1) : history; + return [...withoutDuplicateTail, entry].slice(-COMPOSER_HISTORY_LIMIT); +} + function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatViewState { let key = progressUpsertKey(progress, state.progressScope); let existingIndex = state.turns.findIndex( @@ -953,6 +1003,7 @@ export function CustomAgentChatView({ const lastPersistedDraftRef = useRef(""); const draftRef = useRef(""); draftRef.current = draft; + const draftBeforeHistoryNavigationRef = useRef(""); const attachmentsRef = useRef([]); attachmentsRef.current = attachments; const imageTokenSeqRef = useRef(0); @@ -1061,6 +1112,14 @@ export function CustomAgentChatView({ () => conversationCacheKey(projectId, agentId, conversationId), [agentId, conversationId, projectId], ); + const composerHistoryKey = useMemo( + () => composerHistoryStorageKey(projectId, agentId, layoutId, nodeId), + [agentId, layoutId, nodeId, projectId], + ); + const [composerHistory, setComposerHistory] = useState(() => + readComposerHistory(composerHistoryKey), + ); + const [, setComposerHistoryIndex] = useState(null); const displayTurns = turns; const sessionLabel = structuredSessionLabel(profile.structuredAdapter); const [requesterDelegation, setRequesterDelegation] = @@ -1127,9 +1186,82 @@ export function CustomAgentChatView({ const editDraft = useCallback((text: string) => { if (!draftHydratedRef.current) draftEditedBeforeHydrationRef.current = true; + setComposerHistoryIndex(null); + draftBeforeHistoryNavigationRef.current = ""; setDraft(text); }, []); + const recallComposerHistory = useCallback( + (direction: "previous" | "next") => { + if (composerHistory.length === 0) return; + setComposerHistoryIndex((current) => { + if (current === null && direction === "previous") { + draftBeforeHistoryNavigationRef.current = draftRef.current; + } + const nextIndex = + direction === "previous" + ? current === null + ? composerHistory.length - 1 + : Math.max(0, current - 1) + : current === null + ? null + : current >= composerHistory.length - 1 + ? null + : current + 1; + if (nextIndex === null) { + setDraft(draftBeforeHistoryNavigationRef.current); + draftBeforeHistoryNavigationRef.current = ""; + } else { + setDraft(composerHistory[nextIndex] ?? ""); + } + return nextIndex; + }); + }, + [composerHistory], + ); + + const handleComposerHistoryKeyDown = useCallback( + (event: KeyboardEvent): boolean => { + if ( + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + (event.key !== "ArrowUp" && event.key !== "ArrowDown") + ) { + return false; + } + const target = event.currentTarget; + if ( + event.key === "ArrowUp" && + target.value.slice(0, target.selectionStart).includes("\n") + ) { + return false; + } + if ( + event.key === "ArrowDown" && + target.value.slice(target.selectionEnd).includes("\n") + ) { + return false; + } + event.preventDefault(); + recallComposerHistory(event.key === "ArrowUp" ? "previous" : "next"); + return true; + }, + [recallComposerHistory], + ); + + useEffect(() => { + const restored = readComposerHistory(composerHistoryKey); + setComposerHistory(restored); + setComposerHistoryIndex(null); + draftBeforeHistoryNavigationRef.current = ""; + }, [composerHistoryKey]); + + useEffect(() => { + writeComposerHistory(composerHistoryKey, composerHistory); + }, [composerHistory, composerHistoryKey]); + useEffect(() => { void refreshRequesterDelegation(); }, [refreshRequesterDelegation]); @@ -1913,6 +2045,11 @@ 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; @@ -2373,6 +2510,7 @@ export function CustomAgentChatView({ return; } } + if (handleComposerHistoryKeyDown(e)) return; if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void send(); diff --git a/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx b/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx index 7c2b8cb6..5fe7128f 100644 --- a/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx +++ b/frontend/src/features/layout/LayoutGrid.cellControlsLayering.test.tsx @@ -64,6 +64,7 @@ import { LayoutGrid } from "./LayoutGrid"; interface GridSetup { agentId: string; + otherAgentId: string; bId: string; gateways: Gateways; layout: MockLayoutGateway; @@ -84,6 +85,10 @@ async function makeSplitAgentGrid(): Promise { name: "Worker", profileId: "mock-claude", }); + const otherAgent = await agentGateway.createAgent("p1", { + name: "Reviewer", + profileId: "mock-claude", + }); const initial = await layout.loadLayout("p1"); const aId = leaves(initial)[0].id; @@ -102,6 +107,7 @@ async function makeSplitAgentGrid(): Promise { return { agentId: agent.id, + otherAgentId: otherAgent.id, bId: "b", gateways: { layout, @@ -206,7 +212,7 @@ describe("LayoutGrid — ticket #48 cell control layering", () => { expect(controls.style.overflow).toBe("hidden"); }); - it("does not render persistent cell chrome over the custom CLI surface (#249)", async () => { + it("keeps cell controls available over a busy custom CLI surface (#249)", async () => { const setup = await makeSplitAgentGrid(); window.localStorage.setItem(`idea.agent-cell-mode.p1.${setup.bId}`, "custom"); setup.workState._setProjectWorkState("p1", { @@ -227,19 +233,47 @@ describe("LayoutGrid — ticket #48 cell control layering", () => { const customToolbar = screen.getByRole("toolbar", { name: "custom agent chat actions", }); + const mutate = vi.spyOn(setup.layout, "mutateLayout"); expect(customToolbar.className).toContain("z-20"); - expect(screen.queryByLabelText(`agent selector ${setup.bId}`)).toBeNull(); - expect(screen.queryByRole("group", { + expect(screen.getByLabelText(`agent selector ${setup.bId}`)).toBeTruthy(); + expect(screen.getByRole("group", { name: `mode CLI agent ${setup.bId}`, - })).toBeNull(); - expect(screen.queryByLabelText(`split ${setup.bId} columns`)).toBeNull(); - expect(screen.queryByLabelText(`split ${setup.bId} rows`)).toBeNull(); - expect(screen.queryByLabelText(`close ${setup.bId}`)).toBeNull(); - expect(screen.queryByRole("button", { + })).toBeTruthy(); + expect(screen.getByLabelText(`split ${setup.bId} columns`)).toBeTruthy(); + expect(screen.getByLabelText(`split ${setup.bId} rows`)).toBeTruthy(); + expect(screen.getByLabelText(`close ${setup.bId}`)).toBeTruthy(); + expect(screen.getByRole("button", { name: `cancel current turn ${setup.bId}`, - })).toBeNull(); - expect(screen.queryByText("Busy")).toBeNull(); + })).toBeTruthy(); + expect(screen.getByText("Busy")).toBeTruthy(); + + fireEvent.click(screen.getByLabelText(`split ${setup.bId} columns`)); + await waitFor(() => + expect(mutate).toHaveBeenCalledWith( + "p1", + expect.objectContaining({ + type: "split", + target: setup.bId, + direction: "row", + }), + undefined, + ), + ); + + const selector = screen.getByLabelText(`agent selector ${setup.bId}`); + fireEvent.change(selector, { target: { value: setup.otherAgentId } }); + await waitFor(() => + expect(mutate).toHaveBeenCalledWith( + "p1", + expect.objectContaining({ + type: "setCellAgent", + target: setup.bId, + agent: setup.otherAgentId, + }), + undefined, + ), + ); }); it("keeps busy Cancel above persistent cell controls and removes History (#151, #160)", async () => { diff --git a/frontend/src/features/layout/LayoutGrid.chat.test.tsx b/frontend/src/features/layout/LayoutGrid.chat.test.tsx index f082b2e1..3f4af830 100644 --- a/frontend/src/features/layout/LayoutGrid.chat.test.tsx +++ b/frontend/src/features/layout/LayoutGrid.chat.test.tsx @@ -219,11 +219,11 @@ describe("LayoutGrid custom agent CLI (#147)", () => { expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy(); expect(screen.queryByTestId("target-announcements-overlay")).toBeNull(); - expect(screen.queryByText("Busy · Main")).toBeNull(); - expect(screen.queryByLabelText(`agent selector ${leaf.id}`)).toBeNull(); - expect(screen.queryByRole("button", { + expect(screen.getByText("Busy · Main")).toBeTruthy(); + expect(screen.getByLabelText(`agent selector ${leaf.id}`)).toBeTruthy(); + expect(screen.getByRole("button", { name: `cancel current turn ${leaf.id}`, - })).toBeNull(); + })).toBeTruthy(); await waitFor(() => expect(screen.getByLabelText(/message CLI custom/)).toHaveProperty( "disabled", diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index aa0754f0..0a5dfa68 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -655,13 +655,6 @@ function LeafView({ customCliAvailable && pinnedAgent ? pinnedAgent : trustedCustomCli?.agent; const customCliProfile = customCliAvailable && pinnedProfile ? pinnedProfile : trustedCustomCli?.profile; - const customCliActive = Boolean( - agentId && - cellMode === "custom" && - effectiveCustomCliAvailable && - customCliAgent && - customCliProfile, - ); useEffect(() => { if (cellMode !== "custom") { setTrustedCustomCli(null); @@ -774,7 +767,6 @@ function LeafView({ task.status === "completed" || task.status === "failed" || task.status === "cancelled", ) ?? []; const busyByWorkState = agentWork?.busy.state === "busy" || Boolean(activeTicket); - const suppressCellChrome = customCliActive && busyByWorkState; const delegatedTicket = activeTicket?.source.kind === "agent" ? activeTicket : undefined; const busyActive = announcementBusyActive || busyByWorkState; const targetAnnouncementsFallback = delegatedTicket?.requesterLabel @@ -1138,27 +1130,27 @@ function LeafView({ overflow: "hidden", }} > - {!suppressCellChrome && ( - + {/* Agent selector */} )} - - )} - {agentId && busyByWorkState && !suppressCellChrome && ( + + {agentId && busyByWorkState && ( )} - {agentId && !suppressCellChrome && (busyByWorkState || inboxDepth > 0 || completedBackgroundTasks.length > 0) && ( + {agentId && (busyByWorkState || inboxDepth > 0 || completedBackgroundTasks.length > 0) && (