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