mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user