diff --git a/site/src/pages/AgentsPage/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/AgentChatInput.stories.tsx index 978df1ad37..09f0d4de07 100644 --- a/site/src/pages/AgentsPage/AgentChatInput.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatInput.stories.tsx @@ -16,7 +16,9 @@ const meta: Meta = { component: AgentChatInput, args: { onSend: fn(), + onChange: fn(), onModelChange: fn(), + value: "", isDisabled: false, isLoading: false, selectedModel: defaultModelOptions[0].id, @@ -36,37 +38,32 @@ export const Default: Story = {}; export const DisablesSendUntilInput: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const input = canvas.getByPlaceholderText("Type a message..."); const sendButton = canvas.getByRole("button", { name: "Send" }); expect(sendButton).toBeDisabled(); - await userEvent.type(input, "Write tests"); - expect(sendButton).toBeEnabled(); }, }; export const SendsAndClearsInput: Story = { args: { - onSend: fn().mockResolvedValue(undefined), + onSend: fn(), + value: "Run focused tests", }, play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); - const input = canvas.getByPlaceholderText("Type a message..."); - await userEvent.type(input, "Run focused tests"); await userEvent.click(canvas.getByRole("button", { name: "Send" })); await waitFor(() => { - expect(args.onSend).toHaveBeenCalledWith("Run focused tests", undefined); + expect(args.onSend).toHaveBeenCalledWith("Run focused tests"); }); - expect(input).toHaveValue(""); }, }; export const DisabledInput: Story = { args: { isDisabled: true, - initialValue: "Should not send", + value: "Should not send", }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -78,7 +75,7 @@ export const NoModelOptions: Story = { args: { isDisabled: false, hasModelOptions: false, - initialValue: "Model required", + value: "Model required", }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -90,7 +87,7 @@ export const LoadingSpinner: Story = { args: { isDisabled: true, isLoading: true, - initialValue: "Sending...", + value: "Sending...", }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -101,3 +98,18 @@ export const LoadingSpinner: Story = { expect(sendButton.querySelector(".animate-spin")).toBeTruthy(); }, }; + +export const LoadingDisablesSend: Story = { + args: { + isDisabled: false, + isLoading: true, + value: "Another message", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const sendButton = canvas.getByRole("button", { name: "Send" }); + // The send button should be disabled while a previous send is + // in-flight, even though the textarea has content. + expect(sendButton).toBeDisabled(); + }, +}; diff --git a/site/src/pages/AgentsPage/AgentChatInput.tsx b/site/src/pages/AgentsPage/AgentChatInput.tsx index 81b74d60ce..d4e25dde22 100644 --- a/site/src/pages/AgentsPage/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/AgentChatInput.tsx @@ -16,15 +16,9 @@ import { Square, XIcon, } from "lucide-react"; -import { - memo, - type ReactNode, - useCallback, - useEffect, - useRef, - useState, -} from "react"; +import { memo, type ReactNode, useRef } from "react"; import TextareaAutosize from "react-textarea-autosize"; +import { cn } from "utils/cn"; import { formatProviderLabel } from "./modelOptions"; import { QueuedMessagesList } from "./QueuedMessagesList"; @@ -41,16 +35,13 @@ export interface AgentContextUsage { } interface AgentChatInputProps { - onSend: (message: string, editedMessageID?: number) => Promise; + onSend: (message: string) => void; placeholder?: string; isDisabled: boolean; isLoading: boolean; - // Optional initial value for the textarea (e.g. restored from - // localStorage on the create page). - initialValue?: string; - // Fires whenever the textarea value changes, useful for persisting - // the draft externally. - onInputChange?: (value: string) => void; + // Controlled input value. The parent owns the text state. + value: string; + onChange: (value: string) => void; // Model selector. selectedModel: string; onModelChange: (value: string) => void; @@ -71,6 +62,13 @@ interface AgentChatInputProps { queuedMessages?: readonly ChatQueuedMessage[]; onDeleteQueuedMessage?: (id: number) => Promise | void; onPromoteQueuedMessage?: (id: number) => Promise | void; + // Queue editing state, owned by the parent. + editingQueuedMessageID?: number | null; + onStartQueueEdit?: (id: number, text: string) => void; + onCancelQueueEdit?: () => void; + // History editing state, owned by the parent. + isEditingHistoryMessage?: boolean; + onCancelHistoryEdit?: () => void; // Optional context-usage summary shown to the left of the send button. // Pass `null` to render fallback values (e.g. when limit is unknown). @@ -79,13 +77,6 @@ interface AgentChatInputProps { // When true the entire input sticks to the bottom of the scroll // container (used in the detail page). sticky?: boolean; - // External edit request — when set, replaces the input text and - // focuses the textarea. Use a unique `key` to allow re-editing the - // same text. - editRequest?: { text: string; messageId?: number; key: number } | null; - // Called when the user cancels or completes a history edit so the - // parent can clear the editing highlight. - onEditCleared?: () => void; } const hasFiniteTokenValue = (value: number | undefined): value is number => @@ -144,7 +135,6 @@ const ContextUsageIndicator = memo<{ usage: AgentContextUsage | null }>( const hasPercent = percentUsed !== null; const percentLabel = percentUsed === null ? "--" : `${Math.round(percentUsed)}%`; - const indicatorLabel = null; const clampedPercent = hasPercent ? Math.min(Math.max(percentUsed, 0), 100) : 100; @@ -158,14 +148,13 @@ const ContextUsageIndicator = memo<{ usage: AgentContextUsage | null }>( return ( - @@ -191,12 +180,7 @@ const ContextUsageIndicator = memo<{ usage: AgentContextUsage | null }>( }} /> - {indicatorLabel !== null && ( - - {indicatorLabel} - - )} - +
@@ -224,8 +208,8 @@ export const AgentChatInput = memo( placeholder = "Type a message...", isDisabled, isLoading, - initialValue = "", - onInputChange, + value, + onChange, selectedModel, onModelChange, modelOptions, @@ -240,209 +224,62 @@ export const AgentChatInput = memo( queuedMessages = [], onDeleteQueuedMessage, onPromoteQueuedMessage, + editingQueuedMessageID = null, + onStartQueueEdit, + onCancelQueueEdit, + isEditingHistoryMessage = false, + onCancelHistoryEdit, contextUsage, sticky = false, - editRequest = null, - onEditCleared, }) => { - const [input, setInput] = useState(initialValue); - const [editingQueuedMessageID, setEditingQueuedMessageID] = useState< - number | null - >(null); - const [draftBeforeQueueEdit, setDraftBeforeQueueEdit] = useState< - string | null - >(null); const textareaRef = useRef(null); - // Handle external edit requests (e.g. clicking a historical - // user message's edit icon). - const lastEditKeyRef = useRef(null); - const [isEditingHistoryMessage, setIsEditingHistoryMessage] = - useState(false); - const [editingHistoryMessageID, setEditingHistoryMessageID] = useState< - number | null - >(null); - const [draftBeforeHistoryEdit, setDraftBeforeHistoryEdit] = useState< - string | null - >(null); - useEffect(() => { - if (!editRequest || editRequest.key === lastEditKeyRef.current) { + const canSend = + !isDisabled && !isLoading && hasModelOptions && !!value.trim(); + + const handleSubmit = () => { + if (!canSend) { return; } - lastEditKeyRef.current = editRequest.key; - setDraftBeforeHistoryEdit((current) => - isEditingHistoryMessage ? current : input, - ); - setIsEditingHistoryMessage(true); - setEditingHistoryMessageID(editRequest.messageId ?? null); - setInput(editRequest.text); - onInputChange?.(editRequest.text); + onSend(value.trim()); textareaRef.current?.focus(); - }, [editRequest, input, isEditingHistoryMessage, onInputChange]); + }; - const handleCancelHistoryEdit = useCallback(() => { - if (!isEditingHistoryMessage) { - return; - } - const restored = draftBeforeHistoryEdit ?? ""; - setIsEditingHistoryMessage(false); - setEditingHistoryMessageID(null); - setDraftBeforeHistoryEdit(null); - setInput(restored); - onInputChange?.(restored); - onEditCleared?.(); - textareaRef.current?.focus(); - }, [ - draftBeforeHistoryEdit, - isEditingHistoryMessage, - onEditCleared, - onInputChange, - ]); - - useEffect(() => { - if (editingQueuedMessageID === null) { - return; - } - const stillQueued = queuedMessages.some( - (message) => message.id === editingQueuedMessageID, - ); - if (stillQueued) { - return; - } - setEditingQueuedMessageID(null); - setDraftBeforeQueueEdit(null); - }, [editingQueuedMessageID, queuedMessages]); - - const handleSubmit = useCallback(async () => { - const text = input.trim(); - if (!text || isDisabled || !hasModelOptions) { - return; - } - - const queueEditID = editingQueuedMessageID; - const editedMessageID = - isEditingHistoryMessage && editingHistoryMessageID !== null - ? editingHistoryMessageID - : undefined; - // Capture the raw input before clearing so we can restore - // it if the request fails. - const capturedInput = input; - - // Clear the input and editing state immediately so the - // user can start typing their next message without waiting - // for the network round-trip. - setInput(""); - onInputChange?.(""); - if (queueEditID !== null) { - setEditingQueuedMessageID(null); - setDraftBeforeQueueEdit(null); - } - if (isEditingHistoryMessage) { - setIsEditingHistoryMessage(false); - setEditingHistoryMessageID(null); - setDraftBeforeHistoryEdit(null); - onEditCleared?.(); - } - - try { - await onSend(capturedInput, editedMessageID); - if (queueEditID !== null && onDeleteQueuedMessage) { - await onDeleteQueuedMessage(queueEditID); + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + if (editingQueuedMessageID !== null) { + e.preventDefault(); + onCancelQueueEdit?.(); + } else if (isEditingHistoryMessage) { + e.preventDefault(); + onCancelHistoryEdit?.(); + } else if (isStreaming && onInterrupt && !isInterruptPending) { + e.preventDefault(); + onInterrupt(); } - } catch { - // Restore the input so the user can retry. - setInput(capturedInput); - onInputChange?.(capturedInput); - } finally { - // Re-focus the textarea so the user can keep typing. - textareaRef.current?.focus(); - } - }, [ - editingQueuedMessageID, - editingHistoryMessageID, - hasModelOptions, - input, - isDisabled, - isEditingHistoryMessage, - onDeleteQueuedMessage, - onEditCleared, - onInputChange, - onSend, - ]); - - const handleStartQueueEdit = useCallback( - (id: number, text: string) => { - setDraftBeforeQueueEdit((current) => - editingQueuedMessageID === null ? input : current, - ); - setEditingQueuedMessageID(id); - setInput(text); - onInputChange?.(text); - textareaRef.current?.focus(); - }, - [editingQueuedMessageID, input, onInputChange], - ); - - const handleCancelQueueEdit = useCallback(() => { - if (editingQueuedMessageID === null) { return; } - const restored = draftBeforeQueueEdit ?? ""; - setEditingQueuedMessageID(null); - setDraftBeforeQueueEdit(null); - setInput(restored); - onInputChange?.(restored); - textareaRef.current?.focus(); - }, [draftBeforeQueueEdit, editingQueuedMessageID, onInputChange]); + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + // If the input is empty and there are queued messages, + // promote the first one instead of submitting. + if ( + !value.trim() && + !isDisabled && + !isLoading && + queuedMessages.length > 0 && + onPromoteQueuedMessage + ) { + void onPromoteQueuedMessage(queuedMessages[0].id); + return; + } + handleSubmit(); + } + }; const sendButtonLabel = isStreaming && editingQueuedMessageID === null ? "Queue message" : "Send"; - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Escape") { - if (editingQueuedMessageID !== null) { - e.preventDefault(); - handleCancelQueueEdit(); - } else if (isEditingHistoryMessage) { - e.preventDefault(); - handleCancelHistoryEdit(); - } else if (isStreaming && onInterrupt && !isInterruptPending) { - e.preventDefault(); - onInterrupt(); - } - return; - } - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - // If the input is empty and there are queued messages, - // promote the first one instead of submitting. - if ( - !input.trim() && - queuedMessages.length > 0 && - onPromoteQueuedMessage - ) { - void onPromoteQueuedMessage(queuedMessages[0].id); - return; - } - void handleSubmit(); - } - }, - [ - editingQueuedMessageID, - handleCancelHistoryEdit, - handleCancelQueueEdit, - handleSubmit, - input, - isEditingHistoryMessage, - isInterruptPending, - isStreaming, - onInterrupt, - onPromoteQueuedMessage, - queuedMessages, - ], - ); - const content = (
{queuedMessages.length > 0 && ( @@ -450,17 +287,17 @@ export const AgentChatInput = memo( messages={queuedMessages} onDelete={(id) => { if (id === editingQueuedMessageID) { - handleCancelQueueEdit(); + onCancelQueueEdit?.(); } void onDeleteQueuedMessage?.(id); }} onPromote={(id) => { if (id === editingQueuedMessageID) { - handleCancelQueueEdit(); + onCancelQueueEdit?.(); } void onPromoteQueuedMessage?.(id); }} - onEdit={handleStartQueueEdit} + onEdit={onStartQueueEdit} editingMessageID={editingQueuedMessageID} className="mb-2" /> @@ -475,7 +312,7 @@ export const AgentChatInput = memo( type="button" variant="subtle" size="sm" - onClick={handleCancelQueueEdit} + onClick={onCancelQueueEdit} className="h-7 px-2 text-content-secondary hover:text-content-primary" > Cancel @@ -495,7 +332,7 @@ export const AgentChatInput = memo( variant="subtle" size="icon" aria-label="Cancel editing" - onClick={handleCancelHistoryEdit} + onClick={onCancelHistoryEdit} disabled={isLoading} className="size-6 rounded text-content-secondary hover:text-content-primary" > @@ -508,11 +345,8 @@ export const AgentChatInput = memo( aria-label="Chat message" className="min-h-[120px] w-full resize-none border-none bg-transparent px-3 py-2 font-sans text-[15px] leading-6 text-content-primary outline-none placeholder:text-content-secondary disabled:cursor-not-allowed disabled:opacity-70" placeholder={placeholder} - value={input} - onChange={(e) => { - setInput(e.target.value); - onInputChange?.(e.target.value); - }} + value={value} + onChange={(e) => onChange(e.target.value)} onKeyDown={handleKeyDown} disabled={isDisabled} minRows={4} @@ -528,7 +362,6 @@ export const AgentChatInput = memo( formatProviderLabel={formatProviderLabel} dropdownSide="top" dropdownAlign="center" - className="" /> {leftActions} {inputStatusText && ( @@ -557,9 +390,8 @@ export const AgentChatInput = memo( size="icon" variant="default" className="size-7 rounded-full transition-colors [&>svg]:!size-6 flex items-center justify-center" - onClick={() => void handleSubmit()} - disabled={isDisabled || !hasModelOptions || !input.trim()} - title={sendButtonLabel} + onClick={handleSubmit} + disabled={!canSend} > {isLoading ? ( diff --git a/site/src/pages/AgentsPage/AgentDetail.tsx b/site/src/pages/AgentsPage/AgentDetail.tsx index fe57a6f84d..518b71ca92 100644 --- a/site/src/pages/AgentsPage/AgentDetail.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.tsx @@ -187,7 +187,7 @@ const AgentDetailTimeline: FC = ({ interface AgentDetailInputProps { store: ChatStoreHandle; compressionThreshold: number | undefined; - onSend: (message: string, editedMessageID?: number) => Promise; + onSend: (message: string) => void; onDeleteQueuedMessage: (id: number) => Promise; onPromoteQueuedMessage: (id: number) => Promise; onInterrupt: () => void; @@ -201,8 +201,15 @@ interface AgentDetailInputProps { modelSelectorPlaceholder: string; inputStatusText: string | null; modelCatalogStatusMessage: string | null; - editRequest?: { text: string; messageId?: number; key: number } | null; - onEditCleared?: () => void; + // Controlled input value and editing state, owned by the + // conversation component. + inputValue: string; + onInputChange: (value: string) => void; + editingQueuedMessageID: number | null; + onStartQueueEdit: (id: number, text: string) => void; + onCancelQueueEdit: () => void; + isEditingHistoryMessage: boolean; + onCancelHistoryEdit: () => void; } const AgentDetailInput: FC = ({ @@ -222,8 +229,13 @@ const AgentDetailInput: FC = ({ modelSelectorPlaceholder, inputStatusText, modelCatalogStatusMessage, - editRequest, - onEditCleared, + inputValue, + onInputChange, + editingQueuedMessageID, + onStartQueueEdit, + onCancelQueueEdit, + isEditingHistoryMessage, + onCancelHistoryEdit, }) => { const messagesByID = useChatSelector(store, selectMessagesByID); const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs); @@ -251,9 +263,16 @@ const AgentDetailInput: FC = ({ return ( = ({ modelSelectorPlaceholder={modelSelectorPlaceholder} inputStatusText={inputStatusText} modelCatalogStatusMessage={modelCatalogStatusMessage} - editRequest={editRequest} - onEditCleared={onEditCleared} sticky /> ); @@ -317,22 +334,88 @@ const AgentDetailConversation: FC = ({ modelCatalogStatusMessage, savingMessageId, }) => { - const [editRequest, setEditRequest] = useState<{ - text: string; - messageId: number; - key: number; - } | null>(null); + const [inputValue, setInputValue] = useState(""); + + // -- History editing state -- + const [editingMessageId, setEditingMessageId] = useState(null); + const [draftBeforeHistoryEdit, setDraftBeforeHistoryEdit] = useState< + string | null + >(null); const handleEditUserMessage = useCallback( (messageId: number, text: string) => { - setEditRequest({ text, messageId, key: Date.now() }); + setDraftBeforeHistoryEdit((prev) => + editingMessageId !== null ? prev : inputValue, + ); + setEditingMessageId(messageId); + setInputValue(text); }, - [], + [editingMessageId, inputValue], ); - const handleEditCleared = useCallback(() => { - setEditRequest(null); - }, []); + const handleCancelHistoryEdit = useCallback(() => { + setInputValue(draftBeforeHistoryEdit ?? ""); + setEditingMessageId(null); + setDraftBeforeHistoryEdit(null); + }, [draftBeforeHistoryEdit]); + + // -- Queue editing state -- + const [editingQueuedMessageID, setEditingQueuedMessageID] = useState< + number | null + >(null); + const [draftBeforeQueueEdit, setDraftBeforeQueueEdit] = useState< + string | null + >(null); + + const handleStartQueueEdit = useCallback( + (id: number, text: string) => { + setDraftBeforeQueueEdit((prev) => + editingQueuedMessageID === null ? inputValue : prev, + ); + setEditingQueuedMessageID(id); + setInputValue(text); + }, + [editingQueuedMessageID, inputValue], + ); + + const handleCancelQueueEdit = useCallback(() => { + setInputValue(draftBeforeQueueEdit ?? ""); + setEditingQueuedMessageID(null); + setDraftBeforeQueueEdit(null); + }, [draftBeforeQueueEdit]); + + // Wraps the parent onSend to clear local input/editing state + // and handle queue-edit deletion. + const handleSendFromInput = useCallback( + (message: string) => { + const editedMessageID = + editingMessageId !== null ? editingMessageId : undefined; + const queueEditID = editingQueuedMessageID; + + // Clear input and editing state optimistically. + setInputValue(""); + if (editingMessageId !== null) { + setEditingMessageId(null); + setDraftBeforeHistoryEdit(null); + } + if (queueEditID !== null) { + setEditingQueuedMessageID(null); + setDraftBeforeQueueEdit(null); + } + + void onSend(message, editedMessageID) + .then(() => { + if (queueEditID !== null) { + void onDeleteQueuedMessage(queueEditID); + } + }) + .catch(() => { + // Restore input so the user can retry. + setInputValue(message); + }); + }, + [editingMessageId, editingQueuedMessageID, onDeleteQueuedMessage, onSend], + ); return ( <> @@ -341,13 +424,13 @@ const AgentDetailConversation: FC = ({ chatID={chatID} persistedErrorReason={persistedErrorReason} onEditUserMessage={handleEditUserMessage} - editingMessageId={editRequest?.messageId ?? null} + editingMessageId={editingMessageId} savingMessageId={savingMessageId} /> = ({ modelSelectorPlaceholder={modelSelectorPlaceholder} inputStatusText={inputStatusText} modelCatalogStatusMessage={modelCatalogStatusMessage} - editRequest={editRequest} - onEditCleared={handleEditCleared} + inputValue={inputValue} + onInputChange={setInputValue} + editingQueuedMessageID={editingQueuedMessageID} + onStartQueueEdit={handleStartQueueEdit} + onCancelQueueEdit={handleCancelQueueEdit} + isEditingHistoryMessage={editingMessageId !== null} + onCancelHistoryEdit={handleCancelHistoryEdit} /> ); @@ -812,7 +900,9 @@ const AgentDetail: FC = () => {
{}} + value="" + onChange={() => {}} isDisabled={isInputDisabled} isLoading={false} selectedModel={selectedModel} diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx index 7c65459adb..1f66979e83 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -520,24 +520,24 @@ export const AgentsEmptyState: FC = ({ canManageChatModelConfigs, topBarActionsRef, }) => { - const initialInput = useMemo(() => { + const [inputValue, setInputValue] = useState(() => { if (typeof window === "undefined") { return ""; } return localStorage.getItem(emptyInputStorageKey) ?? ""; - }, []); - const initialSystemPrompt = useMemo(() => { + }); + const initialSystemPrompt = () => { if (typeof window === "undefined") { return ""; } return localStorage.getItem(systemPromptStorageKey) ?? ""; - }, []); - const initialLastModelConfigID = useMemo(() => { + }; + const [initialLastModelConfigID] = useState(() => { if (typeof window === "undefined") { return ""; } return localStorage.getItem(lastModelConfigIDStorageKey) ?? ""; - }, []); + }); const modelIDByConfigID = useMemo(() => { const optionIDByRef = new Map(); for (const option of modelOptions) { @@ -671,6 +671,7 @@ export const AgentsEmptyState: FC = ({ }; const handleInputChange = useCallback((value: string) => { + setInputValue(value); if (typeof window !== "undefined") { localStorage.setItem(emptyInputStorageKey, value); } @@ -700,8 +701,8 @@ export const AgentsEmptyState: FC = ({ ); const handleSend = useCallback( - async (message: string) => { - await onCreateChat({ + (message: string) => { + void onCreateChat({ message, workspaceId: selectedWorkspaceIdRef.current ?? undefined, model: selectedModelRef.current || undefined, @@ -741,8 +742,8 @@ export const AgentsEmptyState: FC = ({ placeholder="Ask Coder to build, fix bugs, or explore your project..." isDisabled={isCreating} isLoading={isCreating} - initialValue={initialInput} - onInputChange={handleInputChange} + value={inputValue} + onChange={handleInputChange} selectedModel={selectedModel} onModelChange={handleModelChange} modelOptions={modelOptions}