Skip to content

Commit d32842f

Browse files
ibetitsmikeCoder Agents
andauthored
feat(site): cycle prompt history with up/down arrows (#25004)
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. <details> <summary>Implementation plan and decision log</summary> 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. ``` </details> --- > [!NOTE] > This PR was created on behalf of @ibetitsmike by Coder Agents. --------- Co-authored-by: Coder Agents <noreply@coder.com>
1 parent ffe2595 commit d32842f

4 files changed

Lines changed: 292 additions & 37 deletions

File tree

site/src/pages/AgentsPage/AgentChatPageView.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -556,7 +556,6 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
556556
onCancelQueueEdit={editing.handleCancelQueueEdit}
557557
isEditingHistoryMessage={editing.editingMessageId !== null}
558558
onCancelHistoryEdit={editing.handleCancelHistoryEdit}
559-
onEditUserMessage={editing.handleEditUserMessage}
560559
editingFileBlocks={editing.editingFileBlocks}
561560
mcpServers={mcpServers}
562561
selectedMCPServerIds={selectedMCPServerIds}

site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,135 @@ const meta: Meta<typeof AgentChatInput> = {
4444
export default meta;
4545
type Story = StoryObj<typeof AgentChatInput>;
4646

47+
const promptHistory = [
48+
"Most recent prompt",
49+
"Middle prompt",
50+
"Oldest prompt",
51+
] as const;
52+
53+
const getEditor = (canvasElement: HTMLElement) =>
54+
within(canvasElement).getByTestId("chat-message-input");
55+
56+
const expectEditorText = async (editor: HTMLElement, text: string) => {
57+
await waitFor(() => {
58+
expect(editor.textContent).toBe(text);
59+
});
60+
};
61+
4762
export const Default: Story = {};
4863

64+
export const PromptHistoryCycling: Story = {
65+
args: {
66+
userPromptHistory: promptHistory,
67+
},
68+
play: async ({ canvasElement }) => {
69+
const editor = getEditor(canvasElement);
70+
await expectEditorText(editor, "");
71+
await userEvent.click(editor);
72+
73+
await userEvent.keyboard("{ArrowUp}");
74+
await expectEditorText(editor, "Most recent prompt");
75+
await userEvent.keyboard("{ArrowUp}");
76+
await expectEditorText(editor, "Middle prompt");
77+
await userEvent.keyboard("{ArrowUp}");
78+
await expectEditorText(editor, "Oldest prompt");
79+
await userEvent.keyboard("{ArrowUp}");
80+
await expectEditorText(editor, "Oldest prompt");
81+
82+
await userEvent.keyboard("{ArrowDown}");
83+
await expectEditorText(editor, "Middle prompt");
84+
await userEvent.keyboard("{ArrowDown}");
85+
await expectEditorText(editor, "Most recent prompt");
86+
await userEvent.keyboard("{ArrowDown}");
87+
await expectEditorText(editor, "");
88+
89+
await userEvent.keyboard("{ArrowUp}");
90+
await expectEditorText(editor, "Most recent prompt");
91+
await userEvent.keyboard("{Escape}");
92+
await expectEditorText(editor, "");
93+
},
94+
};
95+
96+
export const PromptHistoryCyclingExitsOnTyping: Story = {
97+
args: {
98+
userPromptHistory: promptHistory,
99+
},
100+
play: async ({ canvasElement }) => {
101+
const editor = getEditor(canvasElement);
102+
await expectEditorText(editor, "");
103+
await userEvent.click(editor);
104+
105+
await userEvent.keyboard("{ArrowUp}");
106+
await expectEditorText(editor, "Most recent prompt");
107+
await userEvent.keyboard("!");
108+
await expectEditorText(editor, "Most recent prompt!");
109+
await userEvent.keyboard("{ArrowUp}");
110+
await expectEditorText(editor, "Most recent prompt!");
111+
112+
await userEvent.keyboard("{Control>}a{/Control}{Backspace}");
113+
await expectEditorText(editor, "");
114+
await userEvent.keyboard("{ArrowUp}");
115+
await expectEditorText(editor, "Most recent prompt");
116+
await userEvent.keyboard("{ArrowDown}");
117+
await expectEditorText(editor, "");
118+
},
119+
};
120+
121+
export const NoPromptHistoryUpArrowIsNoOp: Story = {
122+
args: {
123+
userPromptHistory: [],
124+
},
125+
play: async ({ canvasElement }) => {
126+
const editor = getEditor(canvasElement);
127+
await expectEditorText(editor, "");
128+
await userEvent.click(editor);
129+
await userEvent.keyboard("{ArrowUp}");
130+
await expectEditorText(editor, "");
131+
},
132+
};
133+
134+
export const PromptHistorySuppressedWhileEditingHistoryMessage: Story = {
135+
args: {
136+
isEditingHistoryMessage: true,
137+
userPromptHistory: promptHistory,
138+
},
139+
play: async ({ canvasElement }) => {
140+
const editor = getEditor(canvasElement);
141+
await expectEditorText(editor, "");
142+
await userEvent.click(editor);
143+
await userEvent.keyboard("{ArrowUp}");
144+
await expectEditorText(editor, "");
145+
},
146+
};
147+
148+
export const PromptHistorySuppressedWhileDisabled: Story = {
149+
args: {
150+
isDisabled: true,
151+
userPromptHistory: promptHistory,
152+
},
153+
play: async ({ canvasElement }) => {
154+
const editor = getEditor(canvasElement);
155+
await expectEditorText(editor, "");
156+
await userEvent.click(editor);
157+
await userEvent.keyboard("{ArrowUp}");
158+
await expectEditorText(editor, "");
159+
},
160+
};
161+
162+
export const PromptHistorySuppressedWhileLoading: Story = {
163+
args: {
164+
isLoading: true,
165+
userPromptHistory: promptHistory,
166+
},
167+
play: async ({ canvasElement }) => {
168+
const editor = getEditor(canvasElement);
169+
await expectEditorText(editor, "");
170+
await userEvent.click(editor);
171+
await userEvent.keyboard("{ArrowUp}");
172+
await expectEditorText(editor, "");
173+
},
174+
};
175+
49176
export const DisablesSendUntilInput: Story = {
50177
play: async ({ canvasElement }) => {
51178
const canvas = within(canvasElement);

0 commit comments

Comments
 (0)