diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 30f5634578..60d321fc46 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -556,7 +556,6 @@ export const AgentChatPageView: FC = ({ onCancelQueueEdit={editing.handleCancelQueueEdit} isEditingHistoryMessage={editing.editingMessageId !== null} onCancelHistoryEdit={editing.handleCancelHistoryEdit} - onEditUserMessage={editing.handleEditUserMessage} editingFileBlocks={editing.editingFileBlocks} mcpServers={mcpServers} selectedMCPServerIds={selectedMCPServerIds} diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx index 0dfd26e701..569b5bac7d 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx @@ -44,8 +44,135 @@ const meta: Meta = { export default meta; type Story = StoryObj; +const promptHistory = [ + "Most recent prompt", + "Middle prompt", + "Oldest prompt", +] as const; + +const getEditor = (canvasElement: HTMLElement) => + within(canvasElement).getByTestId("chat-message-input"); + +const expectEditorText = async (editor: HTMLElement, text: string) => { + await waitFor(() => { + expect(editor.textContent).toBe(text); + }); +}; + export const Default: Story = {}; +export const PromptHistoryCycling: Story = { + args: { + userPromptHistory: promptHistory, + }, + play: async ({ canvasElement }) => { + const editor = getEditor(canvasElement); + await expectEditorText(editor, ""); + await userEvent.click(editor); + + await userEvent.keyboard("{ArrowUp}"); + await expectEditorText(editor, "Most recent prompt"); + await userEvent.keyboard("{ArrowUp}"); + await expectEditorText(editor, "Middle prompt"); + await userEvent.keyboard("{ArrowUp}"); + await expectEditorText(editor, "Oldest prompt"); + await userEvent.keyboard("{ArrowUp}"); + await expectEditorText(editor, "Oldest prompt"); + + await userEvent.keyboard("{ArrowDown}"); + await expectEditorText(editor, "Middle prompt"); + await userEvent.keyboard("{ArrowDown}"); + await expectEditorText(editor, "Most recent prompt"); + await userEvent.keyboard("{ArrowDown}"); + await expectEditorText(editor, ""); + + await userEvent.keyboard("{ArrowUp}"); + await expectEditorText(editor, "Most recent prompt"); + await userEvent.keyboard("{Escape}"); + await expectEditorText(editor, ""); + }, +}; + +export const PromptHistoryCyclingExitsOnTyping: Story = { + args: { + userPromptHistory: promptHistory, + }, + play: async ({ canvasElement }) => { + const editor = getEditor(canvasElement); + await expectEditorText(editor, ""); + await userEvent.click(editor); + + await userEvent.keyboard("{ArrowUp}"); + await expectEditorText(editor, "Most recent prompt"); + await userEvent.keyboard("!"); + await expectEditorText(editor, "Most recent prompt!"); + await userEvent.keyboard("{ArrowUp}"); + await expectEditorText(editor, "Most recent prompt!"); + + await userEvent.keyboard("{Control>}a{/Control}{Backspace}"); + await expectEditorText(editor, ""); + await userEvent.keyboard("{ArrowUp}"); + await expectEditorText(editor, "Most recent prompt"); + await userEvent.keyboard("{ArrowDown}"); + await expectEditorText(editor, ""); + }, +}; + +export const NoPromptHistoryUpArrowIsNoOp: Story = { + args: { + userPromptHistory: [], + }, + play: async ({ canvasElement }) => { + const editor = getEditor(canvasElement); + await expectEditorText(editor, ""); + await userEvent.click(editor); + await userEvent.keyboard("{ArrowUp}"); + await expectEditorText(editor, ""); + }, +}; + +export const PromptHistorySuppressedWhileEditingHistoryMessage: Story = { + args: { + isEditingHistoryMessage: true, + userPromptHistory: promptHistory, + }, + play: async ({ canvasElement }) => { + const editor = getEditor(canvasElement); + await expectEditorText(editor, ""); + await userEvent.click(editor); + await userEvent.keyboard("{ArrowUp}"); + await expectEditorText(editor, ""); + }, +}; + +export const PromptHistorySuppressedWhileDisabled: Story = { + args: { + isDisabled: true, + userPromptHistory: promptHistory, + }, + play: async ({ canvasElement }) => { + const editor = getEditor(canvasElement); + await expectEditorText(editor, ""); + await userEvent.click(editor); + await userEvent.keyboard("{ArrowUp}"); + await expectEditorText(editor, ""); + }, +}; + +export const PromptHistorySuppressedWhileLoading: Story = { + args: { + isLoading: true, + userPromptHistory: promptHistory, + }, + play: async ({ canvasElement }) => { + const editor = getEditor(canvasElement); + await expectEditorText(editor, ""); + await userEvent.click(editor); + await userEvent.keyboard("{ArrowUp}"); + await expectEditorText(editor, ""); + }, +}; + export const DisablesSendUntilInput: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 0caa8bb1d0..85c4fee9c1 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -145,7 +145,8 @@ interface AgentChatInputProps { // History editing state, owned by the parent. isEditingHistoryMessage?: boolean; onCancelHistoryEdit?: () => void; - onEditLastUserMessage?: () => void; + // Newest-first list of non-empty user prompts for local history cycling. + userPromptHistory?: readonly string[]; // Optional context-usage summary shown to the left of the send button. // Pass `null` to render fallback values (e.g. when limit is unknown). @@ -312,7 +313,7 @@ export const AgentChatInput: FC = ({ onCancelQueueEdit, isEditingHistoryMessage = false, onCancelHistoryEdit, - onEditLastUserMessage, + userPromptHistory = [], contextUsage, attachments = [], onAttach, @@ -351,6 +352,39 @@ export const AgentChatInput: FC = ({ const mcpPopupRef = useRef(null); const [hasFileReferences, setHasFileReferences] = useState(false); + const [cycleIndex, setCycleIndex] = useState(null); + const [cycleSavedDraft, setCycleSavedDraft] = useState(null); + const cycleHistorySnapshotRef = useRef(null); + const currentCycleValueRef = useRef(null); + const previousRemountKeyRef = useRef(remountKey); + + const resetPromptCycle = () => { + setCycleIndex(null); + setCycleSavedDraft(null); + cycleHistorySnapshotRef.current = null; + currentCycleValueRef.current = null; + }; + + const applyCycleValue = (text: string) => { + const editor = internalRef.current; + if (!editor) return; + currentCycleValueRef.current = text; + editor.setValue(text); + editor.focus(); + }; + + useEffect(() => { + if (previousRemountKeyRef.current === remountKey) return; + previousRemountKeyRef.current = remountKey; + // Inlined resetPromptCycle body. Calling resetPromptCycle directly + // would force it into the dep array; the React Compiler stabilises + // callbacks but biome's react-hooks lint does not. + setCycleIndex(null); + setCycleSavedDraft(null); + cycleHistorySnapshotRef.current = null; + currentCycleValueRef.current = null; + // Keep in sync with resetPromptCycle above. + }, [remountKey]); const speech = useSpeechRecognition(); const [preRecordingValue, setPreRecordingValue] = useState(""); @@ -368,9 +402,23 @@ export const AgentChatInput: FC = ({ } }, [speech.transcript, speech.isRecording, preRecordingValue]); - // Forward the internal ref to the parent-supplied inputRef - // so both point to the same ChatMessageInputRef instance. - useImperativeHandle(inputRef, () => internalRef.current!, []); + // Forward a stable delegating handle to the parent-supplied inputRef. + // Delegates lazily to internalRef.current so methods see the current + // Lexical instance after a remount, not the orphaned ref captured at + // factory time. + useImperativeHandle( + inputRef, + () => ({ + setValue: (text) => internalRef.current?.setValue(text), + insertText: (text) => internalRef.current?.insertText(text), + clear: () => internalRef.current?.clear(), + focus: () => internalRef.current?.focus(), + getValue: () => internalRef.current?.getValue() ?? "", + addFileReference: (ref) => internalRef.current?.addFileReference(ref), + getContentParts: () => internalRef.current?.getContentParts() ?? [], + }), + [], + ); // Listen for OAuth2 completion postMessage from popup. useEffect(() => { @@ -511,6 +559,7 @@ export const AgentChatInput: FC = ({ const handleFileSelect = (e: React.ChangeEvent) => { if (e.target.files && onAttach) { + resetPromptCycle(); onAttach(Array.from(e.target.files)); } // Reset so the same file can be selected again. @@ -518,6 +567,7 @@ export const AgentChatInput: FC = ({ }; const handleFilePaste = (file: File) => { + resetPromptCycle(); onAttach?.([file]); }; @@ -526,6 +576,7 @@ export const AgentChatInput: FC = ({ if (content === undefined) return; const editor = internalRef.current; if (!editor) return; + resetPromptCycle(); editor.insertText(content); onRemoveAttachment?.(file); }; @@ -567,9 +618,9 @@ export const AgentChatInput: FC = ({ const attachable = Array.from(e.dataTransfer.files).filter( isChatAttachmentFile, ); - if (attachable.length > 0) { - onAttach(attachable); - } + if (attachable.length === 0) return; + resetPromptCycle(); + onAttach(attachable); }; // Track whether the editor has content so we can gate the @@ -587,6 +638,16 @@ export const AgentChatInput: FC = ({ serializedEditorState: string, hasRefs: boolean, ) => { + // Lexical fires onChange synchronously from editor.setValue(). + // While cycling, compare incoming content to currentCycleValueRef, + // the last value we applied. Different content means user input, + // so reset; matching content is our own setValue echo, so keep cycling. + // This works because React batches state updates within event handlers + // and commits them after the handler returns, so the synchronous onChange + // callback sees the pre-batch cycleIndex value, not the queued update. + if (cycleIndex !== null && content !== currentCycleValueRef.current) { + resetPromptCycle(); + } setHasContent(Boolean(content.trim())); setHasFileReferences(hasRefs); setInvisibleCharCount(countInvisibleCharacters(content)); @@ -650,11 +711,13 @@ export const AgentChatInput: FC = ({ } onSend(text); + resetPromptCycle(); if (!isMobileViewport()) { internalRef.current?.focus(); } }; const handleStartRecording = () => { + resetPromptCycle(); setPreRecordingValue(internalRef.current?.getValue()?.trim() ?? ""); speech.start(); }; @@ -690,18 +753,90 @@ export const AgentChatInput: FC = ({ } } }; + const restoreCycleDraft = () => { + const savedDraft = cycleSavedDraft ?? ""; + setCycleIndex(null); + setCycleSavedDraft(null); + cycleHistorySnapshotRef.current = null; + applyCycleValue(savedDraft); + }; + const handleEditorKeyDown = (e: React.KeyboardEvent) => { - if ( - e.key !== "ArrowUp" || - editingQueuedMessageID !== null || - isEditingHistoryMessage || - !onEditLastUserMessage || - !isComposerEffectivelyEmpty - ) { + if (e.key === "Escape" && cycleIndex !== null) { + e.preventDefault(); + e.stopPropagation(); + restoreCycleDraft(); return; } + + // isStreaming is intentionally excluded. Cycling is allowed while + // streaming so the user can prepare the next prompt. Escape is + // cycle-aware so it does not accidentally interrupt streaming. + const isPromptCyclingSuppressed = + editingQueuedMessageID !== null || + isEditingHistoryMessage || + isDisabled || + isLoading; + if (isPromptCyclingSuppressed) { + return; + } + + if (e.key !== "ArrowUp" && e.key !== "ArrowDown") { + return; + } + + if (cycleIndex === null) { + if (e.key !== "ArrowUp" || !isComposerEffectivelyEmpty) { + return; + } + const cycleHistory = [...userPromptHistory]; + const latestPrompt = cycleHistory[0]; + if (latestPrompt === undefined) { + return; + } + e.preventDefault(); + cycleHistorySnapshotRef.current = cycleHistory; + setCycleIndex(0); + setCycleSavedDraft(internalRef.current?.getValue() ?? ""); + applyCycleValue(latestPrompt); + return; + } + e.preventDefault(); - onEditLastUserMessage(); + const cycleHistory = cycleHistorySnapshotRef.current ?? userPromptHistory; + if (e.key === "ArrowDown") { + if (cycleIndex === 0) { + restoreCycleDraft(); + return; + } + const nextIndex = cycleIndex - 1; + const nextPrompt = cycleHistory[nextIndex]; + if (nextPrompt === undefined) { + restoreCycleDraft(); + return; + } + setCycleIndex(nextIndex); + applyCycleValue(nextPrompt); + return; + } + + // ArrowUp: load an older prompt. + const lastIndex = cycleHistory.length - 1; + if (lastIndex < 0) { + restoreCycleDraft(); + return; + } + const nextIndex = Math.min(cycleIndex + 1, lastIndex); + if (nextIndex === cycleIndex) { + return; + } + const nextPrompt = cycleHistory[nextIndex]; + if (nextPrompt === undefined) { + restoreCycleDraft(); + return; + } + setCycleIndex(nextIndex); + applyCycleValue(nextPrompt); }; const sendButtonLabel = @@ -804,6 +939,7 @@ export const AgentChatInput: FC = ({ = ({