diff --git a/src/components/canvas/canvas-conversation-surface.tsx b/src/components/canvas/canvas-conversation-surface.tsx index ec5771bddc..87bdc1e4a0 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" @@ -711,6 +713,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 ( @@ -753,6 +765,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 6700ffd27d..38d7836354 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. */ @@ -119,6 +121,7 @@ export const ChatInput = memo(function ChatInput({ attachmentTabId, folderPickerOverride, draftStorageKey, + getSentHistory, isActive, showActiveFlow, queue, @@ -222,6 +225,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..7f644f7c77 100644 --- a/src/components/chat/composer/rich-composer.test.tsx +++ b/src/components/chat/composer/rich-composer.test.tsx @@ -467,3 +467,163 @@ 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 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 }) + 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..2304bed820 100644 --- a/src/components/chat/composer/rich-composer.tsx +++ b/src/components/chat/composer/rich-composer.tsx @@ -12,9 +12,11 @@ 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" +import type { HistoryDirection } from "@/lib/composer-history" import { isImeCompositionKey } from "@/lib/ime-composition" import { NO_KNOWN_INVOCATIONS, @@ -192,6 +194,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 +264,7 @@ export const RichComposer = forwardRef( newlineShortcut, isExternalMenuOpen, onExternalMenuKeyDown, + onHistoryKeyDown, onPasteFiles, onDropFiles, onPlainPaste, @@ -272,6 +290,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 +311,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 +405,43 @@ 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, 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 === Selection.atStart(doc).from + : selection.to === Selection.atEnd(doc).to) + 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 a5157879b7..3082dc95e9 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. @@ -188,6 +190,7 @@ export function ConversationShell({ attachmentTabId, folderPickerOverride, draftStorageKey, + getSentHistory, hideInput = false, composerBanner, feedbackList, @@ -366,6 +369,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 607e6b3f73..f2f8301250 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, @@ -1791,3 +1795,173 @@ 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("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"]) + + 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 b2672eb9e0..4cdd89914f 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -49,6 +49,10 @@ import { imageFilesFromClipboardApi } from "@/lib/clipboard-images" import { toErrorMessage } from "@/lib/app-error" import { isNoActiveTurnRejection } from "@/lib/turn-busy" import { buildSteerPayload } from "@/lib/prompt-draft" +import { + stepComposerHistory, + type HistoryDirection, +} from "@/lib/composer-history" import { ServerFileBrowserDialog } from "@/components/shared/server-file-browser-dialog" import { toast } from "sonner" import type { @@ -244,6 +248,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 } @@ -372,6 +385,7 @@ export function MessageInput({ feedbackAddDisabled, injectContent, onInjectConsumed, + getSentHistory, }: MessageInputProps) { const t = useTranslations("Folder.chat.messageInput") const tQueue = useTranslations("Folder.chat.messageQueue") @@ -415,6 +429,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`. @@ -520,6 +554,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 @@ -528,18 +575,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 () => { @@ -742,11 +793,80 @@ 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() ?? "", + } + // 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") { + 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 + }, + [flushDraftSave, getSentHistory, isEditingQueueItem] + ) + const handleComposerReady = useCallback(() => { setComposerReady(true) }, []) @@ -1326,6 +1446,8 @@ export function MessageInput({ setComposerEmpty(true) clearAttachments() closeSlashMenu() + historyIndexRef.current = null + historyDraftRef.current = null }, [clearAttachments, closeSlashMenu]) const handleSend = useCallback(() => { @@ -2026,6 +2148,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 5ece8f855a..d08580a6aa 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -127,6 +127,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 { @@ -2020,6 +2021,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 } +}