From 3593fcb0df32a09fe8ff2ef2a99e66fb13e67d60 Mon Sep 17 00:00:00 2001 From: dawn <93917549+dawNotPoi@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:55:45 +0800 Subject: [PATCH 1/3] feat(chat): recall sent prompts with ArrowUp/ArrowDown --- .../canvas/canvas-conversation-surface.tsx | 13 ++ src/components/chat/chat-input.tsx | 4 + .../chat/composer/rich-composer.test.tsx | 116 ++++++++++++++ .../chat/composer/rich-composer.tsx | 54 +++++++ src/components/chat/conversation-shell.tsx | 4 + src/components/chat/message-input.test.tsx | 133 +++++++++++++++++ src/components/chat/message-input.tsx | 102 ++++++++++++- .../conversation-detail-panel.tsx | 12 ++ src/lib/composer-history.test.ts | 141 ++++++++++++++++++ src/lib/composer-history.ts | 97 ++++++++++++ 10 files changed, 675 insertions(+), 1 deletion(-) create mode 100644 src/lib/composer-history.test.ts create mode 100644 src/lib/composer-history.ts diff --git a/src/components/canvas/canvas-conversation-surface.tsx b/src/components/canvas/canvas-conversation-surface.tsx index 6f2120b873..bf2716796f 100644 --- a/src/components/canvas/canvas-conversation-surface.tsx +++ b/src/components/canvas/canvas-conversation-surface.tsx @@ -42,8 +42,10 @@ import type { QuestionAnswer, } from "@/lib/types" import { cn, randomUUID } from "@/lib/utils" +import { userPromptHistory } from "@/lib/composer-history" import { useAppWorkspaceStore } from "@/stores/app-workspace-store" import { + getTimelineTurns, useConversationRuntimeActions, useConversationRuntimeStore, } from "@/stores/conversation-runtime-store" @@ -707,6 +709,16 @@ export function CanvasConversationSurface({ return conn.modes?.current_mode_id ?? connectionModes[0]?.id ?? null }, [conn.modes, connectionModes, modeId]) + // Arrow-key history source, read lazily on the first Up/Down (see + // `MessageInput.getSentHistory`) so streaming tokens cost nothing here. + const getSentHistory = useCallback( + () => + userPromptHistory( + getTimelineTurns(effectiveConversationId).map((entry) => entry.turn) + ), + [effectiveConversationId] + ) + const isDraft = dbConversationId == null return ( @@ -749,6 +761,7 @@ export function CanvasConversationSurface({ agentType={agentType} availableCommands={conn.availableCommands ?? []} draftStorageKey={`canvas-draft:${contextKey}`} + getSentHistory={getSentHistory} // The card's own connection key doubles as its composer scope: the // context-usage ring and connection dot in the picker row read it as // a contextKey (they showed nothing at all while it was undefined). diff --git a/src/components/chat/chat-input.tsx b/src/components/chat/chat-input.tsx index a8ce461e35..bf4079741a 100644 --- a/src/components/chat/chat-input.tsx +++ b/src/components/chat/chat-input.tsx @@ -43,6 +43,8 @@ interface ChatInputProps { /** Pass-through: see `MessageInput`. */ folderPickerOverride?: ConversationFolderPickerOverride draftStorageKey?: string | null + /** Pass-through: see `MessageInput.getSentHistory`. */ + getSentHistory?: () => string[] isActive?: boolean /** Show the composer's flowing active-session border. Set only for the active * tab when tiled across multiple sessions; passed through to MessageInput. */ @@ -114,6 +116,7 @@ export const ChatInput = memo(function ChatInput({ attachmentTabId, folderPickerOverride, draftStorageKey, + getSentHistory, isActive, showActiveFlow, queue, @@ -214,6 +217,7 @@ export const ChatInput = memo(function ChatInput({ attachmentTabId={attachmentTabId} folderPickerOverride={folderPickerOverride} draftStorageKey={draftStorageKey} + getSentHistory={getSentHistory} isActive={isActive} showActiveFlow={showActiveFlow} onEnqueue={onEnqueue} diff --git a/src/components/chat/composer/rich-composer.test.tsx b/src/components/chat/composer/rich-composer.test.tsx index 16621ae88a..51ccf061fc 100644 --- a/src/components/chat/composer/rich-composer.test.tsx +++ b/src/components/chat/composer/rich-composer.test.tsx @@ -467,3 +467,119 @@ describe("RichComposer text paste (plain-text schema)", () => { expect(ref.current?.getText()).toBe("") }) }) + +describe("RichComposer prompt-history Arrow routing", () => { + it("routes ArrowUp to onHistoryKeyDown at the document start and consumes it", async () => { + const onHistoryKeyDown = vi.fn(() => true) + const { ref } = await mount({ onHistoryKeyDown }) + act(() => ref.current?.setText("hello")) + act(() => ref.current?.getEditor()?.commands.focus("start")) + const dom = ref.current?.getEditor()?.view.dom as HTMLElement + + const event = pressKey(dom, { key: "ArrowUp" }) + + expect(onHistoryKeyDown).toHaveBeenCalledWith("older", expect.anything()) + expect(event.defaultPrevented).toBe(true) + }) + + it("routes ArrowDown to onHistoryKeyDown at the document end", async () => { + const onHistoryKeyDown = vi.fn(() => true) + const { ref } = await mount({ onHistoryKeyDown }) + act(() => ref.current?.setText("hello")) + act(() => ref.current?.getEditor()?.commands.focus("end")) + const dom = ref.current?.getEditor()?.view.dom as HTMLElement + + const event = pressKey(dom, { key: "ArrowDown" }) + + expect(onHistoryKeyDown).toHaveBeenCalledWith("newer", expect.anything()) + expect(event.defaultPrevented).toBe(true) + }) + + it("leaves the Arrow keys to the caret away from the edge", async () => { + const onHistoryKeyDown = vi.fn(() => true) + const { ref } = await mount({ onHistoryKeyDown }) + act(() => ref.current?.setText("hello")) + // Caret at the END: ArrowUp is not "older" here, so it stays a caret move + // and the host is never asked. + act(() => ref.current?.getEditor()?.commands.focus("end")) + const dom = ref.current?.getEditor()?.view.dom as HTMLElement + + const event = pressKey(dom, { key: "ArrowUp" }) + + expect(onHistoryKeyDown).not.toHaveBeenCalled() + expect(event.defaultPrevented).toBe(false) + }) + + it("keeps Arrow keys for the IME while a composition is in flight", async () => { + const onHistoryKeyDown = vi.fn(() => true) + const { ref } = await mount({ onHistoryKeyDown }) + act(() => ref.current?.setText("ni")) + act(() => ref.current?.getEditor()?.commands.focus("start")) + const dom = ref.current?.getEditor()?.view.dom as HTMLElement + + const event = pressKey(dom, { key: "ArrowUp", isComposing: true }) + + expect(onHistoryKeyDown).not.toHaveBeenCalled() + expect(event.defaultPrevented).toBe(false) + }) + + it("defers to an open menu before history", async () => { + const onHistoryKeyDown = vi.fn(() => true) + const onExternalMenuKeyDown = vi.fn(() => true) + const { ref } = await mount({ + onHistoryKeyDown, + onExternalMenuKeyDown, + isExternalMenuOpen: true, + }) + act(() => ref.current?.setText("hello")) + act(() => ref.current?.getEditor()?.commands.focus("start")) + const dom = ref.current?.getEditor()?.view.dom as HTMLElement + + pressKey(dom, { key: "ArrowUp" }) + + expect(onExternalMenuKeyDown).toHaveBeenCalled() + expect(onHistoryKeyDown).not.toHaveBeenCalled() + }) + + it("does not consume the key when the host declines", async () => { + const onHistoryKeyDown = vi.fn(() => false) + const { ref } = await mount({ onHistoryKeyDown }) + act(() => ref.current?.setText("hello")) + act(() => ref.current?.getEditor()?.commands.focus("start")) + const dom = ref.current?.getEditor()?.view.dom as HTMLElement + + const event = pressKey(dom, { key: "ArrowUp" }) + + expect(onHistoryKeyDown).toHaveBeenCalledWith("older", expect.anything()) + expect(event.defaultPrevented).toBe(false) + }) + + it("keeps Shift+Arrow for selection instead of history", async () => { + const onHistoryKeyDown = vi.fn(() => true) + const { ref } = await mount({ onHistoryKeyDown }) + act(() => ref.current?.setText("hello")) + act(() => ref.current?.getEditor()?.commands.focus("start")) + const dom = ref.current?.getEditor()?.view.dom as HTMLElement + + const event = pressKey(dom, { key: "ArrowUp", shiftKey: true }) + + expect(onHistoryKeyDown).not.toHaveBeenCalled() + expect(event.defaultPrevented).toBe(false) + }) + + it("keeps Ctrl/Alt+Arrow for word and line jumps", async () => { + const onHistoryKeyDown = vi.fn(() => true) + const { ref } = await mount({ onHistoryKeyDown }) + act(() => ref.current?.setText("hello")) + act(() => ref.current?.getEditor()?.commands.focus("start")) + const dom = ref.current?.getEditor()?.view.dom as HTMLElement + + expect( + pressKey(dom, { key: "ArrowUp", ctrlKey: true }).defaultPrevented + ).toBe(false) + expect( + pressKey(dom, { key: "ArrowUp", altKey: true }).defaultPrevented + ).toBe(false) + expect(onHistoryKeyDown).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/chat/composer/rich-composer.tsx b/src/components/chat/composer/rich-composer.tsx index 4e897834ae..f4cfbdf336 100644 --- a/src/components/chat/composer/rich-composer.tsx +++ b/src/components/chat/composer/rich-composer.tsx @@ -15,6 +15,7 @@ import { type Editor, type JSONContent } from "@tiptap/core" import { EditorContent, useEditor } from "@tiptap/react" import { exitSuggestion } from "@tiptap/suggestion" +import type { HistoryDirection } from "@/lib/composer-history" import { isImeCompositionKey } from "@/lib/ime-composition" import { NO_KNOWN_INVOCATIONS, @@ -192,6 +193,21 @@ export interface RichComposerProps { * false (e.g. a letter that filters the list) to let normal editing proceed. */ onExternalMenuKeyDown?: (event: KeyboardEvent) => boolean + /** + * Arrow-key prompt history (the chat composer's Up/Down recall). Called for a + * bare ArrowUp/ArrowDown BEFORE the caret moves, but ONLY when the collapsed + * selection already sits at the document's first (`"older"`) or last + * (`"newer"`) position — so the caret keeps moving line by line inside a + * multi-line entry, and only a press at an edge switches prompts. Return true + * to consume the key. + * + * Optional on purpose: the task and automation composers pass no handler and + * keep the editor's default Arrow behaviour. + */ + onHistoryKeyDown?: ( + direction: HistoryDirection, + event: KeyboardEvent + ) => boolean /** * Called on paste before the editor handles it. Return true when the paste was * consumed out-of-band (e.g. an image/file became an attachment) so the editor @@ -247,6 +263,7 @@ export const RichComposer = forwardRef( newlineShortcut, isExternalMenuOpen, onExternalMenuKeyDown, + onHistoryKeyDown, onPasteFiles, onDropFiles, onPlainPaste, @@ -272,6 +289,7 @@ export const RichComposer = forwardRef( const newlineShortcutRef = useRef(newlineShortcut) const isExternalMenuOpenRef = useRef(isExternalMenuOpen) const onExternalMenuKeyDownRef = useRef(onExternalMenuKeyDown) + const onHistoryKeyDownRef = useRef(onHistoryKeyDown) const onPasteFilesRef = useRef(onPasteFiles) const onDropFilesRef = useRef(onDropFiles) const onPlainPasteRef = useRef(onPlainPaste) @@ -292,6 +310,7 @@ export const RichComposer = forwardRef( newlineShortcutRef.current = newlineShortcut isExternalMenuOpenRef.current = isExternalMenuOpen onExternalMenuKeyDownRef.current = onExternalMenuKeyDown + onHistoryKeyDownRef.current = onHistoryKeyDown onPasteFilesRef.current = onPasteFiles onDropFilesRef.current = onDropFiles onPlainPasteRef.current = onPlainPaste @@ -385,6 +404,41 @@ export const RichComposer = forwardRef( if (isExternalMenuOpenRef.current) { return onExternalMenuKeyDownRef.current?.(event) ?? false } + // Prompt history (chat composer only): a bare Up/Down at the + // document's first/last position steps through sent prompts; anywhere + // else the editor keeps the caret movement, which is what makes a + // multi-line recalled message navigable line by line. Placed after + // the menus so an open panel always wins, and after the IME guard + // above so a CJK candidate list keeps its own Arrow keys. + // + // A modifier keeps the native meaning: Shift+Arrow extends the + // selection (empty until it spans), and Ctrl/Alt+Arrow is a + // word/line jump — none of them is "recall a prompt". + if ( + onHistoryKeyDownRef.current && + (event.key === "ArrowUp" || event.key === "ArrowDown") && + !event.shiftKey && + !event.altKey && + !event.ctrlKey && + !event.metaKey + ) { + const { selection } = view.state + const older = event.key === "ArrowUp" + const atBoundary = + selection.empty && + (older + ? selection.$from.depth === 1 && + selection.$from.parentOffset === 0 + : selection.$to.depth === 1 && + selection.$to.parentOffset === + selection.$to.parent.content.size) + if (atBoundary) { + return onHistoryKeyDownRef.current( + older ? "older" : "newer", + event + ) + } + } // Paste without formatting: Ctrl/⌘+Shift+V routes to the host, which // owns the clipboard read. Consume the key (suppressing the browser's // native rich paste) only when the host takes over; otherwise return diff --git a/src/components/chat/conversation-shell.tsx b/src/components/chat/conversation-shell.tsx index ef5af5a24f..46121479d0 100644 --- a/src/components/chat/conversation-shell.tsx +++ b/src/components/chat/conversation-shell.tsx @@ -94,6 +94,8 @@ interface ConversationShellProps { /** Pass-through: see `MessageInput`. */ folderPickerOverride?: ConversationFolderPickerOverride draftStorageKey?: string | null + /** Pass-through: see `MessageInput.getSentHistory`. */ + getSentHistory?: () => string[] hideInput?: boolean /** Optional banner rendered in the composer dock, where the input sits. * Used with `hideInput` to explain WHY the composer is unavailable (e.g. @@ -184,6 +186,7 @@ export function ConversationShell({ attachmentTabId, folderPickerOverride, draftStorageKey, + getSentHistory, hideInput = false, composerBanner, feedbackList, @@ -361,6 +364,7 @@ export function ConversationShell({ attachmentTabId={attachmentTabId} folderPickerOverride={folderPickerOverride} draftStorageKey={draftStorageKey} + getSentHistory={getSentHistory} isActive={isActive} showActiveFlow={showActiveFlow} queue={queue} diff --git a/src/components/chat/message-input.test.tsx b/src/components/chat/message-input.test.tsx index 7d21880ce6..3d58df8598 100644 --- a/src/components/chat/message-input.test.tsx +++ b/src/components/chat/message-input.test.tsx @@ -1734,3 +1734,136 @@ describe("MessageInput right-click token selection", () => { expect(selectedText(editor)).toBe("adam@example.com") }) }) + +describe("MessageInput prompt history", () => { + async function mountWithHistory( + history: string[], + props: Partial> = {} + ) { + renderInput({ getSentHistory: () => history, ...props }) + await waitFor( + () => expect(composerHandle.current?.getEditor()).toBeTruthy(), + { timeout: 5000 } + ) + const handle = composerHandle.current + const editor = handle?.getEditor() + if (!handle || !editor) throw new Error("composer editor not mounted") + return { handle, editor } + } + + function press(editor: Editor, key: string) { + act(() => { + ;(editor.view.dom as HTMLElement).dispatchEvent( + new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }) + ) + }) + } + + it("recalls sent prompts on Up and restores the draft on Down", async () => { + const { handle, editor } = await mountWithHistory(["first", "latest"]) + + act(() => handle.setText("my draft")) + act(() => editor.commands.focus("start")) + press(editor, "ArrowUp") + expect(handle.getText()).toBe("latest") + + act(() => editor.commands.focus("start")) + press(editor, "ArrowUp") + expect(handle.getText()).toBe("first") + + act(() => editor.commands.focus("end")) + press(editor, "ArrowDown") + expect(handle.getText()).toBe("latest") + + // Past the newest entry the original draft comes back. + act(() => editor.commands.focus("end")) + press(editor, "ArrowDown") + expect(handle.getText()).toBe("my draft") + }) + + it("editing a recalled prompt leaves history mode", async () => { + const { handle, editor } = await mountWithHistory(["first", "latest"]) + + act(() => editor.commands.focus("start")) + press(editor, "ArrowUp") + expect(handle.getText()).toBe("latest") + + // Type into the recalled entry, then Down must NOT be treated as "newer" + // past the newest — history mode ended with the edit. + act(() => editor.commands.focus("end")) + act(() => editor.commands.insertContent(" edited")) + expect(handle.getText()).toBe("latest edited") + act(() => editor.commands.focus("end")) + press(editor, "ArrowDown") + expect(handle.getText()).toBe("latest edited") + }) + + it("does nothing on Up when the session has no history", async () => { + const { handle, editor } = await mountWithHistory([]) + + act(() => handle.setText("my draft")) + act(() => editor.commands.focus("start")) + press(editor, "ArrowUp") + expect(handle.getText()).toBe("my draft") + }) + + it("steps on the edge and lands on the edge it travelled from", async () => { + const { handle, editor } = await mountWithHistory([ + "first", + "older\nmulti\nline", + "newest\nmulti\nline", + ]) + + act(() => handle.setText("DRAFT")) + act(() => editor.commands.focus("start")) + press(editor, "ArrowUp") + expect(handle.getText()).toBe("newest\nmulti\nline") + + // Recalling lands at the TOP, so the same key keeps going older without the + // caret having to be walked anywhere. + press(editor, "ArrowUp") + expect(handle.getText()).toBe("older\nmulti\nline") + press(editor, "ArrowUp") + expect(handle.getText()).toBe("first") + + // Turning around means walking to the bottom edge first — until it gets + // there the caret is the editor's, not the history's. (jsdom has no native + // caret movement, so that walk is simulated with focus("end").) + act(() => editor.commands.focus("end")) + press(editor, "ArrowDown") + expect(handle.getText()).toBe("older\nmulti\nline") + press(editor, "ArrowDown") + expect(handle.getText()).toBe("newest\nmulti\nline") + press(editor, "ArrowDown") + expect(handle.getText()).toBe("DRAFT") + }) + + it("does not switch prompts while the caret is inside a multi-line entry", async () => { + const { handle, editor } = await mountWithHistory([ + "older", + "newest\nmulti\nline", + ]) + + act(() => editor.commands.focus("start")) + press(editor, "ArrowUp") + expect(handle.getText()).toBe("newest\nmulti\nline") + + // The caret is at the TOP, so Down belongs to the caret, not the history + // (jsdom has no native caret movement, so the observable is "no recall"). + press(editor, "ArrowDown") + expect(handle.getText()).toBe("newest\nmulti\nline") + }) + + it("leaves the arrows to the caret while editing a queued message", async () => { + const { handle, editor } = await mountWithHistory(["older", "newest"], { + isEditingQueueItem: true, + }) + + act(() => handle.setText("queued edit")) + act(() => editor.commands.focus("start")) + press(editor, "ArrowUp") + + // A recall here would replace the queued message being edited. + expect(handle.getText()).toBe("queued edit") + }) +}) diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 82508b0ac5..5a95ab8946 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -47,6 +47,10 @@ import { useShortcutSettings } from "@/hooks/use-shortcut-settings" import { imageFilesFromClipboardApi } from "@/lib/clipboard-images" import { toErrorMessage } from "@/lib/app-error" import { isNoActiveTurnRejection } from "@/lib/turn-busy" +import { + stepComposerHistory, + type HistoryDirection, +} from "@/lib/composer-history" import { ServerFileBrowserDialog } from "@/components/shared/server-file-browser-dialog" import { toast } from "sonner" import type { @@ -242,6 +246,15 @@ interface MessageInputProps { /** Grey out the live-feedback "+" entry when a note can't be sent right now * (no active turn / agent lacks the tool). */ feedbackAddDisabled?: boolean + /** + * The current session's user prompts, oldest first — the ArrowUp/ArrowDown + * recall history. A GETTER rather than an array on purpose: prompts are + * append-only and the runtime store updates on every streaming token, so a + * reactive prop would recompute (and re-render the composer) per token for a + * list that is only read when the user presses Up/Down. Absent for a surface + * with no session, or a brand-new one — which then simply has no history. + */ + getSentHistory?: () => string[] injectContent?: ComposerInjectContent | null onInjectConsumed?: () => void } @@ -346,6 +359,7 @@ export function MessageInput({ feedbackAddDisabled, injectContent, onInjectConsumed, + getSentHistory, }: MessageInputProps) { const t = useTranslations("Folder.chat.messageInput") const tQueue = useTranslations("Folder.chat.messageQueue") @@ -389,6 +403,26 @@ export function MessageInput({ const effectiveDraftStorageKey = draftStorageKey ?? null const resolvedPlaceholder = placeholder ?? t("askAnything") const editorRef = useRef(null) + // Prompt-history navigation. `historyRef` is seeded from `getSentHistory` + // lazily, the first time the user steps into history, so a session that never + // uses it pays nothing. + const historyRef = useRef([]) + const historyIndexRef = useRef(null) + const historyDraftRef = useRef<{ + json: JSONContent | null + text: string + } | null>(null) + // True while the history itself writes the document, so the resulting + // onChange is not mistaken for a user edit that ends navigation. + const applyingHistoryRef = useRef(false) + // A conversation switch ends navigation: the recalled entries and the stashed + // draft belong to the session that was on screen. The next Up re-seeds from + // the new session's own prompts. + useEffect(() => { + historyIndexRef.current = null + historyDraftRef.current = null + historyRef.current = [] + }, [effectiveDraftStorageKey]) const containerRef = useRef(null) // The editor owns the content now; this mirror of its empty state drives the // send button and `hasSendableContent`. @@ -716,11 +750,74 @@ export function MessageInput({ }, [skillPrefix, composerReady]) const handleComposerChange = useCallback(() => { + // The history's own writes are not edits. They must not end navigation, and + // they must not be saved as the draft: overwriting the stored draft with a + // recalled prompt would lose what the user had typed if they closed the tab + // without stepping back down. An actual edit falls into the branch below + // and saves normally. + if (!applyingHistoryRef.current) { + if (historyIndexRef.current !== null) { + historyIndexRef.current = null + historyDraftRef.current = null + } + scheduleDraftSave() + } syncComposerEmpty() - scheduleDraftSave() detectSlashTriggerRef.current?.() }, [syncComposerEmpty, scheduleDraftSave]) + // Arrow-key prompt history. RichComposer only calls this from the document + // edge, so the caret keeps moving line by line inside a multi-line entry. A + // step lands on the edge it travelled FROM — the top for older, the bottom + // for newer — so pressing the same key again keeps going. Editing ends the + // navigation (see `handleComposerChange`); re-entry always starts at the + // newest prompt. Returns true to consume the key. + const handleHistoryKeyDown = useCallback( + (direction: HistoryDirection): boolean => { + // Queue-edit mode owns the composer's content: recalling a chat prompt + // would replace the queued message being edited. + if (isEditingQueueItem) return false + if (direction === "older" && historyIndexRef.current === null) { + // Fresh navigation: seed here so a prompt sent since the last one is + // included, then stash the box before the first recall replaces it. + historyRef.current = getSentHistory?.() ?? [] + } + const step = stepComposerHistory( + historyRef.current, + historyIndexRef.current, + direction + ) + if (step.action === "none") { + // Keep the key while a navigation is open; with nothing to recall, let + // it fall through to the editor's caret movement. + return historyIndexRef.current !== null + } + if (step.enters) { + historyDraftRef.current = { + json: editorRef.current?.getJSON() ?? null, + text: editorRef.current?.getText() ?? "", + } + } + applyingHistoryRef.current = true + if (step.action === "show") { + editorRef.current?.setText(step.text ?? "") + } else { + const draft = historyDraftRef.current + if (draft?.json) editorRef.current?.setDoc(draft.json) + else editorRef.current?.setText(draft?.text ?? "") + historyDraftRef.current = null + } + // Land on the edge we travelled from, so the SAME key keeps stepping. + editorRef.current + ?.getEditor() + ?.commands.focus(direction === "older" ? "start" : "end") + applyingHistoryRef.current = false + historyIndexRef.current = step.index + return true + }, + [getSentHistory, isEditingQueueItem] + ) + const handleComposerReady = useCallback(() => { setComposerReady(true) }, []) @@ -1295,6 +1392,8 @@ export function MessageInput({ setComposerEmpty(true) clearAttachments() closeSlashMenu() + historyIndexRef.current = null + historyDraftRef.current = null }, [clearAttachments, closeSlashMenu]) const handleSend = useCallback(() => { @@ -1996,6 +2095,7 @@ export function MessageInput({ newlineShortcut={shortcuts.newline_in_message} isExternalMenuOpen={slashMenuVisible} onExternalMenuKeyDown={handleExternalMenuKeyDown} + onHistoryKeyDown={handleHistoryKeyDown} className="min-h-0 flex-1" />
diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index 2c18e3ed28..646a7fe525 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -120,6 +120,7 @@ import { lastUserPromptText, type SessionFailureAction, } from "@/lib/session-failures" +import { userPromptHistory } from "@/lib/composer-history" import { contentBlocksFromUserMessage } from "@/lib/user-message-blocks" import { getAgentLabel } from "@/lib/custom-agents" import { @@ -1946,6 +1947,16 @@ const ConversationTabView = memo(function ConversationTabView({ // and the action would silently do nothing. const composerAvailable = !isWelcomeMode && !acpLoadError + // Arrow-key history source: read lazily when the user actually steps into + // history, so streaming tokens neither recompute it nor re-render the panel. + const getSentHistory = useCallback( + () => + userPromptHistory( + getTimelineTurns(effectiveConversationId).map((entry) => entry.turn) + ), + [effectiveConversationId] + ) + const messageListNode = ( diff --git a/src/lib/composer-history.test.ts b/src/lib/composer-history.test.ts new file mode 100644 index 0000000000..8f9bee2ab5 --- /dev/null +++ b/src/lib/composer-history.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest" + +import { + stepComposerHistory, + userPromptHistory, + type HistoryStep, +} from "./composer-history" +import type { ContentBlock, MessageTurn, TurnRole } from "./types" + +function turn(role: TurnRole, blocks: ContentBlock[]): MessageTurn { + return { id: `${role}-${blocks.length}`, role, blocks, timestamp: "t" } +} + +const text = (value: string): ContentBlock => ({ type: "text", text: value }) +const image: ContentBlock = { type: "image", data: "", mime_type: "image/png" } + +/** The step a caller would apply, for a terse assertion. */ +function step( + history: readonly string[], + index: number | null, + direction: "older" | "newer" +): HistoryStep { + return stepComposerHistory(history, index, direction) +} + +describe("userPromptHistory", () => { + it("returns user prompts oldest first and ignores the agent's replies", () => { + const turns = [ + turn("user", [text("first")]), + turn("assistant", [text("sure")]), + turn("user", [text("second")]), + ] + expect(userPromptHistory(turns)).toEqual(["first", "second"]) + }) + + it("joins a multi-block prompt and trims it", () => { + expect( + userPromptHistory([ + turn("user", [text(" line one"), text("line two ")]), + ]) + ).toEqual(["line one\nline two"]) + }) + + it("skips image-only and blank turns — there is nothing to recall", () => { + const turns = [ + turn("user", [image]), + turn("user", [text(" ")]), + turn("user", [text("real")]), + ] + expect(userPromptHistory(turns)).toEqual(["real"]) + }) + + it("collapses consecutive duplicates but keeps a non-adjacent repeat", () => { + const turns = [ + turn("user", [text("a")]), + turn("user", [text("a")]), + turn("user", [text("b")]), + turn("user", [text("a")]), + ] + expect(userPromptHistory(turns)).toEqual(["a", "b", "a"]) + }) + + it("has no history without turns — a new session recalls nothing", () => { + expect(userPromptHistory(undefined)).toEqual([]) + expect(userPromptHistory([])).toEqual([]) + }) +}) + +describe("stepComposerHistory", () => { + const history = ["one", "two", "three"] + + it("enters at the newest entry and asks the caller to stash the draft", () => { + expect(step(history, null, "older")).toEqual({ + action: "show", + text: "three", + index: 2, + enters: true, + }) + }) + + it("walks older and stops at the oldest", () => { + expect(step(history, 2, "older")).toEqual({ + action: "show", + text: "two", + index: 1, + enters: false, + }) + expect(step(history, 1, "older")).toEqual({ + action: "show", + text: "one", + index: 0, + enters: false, + }) + // At the oldest there is nothing further: stay put, keep consuming the key. + expect(step(history, 0, "older")).toEqual({ + action: "none", + index: 0, + enters: false, + }) + }) + + it("walks newer and restores the draft past the newest", () => { + expect(step(history, 0, "newer")).toEqual({ + action: "show", + text: "two", + index: 1, + enters: false, + }) + expect(step(history, 1, "newer")).toEqual({ + action: "show", + text: "three", + index: 2, + enters: false, + }) + expect(step(history, 2, "newer")).toEqual({ + action: "restore", + index: null, + enters: false, + }) + }) + + it("falls through while not navigating", () => { + // Down with nothing recalled belongs to the caret, not the history. + expect(step(history, null, "newer")).toEqual({ + action: "none", + index: null, + enters: false, + }) + // Up with no history at all must also fall through. + expect(step([], null, "older")).toEqual({ + action: "none", + index: null, + enters: false, + }) + expect(step([], 0, "newer")).toEqual({ + action: "none", + index: null, + enters: false, + }) + }) +}) diff --git a/src/lib/composer-history.ts b/src/lib/composer-history.ts new file mode 100644 index 0000000000..d5757dfb2d --- /dev/null +++ b/src/lib/composer-history.ts @@ -0,0 +1,97 @@ +import type { ContentBlock, MessageTurn } from "@/lib/types" + +/** Text of one turn's text blocks — empty (image-only) turns read as `""`. */ +function textOf(turn: MessageTurn): string { + return turn.blocks + .filter( + (b): b is Extract => b.type === "text" + ) + .map((b) => b.text) + .join("\n") + .trim() +} + +/** + * Every user prompt of a session, oldest first — what the composer's Up/Down + * history cycles. + * + * Derived from the session's own turns rather than persisted separately, which + * is exactly the behaviour the issue asks for: a new session has no turns and + * therefore no history, while a resumed one gets its real prompts back. Empty + * (image-only) turns are skipped, and consecutive duplicates collapse — sending + * the same text twice is one entry to step through, not two. + */ +export function userPromptHistory( + turns: readonly MessageTurn[] | undefined +): string[] { + if (!turns) return [] + const out: string[] = [] + for (const turn of turns) { + if (turn.role !== "user") continue + const text = textOf(turn) + if (!text) continue + if (out[out.length - 1] === text) continue + out.push(text) + } + return out +} + +export type HistoryDirection = "older" | "newer" + +export interface HistoryStep { + /** What the composer should do with the editor. */ + action: "show" | "restore" | "none" + /** The entry to show when `action === "show"`. */ + text?: string + /** The index to keep; `null` means "not navigating". */ + index: number | null + /** True when this step ENTERS navigation — the caller must stash the draft. */ + enters: boolean +} + +/** + * One ArrowUp ("older") / ArrowDown ("newer") step. Pure, so the transition + * rules are unit-tested without driving a ProseMirror view. + * + * `index === null` means "not navigating". An "older" step enters at the newest + * entry and asks the caller to stash what is currently in the box; a "newer" + * step with `index === null` does nothing (there is nothing to move forward to, + * so the key must fall through to normal caret movement). Stepping newer past + * the newest returns "restore": put the stashed draft back and leave navigation. + * + * The caller owns the draft and the editor; this only decides the transition. + */ +export function stepComposerHistory( + history: readonly string[], + index: number | null, + direction: HistoryDirection +): HistoryStep { + const last = history.length - 1 + // A history that emptied out (the session was reset mid-navigation) is not + // navigable either way: leave navigation and let the key fall through. + if (last < 0) return { action: "none", index: null, enters: false } + if (direction === "older") { + if (index === null) { + return { action: "show", text: history[last], index: last, enters: true } + } + if (index > 0) { + return { + action: "show", + text: history[index - 1], + index: index - 1, + enters: false, + } + } + return { action: "none", index, enters: false } + } + if (index === null) return { action: "none", index: null, enters: false } + if (index < last) { + return { + action: "show", + text: history[index + 1], + index: index + 1, + enters: false, + } + } + return { action: "restore", index: null, enters: false } +} From 7444874ca612cfe30f7608af1e2d7cca9b516f46 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 17 Sep 2026 07:44:46 +0800 Subject: [PATCH 2/3] fix(chat): recall prompts only at the document's own edges The Arrow-key prompt history asked "is the caret at the start/end of its block?", which is the same question as "start/end of the document" only while the box holds a single paragraph. A native paste can leave several in it, and there the start of paragraph two answered yes: ArrowUp swapped the whole draft for a recalled prompt instead of moving up a line. Ask ProseMirror for the document's own edges instead. --- .../chat/composer/rich-composer.test.tsx | 44 +++++++++++++++++++ .../chat/composer/rich-composer.tsx | 15 ++++--- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/components/chat/composer/rich-composer.test.tsx b/src/components/chat/composer/rich-composer.test.tsx index 51ccf061fc..7f644f7c77 100644 --- a/src/components/chat/composer/rich-composer.test.tsx +++ b/src/components/chat/composer/rich-composer.test.tsx @@ -510,6 +510,50 @@ describe("RichComposer prompt-history Arrow routing", () => { expect(event.defaultPrevented).toBe(false) }) + it("keeps the Arrow keys to the caret between paragraphs of one document", async () => { + // A native paste can leave the box holding SEVERAL paragraphs. The start of + // the second one is the start of its block but not of the document, so it + // is a plain "move up a line" — recalling there would swap the whole draft + // out from under the caret. + const onHistoryKeyDown = vi.fn(() => true) + const { ref } = await mount({ onHistoryKeyDown }) + act(() => + ref.current?.setDoc({ + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "one" }] }, + { type: "paragraph", content: [{ type: "text", text: "two" }] }, + ], + }) + ) + const editor = ref.current?.getEditor() + const dom = editor?.view.dom as HTMLElement + + // Start of paragraph two. + act(() => editor?.commands.setTextSelection(6)) + expect(pressKey(dom, { key: "ArrowUp" }).defaultPrevented).toBe(false) + // End of paragraph one. + act(() => editor?.commands.setTextSelection(4)) + expect(pressKey(dom, { key: "ArrowDown" }).defaultPrevented).toBe(false) + + expect(onHistoryKeyDown).not.toHaveBeenCalled() + + // The document's own edges still route: start of the first paragraph, + // end of the last. + act(() => editor?.commands.setTextSelection(1)) + pressKey(dom, { key: "ArrowUp" }) + expect(onHistoryKeyDown).toHaveBeenLastCalledWith( + "older", + expect.anything() + ) + act(() => editor?.commands.setTextSelection(9)) + pressKey(dom, { key: "ArrowDown" }) + expect(onHistoryKeyDown).toHaveBeenLastCalledWith( + "newer", + expect.anything() + ) + }) + it("keeps Arrow keys for the IME while a composition is in flight", async () => { const onHistoryKeyDown = vi.fn(() => true) const { ref } = await mount({ onHistoryKeyDown }) diff --git a/src/components/chat/composer/rich-composer.tsx b/src/components/chat/composer/rich-composer.tsx index f4cfbdf336..2304bed820 100644 --- a/src/components/chat/composer/rich-composer.tsx +++ b/src/components/chat/composer/rich-composer.tsx @@ -12,6 +12,7 @@ import { type RefObject, } from "react" import { type Editor, type JSONContent } from "@tiptap/core" +import { Selection } from "@tiptap/pm/state" import { EditorContent, useEditor } from "@tiptap/react" import { exitSuggestion } from "@tiptap/suggestion" @@ -422,16 +423,18 @@ export const RichComposer = forwardRef( !event.ctrlKey && !event.metaKey ) { - const { selection } = view.state + const { selection, doc } = view.state const older = event.key === "ArrowUp" + // The edge that counts is the DOCUMENT's, not the current block's. + // The box is normally one paragraph of hard breaks, but a native + // paste can leave several paragraphs in it (see the quote + // decoration's scan) — and in one of those, the start of paragraph + // two is an ordinary "move up a line", not a recall. const atBoundary = selection.empty && (older - ? selection.$from.depth === 1 && - selection.$from.parentOffset === 0 - : selection.$to.depth === 1 && - selection.$to.parentOffset === - selection.$to.parent.content.size) + ? selection.from === Selection.atStart(doc).from + : selection.to === Selection.atEnd(doc).to) if (atBoundary) { return onHistoryKeyDownRef.current( older ? "older" : "newer", From ce1dd3c500c808d9f9b2da6fa2b259d614bfae04 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 17 Sep 2026 07:44:52 +0800 Subject: [PATCH 3/3] fix(chat): land the pending draft save before a recall replaces it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit History writes deliberately skip `scheduleDraftSave` so a recalled prompt is never stored as the draft — but a save the preceding keystrokes had already scheduled still fired ~300ms later, by which time the document WAS the recalled prompt. Typing a draft and recalling within that window persisted the recalled text over it, and the stash that ArrowDown restores only lives in memory, so switching tabs from there lost what was typed. Flush that pending save first, against the document it was scheduled for. --- src/components/chat/message-input.test.tsx | 41 +++++++++++++++++++ src/components/chat/message-input.tsx | 47 ++++++++++++++++------ 2 files changed, 76 insertions(+), 12 deletions(-) diff --git a/src/components/chat/message-input.test.tsx b/src/components/chat/message-input.test.tsx index 3d58df8598..b00ad45e4b 100644 --- a/src/components/chat/message-input.test.tsx +++ b/src/components/chat/message-input.test.tsx @@ -16,6 +16,10 @@ import { afterEach, describe, expect, it, vi } from "vitest" import type { RichComposerHandle } from "./composer/rich-composer" import { serializeDocToText } from "./composer/to-prompt-blocks" +import { + clearMessageInputDraftV2, + loadMessageInputDraftV2, +} from "@/lib/message-input-draft" import { emitAttachFileToSession, emitAttachSessionToSession, @@ -1781,6 +1785,43 @@ describe("MessageInput prompt history", () => { expect(handle.getText()).toBe("my draft") }) + it("recalls into an empty composer, where the caret is at both edges", async () => { + const { handle, editor } = await mountWithHistory(["first", "latest"]) + + // The common case: nothing typed yet. Down must still fall through (there + // is nothing recalled to move forward from), Up must recall. + press(editor, "ArrowDown") + expect(handle.getText()).toBe("") + press(editor, "ArrowUp") + expect(handle.getText()).toBe("latest") + }) + + it("persists the draft the first recall replaces", async () => { + // The stash that ArrowDown restores lives only in memory, and the draft + // save is debounced: a recall that lands inside that window must flush the + // typed draft rather than let the timer write the RECALLED prompt over it + // (a tab switch from there would lose what the user typed). + const draftKey = "test:history-recall-draft" + clearMessageInputDraftV2(draftKey) + const { handle, editor } = await mountWithHistory(["first", "latest"], { + draftStorageKey: draftKey, + }) + + act(() => editor.commands.insertContent("my draft")) + act(() => editor.commands.focus("start")) + press(editor, "ArrowUp") + expect(handle.getText()).toBe("latest") + + // Past the debounce: whatever was going to be written has been written. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 350)) + }) + const stored = loadMessageInputDraftV2(draftKey) + expect(JSON.stringify(stored)).toContain("my draft") + expect(JSON.stringify(stored)).not.toContain("latest") + clearMessageInputDraftV2(draftKey) + }) + it("editing a recalled prompt leaves history mode", async () => { const { handle, editor } = await mountWithHistory(["first", "latest"]) diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 5a95ab8946..2b4a390949 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -528,6 +528,19 @@ export function MessageInput({ // Markdown) ~300ms after the last change so inline reference badges survive a // reload — a Markdown round-trip would downgrade them to plain links. const draftSaveTimerRef = useRef(null) + /** Persist (or clear) the draft from the document as it stands right now. */ + const writeDraftNow = useCallback(() => { + const ed = editorRef.current + if (!ed || !effectiveDraftStorageKey) return + if (ed.isEmpty()) { + clearMessageInputDraftV2(effectiveDraftStorageKey) + } else { + saveMessageInputDraftV2( + effectiveDraftStorageKey, + stripEmbeddedReferences(ed.getJSON()) + ) + } + }, [effectiveDraftStorageKey]) const scheduleDraftSave = useCallback(() => { if (typeof window === "undefined") return if (!effectiveDraftStorageKey || isEditingQueueItem) return @@ -536,18 +549,22 @@ export function MessageInput({ } draftSaveTimerRef.current = window.setTimeout(() => { draftSaveTimerRef.current = null - const ed = editorRef.current - if (!ed || !effectiveDraftStorageKey) return - if (ed.isEmpty()) { - clearMessageInputDraftV2(effectiveDraftStorageKey) - } else { - saveMessageInputDraftV2( - effectiveDraftStorageKey, - stripEmbeddedReferences(ed.getJSON()) - ) - } + writeDraftNow() }, 300) - }, [effectiveDraftStorageKey, isEditingQueueItem]) + }, [effectiveDraftStorageKey, isEditingQueueItem, writeDraftNow]) + /** + * Land a *pending* debounced save immediately, before something other than + * the user replaces the document. A save scheduled by the keystrokes that + * preceded a prompt recall would otherwise fire ~300ms later — after the + * recall — and store the recalled prompt in place of the draft it replaced. + */ + const flushDraftSave = useCallback(() => { + if (typeof window === "undefined") return + if (draftSaveTimerRef.current == null) return + window.clearTimeout(draftSaveTimerRef.current) + draftSaveTimerRef.current = null + writeDraftNow() + }, [writeDraftNow]) useEffect(() => { return () => { @@ -797,6 +814,12 @@ export function MessageInput({ json: editorRef.current?.getJSON() ?? null, text: editorRef.current?.getText() ?? "", } + // A save the typing just before this keypress scheduled would fire + // ~300ms from now, AFTER the recall, and persist the recalled prompt + // as the draft. Land it on the document it was scheduled for instead — + // the stash above only lives in memory, so storage is what survives a + // tab switch made while a recalled prompt is on screen. + flushDraftSave() } applyingHistoryRef.current = true if (step.action === "show") { @@ -815,7 +838,7 @@ export function MessageInput({ historyIndexRef.current = step.index return true }, - [getSentHistory, isEditingQueueItem] + [flushDraftSave, getSentHistory, isEditingQueueItem] ) const handleComposerReady = useCallback(() => {