fix: refocus chat input after message send completes (#22666)

## 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.
This commit is contained in:
Kyle Carberry
2026-03-05 09:54:37 -05:00
committed by GitHub
parent 0d8a0af2b5
commit be1d58bc6e
2 changed files with 40 additions and 0 deletions
@@ -263,6 +263,18 @@ export const AgentChatInput = memo<AgentChatInputProps>(
[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(() => {
@@ -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");