Skip to content

feat(site): cycle prompt history with up/down arrows#25004

Merged
ibetitsmike merged 6 commits into
mainfrom
codagt-319-prompt-history-cycling
May 7, 2026
Merged

feat(site): cycle prompt history with up/down arrows#25004
ibetitsmike merged 6 commits into
mainfrom
codagt-319-prompt-history-cycling

Conversation

@ibetitsmike

@ibetitsmike ibetitsmike commented May 6, 2026

Copy link
Copy Markdown
Collaborator

Fixes CODAGT-319.

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:

# 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.

Pressing up-arrow 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 (CODAGT-319).

Co-authored-by: Coder Agents <noreply@coder.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

💡 Codex Review

const text = getEditableUserMessagePayload(message).text.trim();
if (text) {
userPromptHistory.push(text);

P2 Badge Preserve original prompt text when building history

Building userPromptHistory with text.trim() mutates every recalled prompt by stripping leading and trailing whitespace, so cycling and re-sending a prior message no longer reproduces what the user originally sent. This affects prompts where boundary whitespace is intentional (for example leading newlines, trailing spaces, or exact formatting in code/text tasks). Use trimming only for the emptiness check, but store the unmodified text in history.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ibetitsmike ibetitsmike added the code-review label May 7, 2026 — with Coder

@coder-tasks coder-tasks Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Reviewed the prompt history cycling implementation. The feature is well-designed and fixes both the regression (empty editing state) and the UX issue with the destructive up-arrow shortcut. The Storybook play functions are thorough and cover the important scenarios. A few things worth addressing:

Missing resetPromptCycle() in handleStartRecording (line 721): handleStartRecording is the only user-action path that does not explicitly call resetPromptCycle(). Speech input does implicitly exit cycling because the speech useEffect calls editor.clear() + editor.insertText(), which fires handleContentChange with different content, triggering the cycle reset. But relying on that indirection makes the intent harder to audit. Every other exit path (file select, paste, drop, send, + menu file pick) calls resetPromptCycle() explicitly. Adding it to handleStartRecording would be consistent.

Comment thread site/src/pages/AgentsPage/components/AgentChatInput.tsx
Comment thread site/src/pages/AgentsPage/components/AgentChatInput.tsx Outdated
Comment thread site/src/pages/AgentsPage/components/AgentChatInput.tsx Outdated
Comment thread site/src/pages/AgentsPage/components/ChatPageContent.tsx
…cling

- Drop redundant isApplyingCycleValueRef/applyingCycleValueRef pair; the
  one-shot flag had a subtle fragility where a Lexical normalisation
  change before the main onChange would consume it early. The remaining
  currentCycleValueRef compares on every change, which is what we want.
- Add explicit e.stopPropagation() on the cycle-mode Escape handler so
  intent does not depend on the outer composer Escape coincidentally
  being a no-op in this state.
- Reset the cycle on speech-recording start for consistency with every
  other user-action path that exits cycling.
- Preserve original message text in userPromptHistory; trim only for
  the emptiness check so cycling and re-sending reproduces what the
  user actually sent (boundary whitespace can be intentional).
- Comment the newest-first invariant on the userPromptHistory loop.

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cycling state machine is well-designed. Entry, clamping, draft restoration, and exit-on-typing are all covered by real assertions against the Lexical DOM. The removal of onEditLastUserMessage and onEditUserMessage on ChatPageInput is clean; the destructive Edit button continues to work through ConversationTimeline. Pariston traced every branch of the state machine and could not construct a simpler alternative.

Severity count: 2 P2, 7 P3, 3 Nit.

The two P2s are about unrelated diff changes (DEREM-5) and an Escape trap during streaming (DEREM-4). The P3s cluster into three themes: (1) Escape exit from cycling is blocked or misdirected in edge states (DEREM-6, related to DEREM-4), (2) the three-ref cycle-detection protocol is correct but undocumented, flagged independently by four reviewers (DEREM-7), and (3) test coverage gaps for suppression conditions and the streaming Escape path (DEREM-1, DEREM-11).

Pariston: "I tried to build a case that a simpler fix exists and could not. The cycling state is local, the reset triggers are comprehensive, and the tests cover the main interaction paths."

Ging-ts, Ging-react, Robin, and Luffy returned no findings.


site/src/pages/AgentsPage/components/AgentChatInput.tsx:721

P3 [DEREM-10] handleStartRecording captures the editor's current value as preRecordingValue. When cycling, that value is the historical prompt, not the user's actual draft.

As speech.transcript changes, the effect at line 398 builds preRecordingValue + transcript, embedding the historical prompt in the result. The user's speech input is silently prepended with text they didn't dictate. Clicking the microphone does not call resetPromptCycle() or restoreCycleDraft().

"The interaction (cycle through history, then click mic) is unlikely. Fix: call restoreCycleDraft() when cycleIndex !== null at the top of handleStartRecording, so the editor shows the saved draft before capturing it." (Pariston)

🤖

🤖 This review was automatically generated with Coder Agents.

Comment thread site/src/pages/AgentsPage/components/AgentChatInput.tsx Outdated
Comment thread site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx
Comment thread site/src/pages/AgentsPage/components/AgentChatInput.tsx
Comment thread site/src/pages/AgentsPage/components/AgentChatInput.tsx Outdated
Comment thread site/src/pages/AgentsPage/components/AgentChatInput.tsx Outdated
Comment thread site/src/pages/AgentsPage/components/AgentChatInput.tsx
Comment thread site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx
Comment thread site/src/pages/AgentsPage/components/AgentChatInput.tsx
Comment thread site/src/pages/AgentsPage/components/AgentChatInput.tsx Outdated
Comment thread site/src/pages/AgentsPage/components/AgentChatInput.tsx Outdated

@coder-tasks coder-tasks Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Reviewed the prompt history cycling implementation (4 files). The design is solid: replacing the buggy destructive ArrowUp shortcut with non-destructive terminal-style cycling is the right call. The state machine (cycleIndex + cycleSavedDraft + currentCycleValueRef) handles the key edge cases correctly, and the Storybook play functions cover the main scenarios well.

A few things worth addressing:

  1. restoreCycleDraft leaves currentCycleValueRef.current non-null (set to draft value instead of cleared).
  2. resetPromptCycle body is copy-pasted into the useEffect, creating a maintenance hazard.
  3. The isStreaming omission from isPromptCyclingSuppressed deserves a short explanatory comment.
  4. PromptHistoryDemo has no play function — intentional but deviates from convention.
  5. Minor: messages[index] guard in ChatPageContent.tsx is always-truthy at the TypeScript level.

Comment thread site/src/pages/AgentsPage/components/AgentChatInput.tsx
Comment thread site/src/pages/AgentsPage/components/AgentChatInput.tsx
Comment thread site/src/pages/AgentsPage/components/AgentChatInput.tsx
Comment thread site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx Outdated
Comment thread site/src/pages/AgentsPage/components/ChatPageContent.tsx
- Move Escape-exits-cycle handler above isPromptCyclingSuppressed so
  the user can always exit cycling, even if isDisabled or isLoading
  flips true mid-cycle (DEREM-6).
- Escape during streaming now exits cycling cleanly without also
  interrupting the stream; stopPropagation keeps the outer composer
  Escape handler from firing on the same keypress. A subsequent
  Escape (no longer cycling) interrupts as before (DEREM-4).
- Snapshot userPromptHistory at cycle entry so a new message arriving
  mid-cycle does not silently shift the indexed prompt (DEREM-8).
- Add three new tests: PromptHistorySuppressedWhileDisabled,
  PromptHistorySuppressedWhileLoading, and an extension to the
  typing-exit story that verifies the second cycle's draft snapshot
  is the new empty state, not the previous prompt.
- Use messages.at(index) in ChatPageContent for self-documenting
  T | undefined typing.
- Add explanatory comments above the cycle-detection protocol and
  the isStreaming exclusion in isPromptCyclingSuppressed.
- Revert pre-existing emdash and non-null cleanup unrelated to the
  feature (and their accidental comma splices); remove the
  no-play-function PromptHistoryDemo story that supported the
  manual-UAT walkthrough.

@coder-tasks coder-tasks Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Reviewed PR #25004: prompt history cycling with up/down arrows in the agent chat composer. The implementation is well-structured and the Storybook test coverage is thorough. Found 2 nitpick-level issues.

Overall Assessment

The core logic is sound:

  • The cycleHistorySnapshotRef snapshot correctly prevents out-of-bounds access if new messages arrive during cycling.
  • The content-comparison trick in handleContentChange (comparing against currentCycleValueRef.current) correctly distinguishes programmatic setValue calls from user input without relying on committed React state.
  • Escape propagation is correctly stopped to avoid accidentally interrupting a stream with the first Escape press.
  • The useImperativeHandle rewrite from eager capture to lazy delegation is a legitimate fix for the stale-ref crash after remounts.

Comment thread site/src/pages/AgentsPage/components/AgentChatInput.tsx
Comment thread site/src/pages/AgentsPage/components/AgentChatInput.tsx Outdated

@coder-tasks coder-tasks Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Reviewed PR #25004 at updated commit 5232a255. The onChange comment has been corrected per the previous review — the React-batching explanation is now accurate. One nitpick remains.

Summary

Implementation is solid. The cycling state machine, snapshot-based history isolation, and ref-comparison trick in handleContentChange all look correct. Storybook coverage is comprehensive.

Comment thread site/src/pages/AgentsPage/components/AgentChatInput.tsx

@coder-tasks coder-tasks Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Reviewed prompt history cycling implementation (CODAGT-319). Found 2 nitpicks.

The overall design is solid. Key things done well:

  • cycleHistorySnapshotRef correctly prevents a new incoming message from shifting the indexed prompt mid-cycle.
  • The cycle-detection protocol in handleContentChange (comparing content against currentCycleValueRef.current) is correct; setting the ref before calling editor.setValue() in applyCycleValue is essential and the ordering is right.
  • Placing the Escape-exits-cycle handler before isPromptCyclingSuppressed so the user can always exit cycling even when isDisabled/isLoading flips true mid-cycle is the right call.
  • The useImperativeHandle lazy-delegation rewrite is a real bug fix for stale refs after remounts.
  • Storybook coverage is thorough.

Comment thread site/src/pages/AgentsPage/components/AgentChatInput.tsx Outdated
Comment thread site/src/pages/AgentsPage/components/AgentChatInput.tsx
- handleDrop reset moved past both the no-onAttach/empty-files guard
  and the no-attachable-files filter so cycling only exits when the
  drop actually mutates the composer.
- Add a 'Keep in sync with resetPromptCycle above.' comment so the
  inlined remountKey effect body stays coupled to the helper.

@coder-tasks coder-tasks Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Reviewed prompt history cycling with up/down arrows. The implementation is well-structured with thorough test coverage via Storybook play functions. The useImperativeHandle fix is a solid bug fix. Found 2 nitpicks.

Overall: LGTM with minor notes.

Comment thread site/src/pages/AgentsPage/components/AgentChatInput.tsx
Comment thread site/src/pages/AgentsPage/components/AgentChatInput.tsx

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ibetitsmike ibetitsmike merged commit d32842f into main May 7, 2026
43 of 44 checks passed
@ibetitsmike ibetitsmike deleted the codagt-319-prompt-history-cycling branch May 7, 2026 18:31
@github-actions github-actions Bot locked and limited conversation to collaborators May 7, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants