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
co-authored by Coder Agents
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} onCancelQueueEdit={editing.handleCancelQueueEdit}
isEditingHistoryMessage={editing.editingMessageId !== null} isEditingHistoryMessage={editing.editingMessageId !== null}
onCancelHistoryEdit={editing.handleCancelHistoryEdit} onCancelHistoryEdit={editing.handleCancelHistoryEdit}
onEditUserMessage={editing.handleEditUserMessage}
editingFileBlocks={editing.editingFileBlocks} editingFileBlocks={editing.editingFileBlocks}
mcpServers={mcpServers} mcpServers={mcpServers}
selectedMCPServerIds={selectedMCPServerIds} selectedMCPServerIds={selectedMCPServerIds}
@@ -44,8 +44,135 @@ const meta: Meta<typeof AgentChatInput> = {
export default meta; export default meta;
type Story = StoryObj<typeof AgentChatInput>; 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 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 = { export const DisablesSendUntilInput: Story = {
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
@@ -145,7 +145,8 @@ interface AgentChatInputProps {
// History editing state, owned by the parent. // History editing state, owned by the parent.
isEditingHistoryMessage?: boolean; isEditingHistoryMessage?: boolean;
onCancelHistoryEdit?: () => void; 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. // Optional context-usage summary shown to the left of the send button.
// Pass `null` to render fallback values (e.g. when limit is unknown). // Pass `null` to render fallback values (e.g. when limit is unknown).
@@ -312,7 +313,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
onCancelQueueEdit, onCancelQueueEdit,
isEditingHistoryMessage = false, isEditingHistoryMessage = false,
onCancelHistoryEdit, onCancelHistoryEdit,
onEditLastUserMessage, userPromptHistory = [],
contextUsage, contextUsage,
attachments = [], attachments = [],
onAttach, onAttach,
@@ -351,6 +352,39 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
const mcpPopupRef = useRef<Window | null>(null); const mcpPopupRef = useRef<Window | null>(null);
const [hasFileReferences, setHasFileReferences] = useState(false); 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 speech = useSpeechRecognition();
const [preRecordingValue, setPreRecordingValue] = useState<string>(""); const [preRecordingValue, setPreRecordingValue] = useState<string>("");
@@ -368,9 +402,23 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
} }
}, [speech.transcript, speech.isRecording, preRecordingValue]); }, [speech.transcript, speech.isRecording, preRecordingValue]);
// Forward the internal ref to the parent-supplied inputRef // Forward a stable delegating handle to the parent-supplied inputRef.
// so both point to the same ChatMessageInputRef instance. // Delegates lazily to internalRef.current so methods see the current
useImperativeHandle(inputRef, () => internalRef.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. // Listen for OAuth2 completion postMessage from popup.
useEffect(() => { useEffect(() => {
@@ -511,6 +559,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && onAttach) { if (e.target.files && onAttach) {
resetPromptCycle();
onAttach(Array.from(e.target.files)); onAttach(Array.from(e.target.files));
} }
// Reset so the same file can be selected again. // Reset so the same file can be selected again.
@@ -518,6 +567,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
}; };
const handleFilePaste = (file: File) => { const handleFilePaste = (file: File) => {
resetPromptCycle();
onAttach?.([file]); onAttach?.([file]);
}; };
@@ -526,6 +576,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
if (content === undefined) return; if (content === undefined) return;
const editor = internalRef.current; const editor = internalRef.current;
if (!editor) return; if (!editor) return;
resetPromptCycle();
editor.insertText(content); editor.insertText(content);
onRemoveAttachment?.(file); onRemoveAttachment?.(file);
}; };
@@ -567,9 +618,9 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
const attachable = Array.from(e.dataTransfer.files).filter( const attachable = Array.from(e.dataTransfer.files).filter(
isChatAttachmentFile, isChatAttachmentFile,
); );
if (attachable.length > 0) { if (attachable.length === 0) return;
onAttach(attachable); resetPromptCycle();
} onAttach(attachable);
}; };
// Track whether the editor has content so we can gate the // Track whether the editor has content so we can gate the
@@ -587,6 +638,16 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
serializedEditorState: string, serializedEditorState: string,
hasRefs: boolean, 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())); setHasContent(Boolean(content.trim()));
setHasFileReferences(hasRefs); setHasFileReferences(hasRefs);
setInvisibleCharCount(countInvisibleCharacters(content)); setInvisibleCharCount(countInvisibleCharacters(content));
@@ -650,11 +711,13 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
} }
onSend(text); onSend(text);
resetPromptCycle();
if (!isMobileViewport()) { if (!isMobileViewport()) {
internalRef.current?.focus(); internalRef.current?.focus();
} }
}; };
const handleStartRecording = () => { const handleStartRecording = () => {
resetPromptCycle();
setPreRecordingValue(internalRef.current?.getValue()?.trim() ?? ""); setPreRecordingValue(internalRef.current?.getValue()?.trim() ?? "");
speech.start(); 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) => { const handleEditorKeyDown = (e: React.KeyboardEvent) => {
if ( if (e.key === "Escape" && cycleIndex !== null) {
e.key !== "ArrowUp" || e.preventDefault();
editingQueuedMessageID !== null || e.stopPropagation();
isEditingHistoryMessage || restoreCycleDraft();
!onEditLastUserMessage ||
!isComposerEffectivelyEmpty
) {
return; 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(); 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 = const sendButtonLabel =
@@ -804,6 +939,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
<ChatMessageInput <ChatMessageInput
ref={internalRef} ref={internalRef}
onFilePaste={onAttach ? handleFilePaste : undefined} onFilePaste={onAttach ? handleFilePaste : undefined}
onPaste={resetPromptCycle}
aria-label="Chat message" 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" 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} placeholder={placeholder}
@@ -903,6 +1039,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
resetPromptCycle();
setPlusMenuOpen(false); setPlusMenuOpen(false);
fileInputRef.current?.click(); fileInputRef.current?.click();
}} }}
@@ -187,11 +187,6 @@ interface ChatPageInputProps {
onCancelQueueEdit: () => void; onCancelQueueEdit: () => void;
isEditingHistoryMessage: boolean; isEditingHistoryMessage: boolean;
onCancelHistoryEdit: () => void; onCancelHistoryEdit: () => void;
onEditUserMessage: (
messageId: number,
text: string,
fileBlocks?: readonly TypesGen.ChatMessagePart[],
) => void;
// File parts from the message being edited, converted to // File parts from the message being edited, converted to
// File objects and pre-populated into attachments. // File objects and pre-populated into attachments.
editingFileBlocks?: readonly TypesGen.ChatMessagePart[]; editingFileBlocks?: readonly TypesGen.ChatMessagePart[];
@@ -246,7 +241,6 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
onCancelQueueEdit, onCancelQueueEdit,
isEditingHistoryMessage, isEditingHistoryMessage,
onCancelHistoryEdit, onCancelHistoryEdit,
onEditUserMessage,
editingFileBlocks, editingFileBlocks,
mcpServers, mcpServers,
selectedMCPServerIds, selectedMCPServerIds,
@@ -284,24 +278,22 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
return message; return message;
}) })
.filter(isChatMessage); .filter(isChatMessage);
let lastEditableUserMessage: TypesGen.ChatMessage | undefined; // Newest first. messages are in id-ascending order, so reverse
for (let index = orderedMessageIDs.length - 1; index >= 0; index--) { // iteration yields the most recent user prompts first. Store the
const message = messagesByID.get(orderedMessageIDs[index]); // original text untouched so cycling and re-sending reproduces what
if (message?.role === "user") { // the user originally sent (boundary whitespace can be intentional).
lastEditableUserMessage = message; const userPromptHistory: string[] = [];
break; 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 rawUsage = getLatestContextUsage(messages);
const latestContextUsage = rawUsage const latestContextUsage = rawUsage
? { ...rawUsage, compressionThreshold, lastInjectedContext } ? { ...rawUsage, compressionThreshold, lastInjectedContext }
@@ -472,7 +464,7 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
onCancelQueueEdit={onCancelQueueEdit} onCancelQueueEdit={onCancelQueueEdit}
isEditingHistoryMessage={isEditingHistoryMessage} isEditingHistoryMessage={isEditingHistoryMessage}
onCancelHistoryEdit={onCancelHistoryEdit} onCancelHistoryEdit={onCancelHistoryEdit}
onEditLastUserMessage={handleEditLastUserMessage} userPromptHistory={userPromptHistory}
isDisabled={isInputDisabled} isDisabled={isInputDisabled}
isLoading={isSendPending} isLoading={isSendPending}
isStreaming={isStreaming} isStreaming={isStreaming}