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:
@ -138,6 +138,57 @@ function setMeasuredTextOverflow(
|
|||||||
fireEvent(window, new Event("resize"));
|
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", () => {
|
describe("CustomAgentChatView", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@ -532,6 +583,95 @@ describe("CustomAgentChatView", () => {
|
|||||||
await waitFor(() => expect(composer.value).toBe("draft in progress"));
|
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 () => {
|
it("persists prompt history across a custom chat remount", async () => {
|
||||||
const agent = {
|
const agent = {
|
||||||
launchAgentChat: vi.fn(),
|
launchAgentChat: vi.fn(),
|
||||||
@ -3422,7 +3562,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
expect(screen.getByRole("option", { name: /\/clean/ })).toBeTruthy();
|
expect(screen.getByRole("option", { name: /\/clean/ })).toBeTruthy();
|
||||||
expect(screen.getByRole("option", { name: /\/commit/ })).toBeTruthy();
|
expect(screen.getByRole("option", { name: /\/commit/ })).toBeTruthy();
|
||||||
|
|
||||||
fireEvent.keyDown(composer, { key: "ArrowDown" });
|
fireEvent.keyDown(composer, { key: "ArrowUp" });
|
||||||
fireEvent.keyDown(composer, { key: "Enter" });
|
fireEvent.keyDown(composer, { key: "Enter" });
|
||||||
|
|
||||||
await waitFor(() => expect(composer.value).toBe("/commit "));
|
await waitFor(() => expect(composer.value).toBe("/commit "));
|
||||||
|
|||||||
@ -1009,6 +1009,7 @@ export function CustomAgentChatView({
|
|||||||
const draftRef = useRef("");
|
const draftRef = useRef("");
|
||||||
draftRef.current = draft;
|
draftRef.current = draft;
|
||||||
const draftBeforeHistoryNavigationRef = useRef("");
|
const draftBeforeHistoryNavigationRef = useRef("");
|
||||||
|
const placeComposerAtEndAfterHistoryRef = useRef(false);
|
||||||
const attachmentsRef = useRef<AttachmentDraft[]>([]);
|
const attachmentsRef = useRef<AttachmentDraft[]>([]);
|
||||||
attachmentsRef.current = attachments;
|
attachmentsRef.current = attachments;
|
||||||
const imageTokenSeqRef = useRef(0);
|
const imageTokenSeqRef = useRef(0);
|
||||||
@ -1125,7 +1126,8 @@ export function CustomAgentChatView({
|
|||||||
const [composerHistory, setComposerHistory] = useState<string[]>(() =>
|
const [composerHistory, setComposerHistory] = useState<string[]>(() =>
|
||||||
readComposerHistory(composerHistoryKey),
|
readComposerHistory(composerHistoryKey),
|
||||||
);
|
);
|
||||||
const [, setComposerHistoryIndex] = useState<number | null>(null);
|
const [composerHistoryIndex, setComposerHistoryIndex] =
|
||||||
|
useState<number | null>(null);
|
||||||
const displayTurns = turns;
|
const displayTurns = turns;
|
||||||
const sessionLabel = structuredSessionLabel(profile.structuredAdapter);
|
const sessionLabel = structuredSessionLabel(profile.structuredAdapter);
|
||||||
const [requesterDelegation, setRequesterDelegation] =
|
const [requesterDelegation, setRequesterDelegation] =
|
||||||
@ -1205,6 +1207,7 @@ export function CustomAgentChatView({
|
|||||||
const recallComposerHistory = useCallback(
|
const recallComposerHistory = useCallback(
|
||||||
(direction: "previous" | "next") => {
|
(direction: "previous" | "next") => {
|
||||||
if (composerHistory.length === 0) return;
|
if (composerHistory.length === 0) return;
|
||||||
|
placeComposerAtEndAfterHistoryRef.current = true;
|
||||||
setComposerHistoryIndex((current) => {
|
setComposerHistoryIndex((current) => {
|
||||||
if (current === null && direction === "previous") {
|
if (current === null && direction === "previous") {
|
||||||
draftBeforeHistoryNavigationRef.current = draftRef.current;
|
draftBeforeHistoryNavigationRef.current = draftRef.current;
|
||||||
@ -1231,6 +1234,16 @@ export function CustomAgentChatView({
|
|||||||
[composerHistory],
|
[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(
|
const handleComposerHistoryKeyDown = useCallback(
|
||||||
(event: KeyboardEvent<HTMLTextAreaElement>): boolean => {
|
(event: KeyboardEvent<HTMLTextAreaElement>): boolean => {
|
||||||
if (
|
if (
|
||||||
@ -1238,28 +1251,30 @@ export function CustomAgentChatView({
|
|||||||
event.ctrlKey ||
|
event.ctrlKey ||
|
||||||
event.metaKey ||
|
event.metaKey ||
|
||||||
event.shiftKey ||
|
event.shiftKey ||
|
||||||
|
event.nativeEvent.isComposing ||
|
||||||
|
event.keyCode === 229 ||
|
||||||
(event.key !== "ArrowUp" && event.key !== "ArrowDown")
|
(event.key !== "ArrowUp" && event.key !== "ArrowDown")
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const target = event.currentTarget;
|
const target = event.currentTarget;
|
||||||
if (
|
const caretIsAtTextEnd =
|
||||||
event.key === "ArrowUp" &&
|
target.selectionStart === target.selectionEnd &&
|
||||||
target.value.slice(0, target.selectionStart).includes("\n")
|
target.selectionEnd === target.value.length;
|
||||||
) {
|
if (!caretIsAtTextEnd) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (
|
if (event.key === "ArrowUp" && composerHistory.length === 0) {
|
||||||
event.key === "ArrowDown" &&
|
return false;
|
||||||
target.value.slice(target.selectionEnd).includes("\n")
|
}
|
||||||
) {
|
if (event.key === "ArrowDown" && composerHistoryIndex === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
recallComposerHistory(event.key === "ArrowUp" ? "previous" : "next");
|
recallComposerHistory(event.key === "ArrowUp" ? "previous" : "next");
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[recallComposerHistory],
|
[composerHistory.length, composerHistoryIndex, recallComposerHistory],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user