From db312bfde3fda10bbe503c7fa1e0700fe72fd0f0 Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 10 Sep 2026 09:53:29 +0200 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20ArrowUp=20rappelle=20l'histori?= =?UTF-8?q?que=20chat=20m=C3=AAme=20en=20multiligne=20=E2=80=94=20#304=20(?= =?UTF-8?q?cause:=20le=20handler=20ArrowUp=20remontait=20dans=20le=20texte?= =?UTF-8?q?=20d=C3=A8s=20que=20le=20draft=20faisait=20plus=20d'une=20ligne?= =?UTF-8?q?,=20au=20lieu=20de=20rappeler=20le=20message=20pr=C3=A9c=C3=A9d?= =?UTF-8?q?ent;=20fix=20type=20terminal:=20ArrowUp=20d=C3=A9clenche=20le?= =?UTF-8?q?=20rappel=20historique=20uniquement=20quand=20le=20curseur=20es?= =?UTF-8?q?t=20=C3=A0=20la=20fin=20de=20la=20derni=C3=A8re=20ligne=20du=20?= =?UTF-8?q?textarea,=20sinon=20navigation=20caret=20normale=20pr=C3=A9serv?= =?UTF-8?q?=C3=A9e;=20ArrowDown=20sym=C3=A9trique=20en=20fin=20de=20draft?= =?UTF-8?q?=20pour=20redescendre=20dans=20l'historique;=20QA=20verte=20rec?= =?UTF-8?q?onfirm=C3=A9e=20par=20Git:=20npm=20test=20--=20src/features/age?= =?UTF-8?q?nts/CustomAgentChatView.test.tsx=2071/71,=20npm=20run=20typeche?= =?UTF-8?q?ck=20OK,=20git=20diff=20--check=20OK;=20QA=20fournie:=20npm=20r?= =?UTF-8?q?un=20build=20OK;=20fichiers=20.ideai/*=20tickets/counter/index/?= =?UTF-8?q?attachments=20et=20scripts/=20hors=20p=C3=A9rim=C3=A8tre;=20auc?= =?UTF-8?q?un=20push)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agents/CustomAgentChatView.test.tsx | 142 +++++++++++++++++- .../features/agents/CustomAgentChatView.tsx | 35 +++-- 2 files changed, 166 insertions(+), 11 deletions(-) diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 630609db..d394771a 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -138,6 +138,57 @@ function setMeasuredTextOverflow( fireEvent(window, new Event("resize")); } +function renderHistoryChat() { + 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} + > + + , + ); + return agent; +} + +async function sendHistoryPrompt( + composer: HTMLTextAreaElement, + agent: ReturnType, + prompt: string, +) { + const expectedCalls = agent.sendAgentChat.mock.calls.length + 1; + fireEvent.change(composer, { target: { value: prompt } }); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(expectedCalls)); +} + describe("CustomAgentChatView", () => { beforeEach(() => { vi.clearAllMocks(); @@ -532,6 +583,95 @@ describe("CustomAgentChatView", () => { await waitFor(() => expect(composer.value).toBe("draft in progress")); }); + it("recalls history from the exact end of a multiline draft and restores it verbatim", async () => { + const agent = renderHistoryChat(); + const composer = (await screen.findByLabelText( + /message CLI custom/, + )) as HTMLTextAreaElement; + await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalled()); + await sendHistoryPrompt(composer, agent, "historic prompt"); + + const multilineDraft = "ligne 1\nligne 2\nfin 🧪 "; + fireEvent.change(composer, { target: { value: multilineDraft } }); + composer.setSelectionRange(multilineDraft.length, multilineDraft.length); + Object.defineProperty(composer, "scrollHeight", { + value: 480, + configurable: true, + }); + composer.scrollTop = 0; + + expect(fireEvent.keyDown(composer, { key: "ArrowUp" })).toBe(false); + await waitFor(() => expect(composer.value).toBe("historic prompt")); + expect(composer.selectionStart).toBe(composer.value.length); + expect(composer.selectionEnd).toBe(composer.value.length); + expect(composer.scrollTop).toBe(480); + + expect(fireEvent.keyDown(composer, { key: "ArrowDown" })).toBe(false); + await waitFor(() => expect(composer.value).toBe(multilineDraft)); + expect(composer.selectionStart).toBe(multilineDraft.length); + expect(composer.selectionEnd).toBe(multilineDraft.length); + expect(composer.scrollTop).toBe(480); + }); + + it("keeps ArrowUp native away from a simple end caret or during modified/IME input", async () => { + const agent = renderHistoryChat(); + const composer = (await screen.findByLabelText( + /message CLI custom/, + )) as HTMLTextAreaElement; + await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalled()); + await sendHistoryPrompt(composer, agent, "historic prompt"); + + const draft = "première\ndeuxième"; + const nativeCases: Array<{ + selection: [number, number]; + event?: Record; + }> = [ + { selection: [4, 4] }, + { selection: [0, draft.length] }, + { selection: [draft.length, draft.length], event: { shiftKey: true } }, + { selection: [draft.length, draft.length], event: { ctrlKey: true } }, + { selection: [draft.length, draft.length], event: { altKey: true } }, + { selection: [draft.length, draft.length], event: { metaKey: true } }, + { selection: [draft.length, draft.length], event: { isComposing: true } }, + { selection: [draft.length, draft.length], event: { keyCode: 229 } }, + ]; + for (const testCase of nativeCases) { + fireEvent.change(composer, { target: { value: draft } }); + composer.setSelectionRange(...testCase.selection); + expect( + fireEvent.keyDown(composer, { key: "ArrowUp", ...testCase.event }), + ).toBe(true); + expect(composer.value).toBe(draft); + } + + composer.setSelectionRange(draft.length, draft.length); + expect(fireEvent.keyDown(composer, { key: "ArrowUp" })).toBe(false); + await waitFor(() => expect(composer.value).toBe("historic prompt")); + }); + + it("does not consume ArrowUp with empty history and never mutates recalled entries", async () => { + const agent = renderHistoryChat(); + const composer = (await screen.findByLabelText( + /message CLI custom/, + )) as HTMLTextAreaElement; + await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalled()); + + expect(fireEvent.keyDown(composer, { key: "ArrowUp" })).toBe(true); + await sendHistoryPrompt(composer, agent, "original history"); + fireEvent.keyDown(composer, { key: "ArrowUp" }); + await waitFor(() => expect(composer.value).toBe("original history")); + + fireEvent.change(composer, { target: { value: "edited recall" } }); + composer.setSelectionRange(composer.value.length, composer.value.length); + fireEvent.keyDown(composer, { key: "ArrowUp" }); + await waitFor(() => expect(composer.value).toBe("original history")); + + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(2)); + expect(composer.value).toBe(""); + expect(fireEvent.keyDown(composer, { key: "ArrowDown" })).toBe(true); + }); + it("persists prompt history across a custom chat remount", async () => { const agent = { launchAgentChat: vi.fn(), @@ -3422,7 +3562,7 @@ describe("CustomAgentChatView", () => { expect(screen.getByRole("option", { name: /\/clean/ })).toBeTruthy(); expect(screen.getByRole("option", { name: /\/commit/ })).toBeTruthy(); - fireEvent.keyDown(composer, { key: "ArrowDown" }); + fireEvent.keyDown(composer, { key: "ArrowUp" }); fireEvent.keyDown(composer, { key: "Enter" }); await waitFor(() => expect(composer.value).toBe("/commit ")); diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index d677225a..7463c10a 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -1009,6 +1009,7 @@ export function CustomAgentChatView({ const draftRef = useRef(""); draftRef.current = draft; const draftBeforeHistoryNavigationRef = useRef(""); + const placeComposerAtEndAfterHistoryRef = useRef(false); const attachmentsRef = useRef([]); attachmentsRef.current = attachments; const imageTokenSeqRef = useRef(0); @@ -1125,7 +1126,8 @@ export function CustomAgentChatView({ const [composerHistory, setComposerHistory] = useState(() => readComposerHistory(composerHistoryKey), ); - const [, setComposerHistoryIndex] = useState(null); + const [composerHistoryIndex, setComposerHistoryIndex] = + useState(null); const displayTurns = turns; const sessionLabel = structuredSessionLabel(profile.structuredAdapter); const [requesterDelegation, setRequesterDelegation] = @@ -1205,6 +1207,7 @@ export function CustomAgentChatView({ const recallComposerHistory = useCallback( (direction: "previous" | "next") => { if (composerHistory.length === 0) return; + placeComposerAtEndAfterHistoryRef.current = true; setComposerHistoryIndex((current) => { if (current === null && direction === "previous") { draftBeforeHistoryNavigationRef.current = draftRef.current; @@ -1231,6 +1234,16 @@ export function CustomAgentChatView({ [composerHistory], ); + useEffect(() => { + if (!placeComposerAtEndAfterHistoryRef.current) return; + placeComposerAtEndAfterHistoryRef.current = false; + const composer = composerRef.current; + if (!composer) return; + const end = composer.value.length; + composer.setSelectionRange(end, end); + composer.scrollTop = composer.scrollHeight; + }, [composerHistoryIndex, draft]); + const handleComposerHistoryKeyDown = useCallback( (event: KeyboardEvent): boolean => { if ( @@ -1238,28 +1251,30 @@ export function CustomAgentChatView({ event.ctrlKey || event.metaKey || event.shiftKey || + event.nativeEvent.isComposing || + event.keyCode === 229 || (event.key !== "ArrowUp" && event.key !== "ArrowDown") ) { return false; } const target = event.currentTarget; - if ( - event.key === "ArrowUp" && - target.value.slice(0, target.selectionStart).includes("\n") - ) { + const caretIsAtTextEnd = + target.selectionStart === target.selectionEnd && + target.selectionEnd === target.value.length; + if (!caretIsAtTextEnd) { return false; } - if ( - event.key === "ArrowDown" && - target.value.slice(target.selectionEnd).includes("\n") - ) { + if (event.key === "ArrowUp" && composerHistory.length === 0) { + return false; + } + if (event.key === "ArrowDown" && composerHistoryIndex === null) { return false; } event.preventDefault(); recallComposerHistory(event.key === "ArrowUp" ? "previous" : "next"); return true; }, - [recallComposerHistory], + [composerHistory.length, composerHistoryIndex, recallComposerHistory], ); useEffect(() => {