From d32842f084df531dc3494bec8ae39eaab3c8ac69 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 7 May 2026 20:31:41 +0200 Subject: [PATCH] feat(site): cycle prompt history with up/down arrows (#25004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes [CODAGT-319](https://linear.app/codercom/issue/CODAGT-319/support-prompt-history-cycling-with-up-arrow). Pressing the up-arrow key in the agent chat composer now cycles through prior user prompts in the chat (terminal/Discord/iTerm2 style). Down-arrow steps forward, Escape exits cycling and restores the in-progress draft. Cycling is non-destructive: the per-message hover **Edit** button is still the destructive truncate-and-edit path. Replaces the previous up-arrow shortcut that immediately entered destructive history-edit mode (and which had a regression where the composer rendered as "editing" with an empty input box). ## Behaviour - **Up** when composer is empty: snapshot the (empty) draft and load the most recent user prompt; subsequent **Up** presses load older prompts. Clamp at oldest, no wrap. - **Up** while non-empty and not yet cycling: pass through (caret movement preserved). - Once cycling, **Up / Down** are intercepted unconditionally because the cycle text fully replaces editor contents. Exit explicitly via Escape, by sending, or by typing. - **Down** while cycling: load the next-newer prompt, or restore the saved draft when past newest. - **Escape** while cycling: exit cycle and restore the saved draft. This also applies during streaming; the same keypress is stopped before it reaches the interrupt handler, and a second Escape interrupts as before. - **Typing / paste / drop / attach / send / `remountKey` change**: exit cycle mode and clear the snapshot. - Cycling is suppressed while `isEditingHistoryMessage`, `editingQueuedMessageID !== null`, or the input is `disabled` / `isLoading`. - Empty `userPromptHistory` makes Up a no-op (no destructive fallback). ## Out of scope (filed as follow-ups if needed) - Restoring file-reference chips / attachments on cycled messages — v1 cycles plain text only, matching the existing per-message destructive Edit's `text` payload. - `^N` / `^P` keybindings (per Cian's note in the Linear thread). - Per-user "enable/disable history cycling" preference (per Rowan's note). - Cross-chat history; cycling is per-chat. ## Tests New Storybook play functions in `AgentChatInput.stories.tsx`: - `PromptHistoryCycling` — Up cycles older, clamps at oldest; Down returns to newer / draft; Escape restores draft. - `PromptHistoryCyclingExitsOnTyping` — typing exits cycle mode; subsequent Up snapshots the fresh empty draft and Down restores it. - `NoPromptHistoryUpArrowIsNoOp` — empty history → Up is a no-op. - `PromptHistorySuppressedWhileEditingHistoryMessage` — cycling does not engage while history-editing. - `PromptHistorySuppressedWhileDisabled` — cycling does not engage while disabled. - `PromptHistorySuppressedWhileLoading` — cycling does not engage while loading. ## Implementation notes Also rewrites `useImperativeHandle` to delegate to `internalRef.current` lazily on every call instead of capturing it eagerly at factory time. The old code crashed when methods were called after a remount because the captured ref was stale; the new wrapper sees the current Lexical instance. Behavior changes from throw-on-null to silent no-op, which matches every other consumer of `ChatMessageInputRef`. Verified locally: ``` pnpm format pnpm check pnpm test:storybook src/pages/AgentsPage/components/AgentChatInput.stories.tsx # 41 passed pnpm lint ``` ## Manual UAT A 13-case manual UAT covering cycle entry/exit, clamping, draft restoration, no-history no-op, suppression while editing a history message, and the send-button enable state — all PASS. Spec lives at the deleted artifact branch; happy to re-attach if reviewers want it.
Implementation plan and decision log The complete plan that drove this PR, including design alternatives considered and edge cases: ```md # CODAGT-319 — Up-arrow prompt history cycling ## Goal Pressing the up-arrow key in the agent chat composer should cycle through the user's previously-sent prompts in the current chat, terminal/Discord/iTerm2 style. Down-arrow steps forward; Escape exits cycle mode and restores the in-progress draft. Cycling is non-destructive — it only populates the composer with text the user can choose to resend, edit, or discard. ## Today's behaviour (and the regression) - `ChatMessageInput` is a Lexical-based plain-text editor used inside `AgentChatInput.tsx`. - `AgentChatInput.tsx` already wires an `ArrowUp` handler. When the composer is empty and not already editing, it calls `onEditLastUserMessage`. - `onEditLastUserMessage` puts the user into a destructive "edit history" mode that warns "Editing will delete all subsequent messages and restart the conversation here.". - Danielle's regression report ("shows me as editing but the input box is empty") indicates the destructive flow has a bug in addition to being the wrong UX for the request. We're replacing that path on the up-arrow, not patching it. The destructive edit remains accessible via the per-message hover Edit button. ## Design ### Behaviour - Up when composer is empty: snapshot the (empty) draft and load the most recent user prompt. Subsequent Up loads older prompts, clamping at oldest. No wrap. - Up while non-empty and not yet cycling: pass through. Matches existing gating. - Once cycling, Up/Down are intercepted unconditionally. Exit via Escape, send, or typing. - Down while cycling: next-newer or restore draft past newest. - Escape while cycling: restore draft. During streaming, stop propagation so the same keypress does not interrupt; a second Escape interrupts as before. - Typing/paste/drop/attach/send: exit cycle. - Suppressed while isEditingHistoryMessage, editingQueuedMessageID !== null, or disabled/isLoading. - No history => Up is a no-op. ### State Local to `AgentChatInput.tsx`: - `cycleIndex: number | null` — null means not cycling. 0 = newest user prompt. - `cycleSavedDraft: string | null` — text restored on dismiss. No localStorage persistence — refresh is a clean exit signal and the chat already has history server-side. ### Wiring - New prop `userPromptHistory: readonly string[]` on `AgentChatInput`, newest-first. - Removed `onEditLastUserMessage` prop entirely (its single call-site is being replaced). Removed dead `onEditUserMessage` prop on `ChatPageInput` (no longer needed since the destructive last-message shortcut is gone; the destructive Edit button uses a separate prop chain through `ChatPageTimeline`). - `ChatPageContent.tsx` derives `userPromptHistory` from existing message store, filtered to `role === "user"` with non-empty `getEditableUserMessagePayload(message).text.trim()`. ### Reset triggers `cycleIndex` and `cycleSavedDraft` reset on: 1. New `remountKey` (chat change, edit start/cancel). 2. Successful send. 3. Paste, drop, file attach. 4. User typing (detected via `handleContentChange` by comparing the incoming content to `currentCycleValueRef`, the last value applied programmatically). ### Out of scope - Chip/attachment cycling. - ^N/^P (Cian's note). - Per-user toggle (Rowan's note). - Cross-chat history. ```
--- > [!NOTE] > This PR was created on behalf of @ibetitsmike by Coder Agents. --------- Co-authored-by: Coder Agents --- .../pages/AgentsPage/AgentChatPageView.tsx | 1 - .../components/AgentChatInput.stories.tsx | 127 +++++++++++++ .../AgentsPage/components/AgentChatInput.tsx | 169 ++++++++++++++++-- .../AgentsPage/components/ChatPageContent.tsx | 36 ++-- 4 files changed, 294 insertions(+), 39 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 30f5634578..60d321fc46 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -556,7 +556,6 @@ export const AgentChatPageView: FC = ({ onCancelQueueEdit={editing.handleCancelQueueEdit} isEditingHistoryMessage={editing.editingMessageId !== null} onCancelHistoryEdit={editing.handleCancelHistoryEdit} - onEditUserMessage={editing.handleEditUserMessage} editingFileBlocks={editing.editingFileBlocks} mcpServers={mcpServers} selectedMCPServerIds={selectedMCPServerIds} diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx index 0dfd26e701..569b5bac7d 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx @@ -44,8 +44,135 @@ const meta: Meta = { export default meta; type Story = StoryObj; +const promptHistory = [ + "Most recent prompt", + "Middle prompt", + "Oldest prompt", +] as const; + +const getEditor = (canvasElement: HTMLElement) => + within(canvasElement).getByTestId("chat-message-input"); + +const expectEditorText = async (editor: HTMLElement, text: string) => { + await waitFor(() => { + expect(editor.textContent).toBe(text); + }); +}; + export const Default: Story = {}; +export const PromptHistoryCycling: Story = { + args: { + userPromptHistory: promptHistory, + }, + play: async ({ canvasElement }) => { + const editor = getEditor(canvasElement); + await expectEditorText(editor, ""); + await userEvent.click(editor); + + await userEvent.keyboard("{ArrowUp}"); + await expectEditorText(editor, "Most recent prompt"); + await userEvent.keyboard("{ArrowUp}"); + await expectEditorText(editor, "Middle prompt"); + await userEvent.keyboard("{ArrowUp}"); + await expectEditorText(editor, "Oldest prompt"); + await userEvent.keyboard("{ArrowUp}"); + await expectEditorText(editor, "Oldest prompt"); + + await userEvent.keyboard("{ArrowDown}"); + await expectEditorText(editor, "Middle prompt"); + await userEvent.keyboard("{ArrowDown}"); + await expectEditorText(editor, "Most recent prompt"); + await userEvent.keyboard("{ArrowDown}"); + await expectEditorText(editor, ""); + + await userEvent.keyboard("{ArrowUp}"); + await expectEditorText(editor, "Most recent prompt"); + await userEvent.keyboard("{Escape}"); + await expectEditorText(editor, ""); + }, +}; + +export const PromptHistoryCyclingExitsOnTyping: Story = { + args: { + userPromptHistory: promptHistory, + }, + play: async ({ canvasElement }) => { + const editor = getEditor(canvasElement); + await expectEditorText(editor, ""); + await userEvent.click(editor); + + await userEvent.keyboard("{ArrowUp}"); + await expectEditorText(editor, "Most recent prompt"); + await userEvent.keyboard("!"); + await expectEditorText(editor, "Most recent prompt!"); + await userEvent.keyboard("{ArrowUp}"); + await expectEditorText(editor, "Most recent prompt!"); + + await userEvent.keyboard("{Control>}a{/Control}{Backspace}"); + await expectEditorText(editor, ""); + await userEvent.keyboard("{ArrowUp}"); + await expectEditorText(editor, "Most recent prompt"); + await userEvent.keyboard("{ArrowDown}"); + await expectEditorText(editor, ""); + }, +}; + +export const NoPromptHistoryUpArrowIsNoOp: Story = { + args: { + userPromptHistory: [], + }, + play: async ({ canvasElement }) => { + const editor = getEditor(canvasElement); + await expectEditorText(editor, ""); + await userEvent.click(editor); + await userEvent.keyboard("{ArrowUp}"); + await expectEditorText(editor, ""); + }, +}; + +export const PromptHistorySuppressedWhileEditingHistoryMessage: Story = { + args: { + isEditingHistoryMessage: true, + userPromptHistory: promptHistory, + }, + play: async ({ canvasElement }) => { + const editor = getEditor(canvasElement); + await expectEditorText(editor, ""); + await userEvent.click(editor); + await userEvent.keyboard("{ArrowUp}"); + await expectEditorText(editor, ""); + }, +}; + +export const PromptHistorySuppressedWhileDisabled: Story = { + args: { + isDisabled: true, + userPromptHistory: promptHistory, + }, + play: async ({ canvasElement }) => { + const editor = getEditor(canvasElement); + await expectEditorText(editor, ""); + await userEvent.click(editor); + await userEvent.keyboard("{ArrowUp}"); + await expectEditorText(editor, ""); + }, +}; + +export const PromptHistorySuppressedWhileLoading: Story = { + args: { + isLoading: true, + userPromptHistory: promptHistory, + }, + play: async ({ canvasElement }) => { + const editor = getEditor(canvasElement); + await expectEditorText(editor, ""); + await userEvent.click(editor); + await userEvent.keyboard("{ArrowUp}"); + await expectEditorText(editor, ""); + }, +}; + export const DisablesSendUntilInput: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 0caa8bb1d0..85c4fee9c1 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -145,7 +145,8 @@ interface AgentChatInputProps { // History editing state, owned by the parent. isEditingHistoryMessage?: boolean; onCancelHistoryEdit?: () => void; - onEditLastUserMessage?: () => void; + // Newest-first list of non-empty user prompts for local history cycling. + userPromptHistory?: readonly string[]; // Optional context-usage summary shown to the left of the send button. // Pass `null` to render fallback values (e.g. when limit is unknown). @@ -312,7 +313,7 @@ export const AgentChatInput: FC = ({ onCancelQueueEdit, isEditingHistoryMessage = false, onCancelHistoryEdit, - onEditLastUserMessage, + userPromptHistory = [], contextUsage, attachments = [], onAttach, @@ -351,6 +352,39 @@ export const AgentChatInput: FC = ({ const mcpPopupRef = useRef(null); const [hasFileReferences, setHasFileReferences] = useState(false); + const [cycleIndex, setCycleIndex] = useState(null); + const [cycleSavedDraft, setCycleSavedDraft] = useState(null); + const cycleHistorySnapshotRef = useRef(null); + const currentCycleValueRef = useRef(null); + const previousRemountKeyRef = useRef(remountKey); + + const resetPromptCycle = () => { + setCycleIndex(null); + setCycleSavedDraft(null); + cycleHistorySnapshotRef.current = null; + currentCycleValueRef.current = null; + }; + + const applyCycleValue = (text: string) => { + const editor = internalRef.current; + if (!editor) return; + currentCycleValueRef.current = text; + editor.setValue(text); + editor.focus(); + }; + + useEffect(() => { + if (previousRemountKeyRef.current === remountKey) return; + previousRemountKeyRef.current = remountKey; + // Inlined resetPromptCycle body. Calling resetPromptCycle directly + // would force it into the dep array; the React Compiler stabilises + // callbacks but biome's react-hooks lint does not. + setCycleIndex(null); + setCycleSavedDraft(null); + cycleHistorySnapshotRef.current = null; + currentCycleValueRef.current = null; + // Keep in sync with resetPromptCycle above. + }, [remountKey]); const speech = useSpeechRecognition(); const [preRecordingValue, setPreRecordingValue] = useState(""); @@ -368,9 +402,23 @@ export const AgentChatInput: FC = ({ } }, [speech.transcript, speech.isRecording, preRecordingValue]); - // Forward the internal ref to the parent-supplied inputRef - // so both point to the same ChatMessageInputRef instance. - useImperativeHandle(inputRef, () => internalRef.current!, []); + // Forward a stable delegating handle to the parent-supplied inputRef. + // Delegates lazily to internalRef.current so methods see the current + // Lexical instance after a remount, not the orphaned ref captured at + // factory time. + useImperativeHandle( + inputRef, + () => ({ + setValue: (text) => internalRef.current?.setValue(text), + insertText: (text) => internalRef.current?.insertText(text), + clear: () => internalRef.current?.clear(), + focus: () => internalRef.current?.focus(), + getValue: () => internalRef.current?.getValue() ?? "", + addFileReference: (ref) => internalRef.current?.addFileReference(ref), + getContentParts: () => internalRef.current?.getContentParts() ?? [], + }), + [], + ); // Listen for OAuth2 completion postMessage from popup. useEffect(() => { @@ -511,6 +559,7 @@ export const AgentChatInput: FC = ({ const handleFileSelect = (e: React.ChangeEvent) => { if (e.target.files && onAttach) { + resetPromptCycle(); onAttach(Array.from(e.target.files)); } // Reset so the same file can be selected again. @@ -518,6 +567,7 @@ export const AgentChatInput: FC = ({ }; const handleFilePaste = (file: File) => { + resetPromptCycle(); onAttach?.([file]); }; @@ -526,6 +576,7 @@ export const AgentChatInput: FC = ({ if (content === undefined) return; const editor = internalRef.current; if (!editor) return; + resetPromptCycle(); editor.insertText(content); onRemoveAttachment?.(file); }; @@ -567,9 +618,9 @@ export const AgentChatInput: FC = ({ const attachable = Array.from(e.dataTransfer.files).filter( isChatAttachmentFile, ); - if (attachable.length > 0) { - onAttach(attachable); - } + if (attachable.length === 0) return; + resetPromptCycle(); + onAttach(attachable); }; // Track whether the editor has content so we can gate the @@ -587,6 +638,16 @@ export const AgentChatInput: FC = ({ serializedEditorState: string, hasRefs: boolean, ) => { + // Lexical fires onChange synchronously from editor.setValue(). + // While cycling, compare incoming content to currentCycleValueRef, + // the last value we applied. Different content means user input, + // so reset; matching content is our own setValue echo, so keep cycling. + // This works because React batches state updates within event handlers + // and commits them after the handler returns, so the synchronous onChange + // callback sees the pre-batch cycleIndex value, not the queued update. + if (cycleIndex !== null && content !== currentCycleValueRef.current) { + resetPromptCycle(); + } setHasContent(Boolean(content.trim())); setHasFileReferences(hasRefs); setInvisibleCharCount(countInvisibleCharacters(content)); @@ -650,11 +711,13 @@ export const AgentChatInput: FC = ({ } onSend(text); + resetPromptCycle(); if (!isMobileViewport()) { internalRef.current?.focus(); } }; const handleStartRecording = () => { + resetPromptCycle(); setPreRecordingValue(internalRef.current?.getValue()?.trim() ?? ""); speech.start(); }; @@ -690,18 +753,90 @@ export const AgentChatInput: FC = ({ } } }; + const restoreCycleDraft = () => { + const savedDraft = cycleSavedDraft ?? ""; + setCycleIndex(null); + setCycleSavedDraft(null); + cycleHistorySnapshotRef.current = null; + applyCycleValue(savedDraft); + }; + const handleEditorKeyDown = (e: React.KeyboardEvent) => { - if ( - e.key !== "ArrowUp" || - editingQueuedMessageID !== null || - isEditingHistoryMessage || - !onEditLastUserMessage || - !isComposerEffectivelyEmpty - ) { + if (e.key === "Escape" && cycleIndex !== null) { + e.preventDefault(); + e.stopPropagation(); + restoreCycleDraft(); return; } + + // isStreaming is intentionally excluded. Cycling is allowed while + // streaming so the user can prepare the next prompt. Escape is + // cycle-aware so it does not accidentally interrupt streaming. + const isPromptCyclingSuppressed = + editingQueuedMessageID !== null || + isEditingHistoryMessage || + isDisabled || + isLoading; + if (isPromptCyclingSuppressed) { + return; + } + + if (e.key !== "ArrowUp" && e.key !== "ArrowDown") { + return; + } + + if (cycleIndex === null) { + if (e.key !== "ArrowUp" || !isComposerEffectivelyEmpty) { + return; + } + const cycleHistory = [...userPromptHistory]; + const latestPrompt = cycleHistory[0]; + if (latestPrompt === undefined) { + return; + } + e.preventDefault(); + cycleHistorySnapshotRef.current = cycleHistory; + setCycleIndex(0); + setCycleSavedDraft(internalRef.current?.getValue() ?? ""); + applyCycleValue(latestPrompt); + return; + } + e.preventDefault(); - onEditLastUserMessage(); + const cycleHistory = cycleHistorySnapshotRef.current ?? userPromptHistory; + if (e.key === "ArrowDown") { + if (cycleIndex === 0) { + restoreCycleDraft(); + return; + } + const nextIndex = cycleIndex - 1; + const nextPrompt = cycleHistory[nextIndex]; + if (nextPrompt === undefined) { + restoreCycleDraft(); + return; + } + setCycleIndex(nextIndex); + applyCycleValue(nextPrompt); + return; + } + + // ArrowUp: load an older prompt. + const lastIndex = cycleHistory.length - 1; + if (lastIndex < 0) { + restoreCycleDraft(); + return; + } + const nextIndex = Math.min(cycleIndex + 1, lastIndex); + if (nextIndex === cycleIndex) { + return; + } + const nextPrompt = cycleHistory[nextIndex]; + if (nextPrompt === undefined) { + restoreCycleDraft(); + return; + } + setCycleIndex(nextIndex); + applyCycleValue(nextPrompt); }; const sendButtonLabel = @@ -804,6 +939,7 @@ export const AgentChatInput: FC = ({ = ({