merge(agents): ArrowUp rappelle l'historique chat même en multiligne — #304 (QA verte reconfirmée par Git: npm test ciblé CustomAgentChatView 71/71, npm run typecheck OK, git diff --check OK, npm run build OK fourni par QA; branche fix/304-arrowup-chat-multiline-history commit db312bf; correctif borné 2 fichiers frontend: CustomAgentChatView.tsx + tests — ArrowUp rappelle le message précédent quand le curseur est en fin de dernière ligne, navigation caret conservée sinon; fichiers .ideai/* et scripts/ hors périmètre; aucun push)

This commit is contained in:
2026-09-10 09:53:33 +02:00
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 "));

View File

@ -1009,6 +1009,7 @@ export function CustomAgentChatView({
const draftRef = useRef("");
draftRef.current = draft;
const draftBeforeHistoryNavigationRef = useRef("");
const placeComposerAtEndAfterHistoryRef = useRef(false);
const attachmentsRef = useRef<AttachmentDraft[]>([]);
attachmentsRef.current = attachments;
const imageTokenSeqRef = useRef(0);
@ -1125,7 +1126,8 @@ export function CustomAgentChatView({
const [composerHistory, setComposerHistory] = useState<string[]>(() =>
readComposerHistory(composerHistoryKey),
);
const [, setComposerHistoryIndex] = useState<number | null>(null);
const [composerHistoryIndex, setComposerHistoryIndex] =
useState<number | null>(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<HTMLTextAreaElement>): 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(() => {