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>
This commit is contained in:
Michael Suchacz
2026-05-07 20:31:41 +02:00
committed by GitHub
parent ffe2595f63
commit d32842f084
4 changed files with 294 additions and 39 deletions
@@ -556,7 +556,6 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
onCancelQueueEdit={editing.handleCancelQueueEdit}
isEditingHistoryMessage={editing.editingMessageId !== null}
onCancelHistoryEdit={editing.handleCancelHistoryEdit}
onEditUserMessage={editing.handleEditUserMessage}
editingFileBlocks={editing.editingFileBlocks}
mcpServers={mcpServers}
selectedMCPServerIds={selectedMCPServerIds}
@@ -44,8 +44,135 @@ const meta: Meta<typeof AgentChatInput> = {
export default meta;
type Story = StoryObj<typeof AgentChatInput>;
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);
@@ -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<AgentChatInputProps> = ({
onCancelQueueEdit,
isEditingHistoryMessage = false,
onCancelHistoryEdit,
onEditLastUserMessage,
userPromptHistory = [],
contextUsage,
attachments = [],
onAttach,
@@ -351,6 +352,39 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
const mcpPopupRef = useRef<Window | null>(null);
const [hasFileReferences, setHasFileReferences] = useState(false);
const [cycleIndex, setCycleIndex] = useState<number | null>(null);
const [cycleSavedDraft, setCycleSavedDraft] = useState<string | null>(null);
const cycleHistorySnapshotRef = useRef<readonly string[] | null>(null);
const currentCycleValueRef = useRef<string | null>(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<string>("");
@@ -368,9 +402,23 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
}
}, [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<AgentChatInputProps> = ({
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
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<AgentChatInputProps> = ({
};
const handleFilePaste = (file: File) => {
resetPromptCycle();
onAttach?.([file]);
};
@@ -526,6 +576,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
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<AgentChatInputProps> = ({
const attachable = Array.from(e.dataTransfer.files).filter(
isChatAttachmentFile,
);
if (attachable.length > 0) {
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<AgentChatInputProps> = ({
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<AgentChatInputProps> = ({
}
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<AgentChatInputProps> = ({
}
}
};
const restoreCycleDraft = () => {
const savedDraft = cycleSavedDraft ?? "";
setCycleIndex(null);
setCycleSavedDraft(null);
cycleHistorySnapshotRef.current = null;
applyCycleValue(savedDraft);
};
const handleEditorKeyDown = (e: React.KeyboardEvent) => {
if (
e.key !== "ArrowUp" ||
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 ||
!onEditLastUserMessage ||
!isComposerEffectivelyEmpty
) {
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();
onEditLastUserMessage();
cycleHistorySnapshotRef.current = cycleHistory;
setCycleIndex(0);
setCycleSavedDraft(internalRef.current?.getValue() ?? "");
applyCycleValue(latestPrompt);
return;
}
e.preventDefault();
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<AgentChatInputProps> = ({
<ChatMessageInput
ref={internalRef}
onFilePaste={onAttach ? handleFilePaste : undefined}
onPaste={resetPromptCycle}
aria-label="Chat message"
className="min-h-[60px] sm:min-h-24 w-full resize-none bg-transparent px-3 py-2 font-sans text-[13px] leading-relaxed text-content-primary placeholder:text-content-secondary disabled:cursor-not-allowed disabled:opacity-70"
placeholder={placeholder}
@@ -903,6 +1039,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
<button
type="button"
onClick={() => {
resetPromptCycle();
setPlusMenuOpen(false);
fileInputRef.current?.click();
}}
@@ -187,11 +187,6 @@ interface ChatPageInputProps {
onCancelQueueEdit: () => void;
isEditingHistoryMessage: boolean;
onCancelHistoryEdit: () => void;
onEditUserMessage: (
messageId: number,
text: string,
fileBlocks?: readonly TypesGen.ChatMessagePart[],
) => void;
// File parts from the message being edited, converted to
// File objects and pre-populated into attachments.
editingFileBlocks?: readonly TypesGen.ChatMessagePart[];
@@ -246,7 +241,6 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
onCancelQueueEdit,
isEditingHistoryMessage,
onCancelHistoryEdit,
onEditUserMessage,
editingFileBlocks,
mcpServers,
selectedMCPServerIds,
@@ -284,24 +278,22 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
return message;
})
.filter(isChatMessage);
let lastEditableUserMessage: TypesGen.ChatMessage | undefined;
for (let index = orderedMessageIDs.length - 1; index >= 0; index--) {
const message = messagesByID.get(orderedMessageIDs[index]);
if (message?.role === "user") {
lastEditableUserMessage = message;
break;
// Newest first. messages are in id-ascending order, so reverse
// iteration yields the most recent user prompts first. Store the
// original text untouched so cycling and re-sending reproduces what
// the user originally sent (boundary whitespace can be intentional).
const userPromptHistory: string[] = [];
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages.at(index);
if (!message || message.role !== "user") {
continue;
}
const text = getEditableUserMessagePayload(message).text;
if (text.trim()) {
userPromptHistory.push(text);
}
}
const handleEditLastUserMessage = lastEditableUserMessage
? () => {
const { text, fileBlocks } = getEditableUserMessagePayload(
lastEditableUserMessage,
);
onEditUserMessage(lastEditableUserMessage.id, text, fileBlocks);
}
: undefined;
const rawUsage = getLatestContextUsage(messages);
const latestContextUsage = rawUsage
? { ...rawUsage, compressionThreshold, lastInjectedContext }
@@ -472,7 +464,7 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
onCancelQueueEdit={onCancelQueueEdit}
isEditingHistoryMessage={isEditingHistoryMessage}
onCancelHistoryEdit={onCancelHistoryEdit}
onEditLastUserMessage={handleEditLastUserMessage}
userPromptHistory={userPromptHistory}
isDisabled={isInputDisabled}
isLoading={isSendPending}
isStreaming={isStreaming}