From be1d58bc6ee5fee8c42f35519b419b603da83985 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 5 Mar 2026 09:54:37 -0500 Subject: [PATCH] fix: refocus chat input after message send completes (#22666) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The chat input loses focus after sending a message. Users have to click back into the input field to type their next message. ## Root Cause When a message is sent: 1. `handleSubmit` in `AgentChatInput` calls `onSend(text)` then immediately `focus()` — but this is premature 2. The async send sets `isLoading=true`, which disables the editor via `EditableStatePlugin` 3. When the send resolves, `handleSendFromInput` calls `clear()` and `focus()` — but the editor may still be disabled at this point (React hasn't re-rendered yet) 4. When React re-renders with `isLoading=false`, the editor becomes editable again but nobody restores focus ## Fix Added a `useEffect` in `AgentChatInput` that watches for `isLoading` transitioning from `true` to `false` and calls `focus()` on the editor. This ensures focus is restored *after* React has re-enabled the editor, not prematurely. ## Test Added a test in `AgentDetail.test.ts` verifying that `focus()` is called on the input ref after `handleSendFromInput` resolves. --- site/src/pages/AgentsPage/AgentChatInput.tsx | 12 ++++++++ site/src/pages/AgentsPage/AgentDetail.test.ts | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/site/src/pages/AgentsPage/AgentChatInput.tsx b/site/src/pages/AgentsPage/AgentChatInput.tsx index 1ea786c582..a67775dd8a 100644 --- a/site/src/pages/AgentsPage/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/AgentChatInput.tsx @@ -263,6 +263,18 @@ export const AgentChatInput = memo( [onContentChange], ); + // Re-focus the editor after a send completes (isLoading goes + // from true → false) so the user can immediately type again. + // Uses the "store previous value in state" pattern recommended + // by React for responding to prop changes during render. + const [prevIsLoading, setPrevIsLoading] = useState(isLoading); + if (prevIsLoading !== isLoading) { + setPrevIsLoading(isLoading); + if (prevIsLoading && !isLoading) { + internalRef.current?.focus(); + } + } + const canSend = !isDisabled && !isLoading && hasModelOptions && hasContent; const handleSubmit = useCallback(() => { diff --git a/site/src/pages/AgentsPage/AgentDetail.test.ts b/site/src/pages/AgentsPage/AgentDetail.test.ts index 521e285464..4c6e54425d 100644 --- a/site/src/pages/AgentsPage/AgentDetail.test.ts +++ b/site/src/pages/AgentsPage/AgentDetail.test.ts @@ -1,4 +1,5 @@ import { act, renderHook } from "@testing-library/react"; + import { beforeEach, describe, expect, it, vi } from "vitest"; import { draftInputStorageKeyPrefix, @@ -86,6 +87,33 @@ describe("useConversationEditingState", () => { unmount(); }); + it("calls focus on the input ref after a successful send", async () => { + const { result, onSend, unmount } = renderEditing(); + + // Attach a mock ChatMessageInputRef to the chatInputRef + const mockFocus = vi.fn(); + const mockClear = vi.fn(); + const mockInputRef = { + focus: mockFocus, + clear: mockClear, + insertText: vi.fn(), + getValue: vi.fn().mockReturnValue(""), + }; + // The hook exposes chatInputRef – assign the mock to it. + result.current.chatInputRef.current = mockInputRef; + + await act(async () => { + result.current.handleSendFromInput("hello"); + await vi.waitFor(() => { + expect(onSend).toHaveBeenCalledWith("hello", undefined); + }); + }); + + expect(mockClear).toHaveBeenCalled(); + expect(mockFocus).toHaveBeenCalled(); + unmount(); + }); + it("clears the draft from localStorage on successful send", async () => { localStorage.setItem(expectedKey, "draft to clear");