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(() => {