fix(frontend): ArrowUp rappelle l'historique chat même en multiligne — #304 (cause: le handler ArrowUp remontait dans le texte dès que le draft faisait plus d'une ligne, au lieu de rappeler le message précédent; fix type terminal: ArrowUp déclenche le rappel historique uniquement quand le curseur est à la fin de la dernière ligne du textarea, sinon navigation caret normale préservée; ArrowDown symétrique en fin de draft pour redescendre dans l'historique; QA verte reconfirmée par Git: npm test -- src/features/agents/CustomAgentChatView.test.tsx 71/71, npm run typecheck OK, git diff --check OK; QA fournie: npm run build OK; fichiers .ideai/* tickets/counter/index/attachments et scripts/ hors périmètre; aucun push)

This commit is contained in:
2026-09-10 09:53:29 +02:00
parent 5b0759630f
commit db312bfde3
2 changed files with 166 additions and 11 deletions

View File

@ -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(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
layoutId="layout-1"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
return agent;
}
async function sendHistoryPrompt(
composer: HTMLTextAreaElement,
agent: ReturnType<typeof renderHistoryChat>,
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<string, unknown>;
}> = [
{ 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 "));