From 4763772c4fb615b91ef5bccd67eef9c58c140436 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Thu, 9 Jul 2026 14:26:59 +0800 Subject: [PATCH] feat(chat): auto-focus compose box on new chat (#5146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starting a new chat (⊕ new chat on the Chat tab, or ⊕ / switching agent in the floating window) now pulls keyboard focus into the compose box so the user can type immediately, instead of having to click into it first. Focus is driven by a monotonic `focusRequest` nonce bumped only by the new-chat handlers, so selecting an existing chat or opening one via a `?session=` deep link never steals focus. ContentEditor.focus() latches through to onCreate when the editor is not yet mounted (immediatelyRender: false), so the freshly-mounted compose box focuses on its first frame. Co-authored-by: Lambda Co-authored-by: multica-agent --- packages/views/chat/chat-page.tsx | 1 + .../views/chat/components/chat-input.test.tsx | 40 ++++++++++++++++++- packages/views/chat/components/chat-input.tsx | 15 +++++++ .../views/chat/components/chat-window.tsx | 15 ++++++- .../chat/components/use-chat-controller.ts | 16 +++++++- packages/views/editor/content-editor.tsx | 13 +++++- 6 files changed, 93 insertions(+), 7 deletions(-) diff --git a/packages/views/chat/chat-page.tsx b/packages/views/chat/chat-page.tsx index 3b544d0b31..b2002642e5 100644 --- a/packages/views/chat/chat-page.tsx +++ b/packages/views/chat/chat-page.tsx @@ -202,6 +202,7 @@ export function ChatPage() { noAgent={c.noAgent} agentArchived={c.isAgentArchived} agentName={c.activeAgent?.name} + focusRequest={c.focusInputRequest} /> ); diff --git a/packages/views/chat/components/chat-input.test.tsx b/packages/views/chat/components/chat-input.test.tsx index 10187bba7f..e2c4f92580 100644 --- a/packages/views/chat/components/chat-input.test.tsx +++ b/packages/views/chat/components/chat-input.test.tsx @@ -41,7 +41,7 @@ const editorProps = vi.hoisted(() => ({ })); // Records imperative editor calls so tests can assert whether a commit // scrubbed the editor (clearEditor) or left it intact (fire-and-forget). -const editorState = vi.hoisted(() => ({ cleared: 0, blurred: 0 })); +const editorState = vi.hoisted(() => ({ cleared: 0, blurred: 0, focused: 0 })); vi.mock("../../editor", () => ({ useFileDropZone: ({ onDrop }: { onDrop: (files: File[]) => void }) => { @@ -78,7 +78,9 @@ vi.mock("../../editor", () => ({ blur: () => { editorState.blurred += 1; }, - focus: () => {}, + focus: () => { + editorState.focused += 1; + }, uploadFile: async (file: File) => { uploadingRef.current += 1; try { @@ -150,6 +152,7 @@ beforeEach(() => { editorProps.last = null; editorState.cleared = 0; editorState.blurred = 0; + editorState.focused = 0; const state = useChatStore.getState() as unknown as { activeSessionId: string | null; selectedAgentId: string; @@ -202,6 +205,39 @@ function renderInput(props: Partial> = {} return { onSend, onUploadFile }; } +describe("ChatInput focusRequest", () => { + it("focuses the editor when focusRequest becomes a non-zero value (new chat)", () => { + const { rerender } = render( + + + , + ); + // The inert initial value must not steal focus (e.g. a plain deep-link open). + expect(editorState.focused).toBe(0); + + // Starting a new chat bumps the nonce — the compose box grabs focus. + rerender( + + + , + ); + expect(editorState.focused).toBe(1); + + // Each subsequent new chat re-focuses. + rerender( + + + , + ); + expect(editorState.focused).toBe(2); + }); + + it("does not focus on mount when focusRequest is undefined or 0", () => { + renderInput(); + expect(editorState.focused).toBe(0); + }); +}); + describe("ChatInput @ context wiring", () => { it("configures chat @ with current/recent issue/project context", () => { const contextItems = [ diff --git a/packages/views/chat/components/chat-input.tsx b/packages/views/chat/components/chat-input.tsx index 79281953c8..d3a133fc37 100644 --- a/packages/views/chat/components/chat-input.tsx +++ b/packages/views/chat/components/chat-input.tsx @@ -85,6 +85,11 @@ interface ChatInputProps { leftAdornment?: ReactNode; /** Chat @ suggestions: current/recent issue/project entries. */ contextItems?: MentionItem[]; + /** Monotonic nonce bumped by the owner whenever the compose box should grab + * keyboard focus — currently on "new chat" so the user can type right away. + * 0 (the initial value) is inert, so a plain deep-link open never steals + * focus; only an explicit bump does. */ + focusRequest?: number; } export function ChatInput({ @@ -100,6 +105,7 @@ export function ChatInput({ agentName, leftAdornment, contextItems, + focusRequest, }: ChatInputProps) { const { t } = useT("chat"); const editorRef = useRef(null); @@ -168,6 +174,15 @@ export function ChatInput({ // attachment never binds to the chat message. const uploadMapRef = useRef>(new Map()); + // Grab keyboard focus when the owner bumps `focusRequest` (a new chat was + // started) so the user can type immediately. The editor's `focus()` latches + // through to `onCreate` when it isn't mounted yet, so this works even on the + // first render of a freshly-mounted compose box. `0` is inert on purpose. + useEffect(() => { + if (!focusRequest) return; + editorRef.current?.focus(); + }, [focusRequest]); + useEffect(() => { if (!restoreDraftRequest) { consumedRestoreIdRef.current = null; diff --git a/packages/views/chat/components/chat-window.tsx b/packages/views/chat/components/chat-window.tsx index 75498a9bae..b1ae8a18a1 100644 --- a/packages/views/chat/components/chat-window.tsx +++ b/packages/views/chat/components/chat-window.tsx @@ -220,6 +220,14 @@ export function ChatWindow() { const handleRestoreDraftConsumed = useCallback(() => { setRestoreDraftRequest(null); }, []); + // Nonce handed to ChatInput to pull focus into the compose box when a new + // chat starts (⊕ or switching agent). 0 is inert so opening the window on an + // existing session never steals focus. + const [focusRequest, setFocusRequest] = useState(0); + const requestInputFocus = useCallback( + () => setFocusRequest((n) => n + 1), + [], + ); // Legacy archived sessions (the old soft-archive feature was removed but // pre-existing rows with status='archived' may still exist) are excluded @@ -663,8 +671,9 @@ export function ChatWindow() { setSelectedAgentId(agent.id); // Reset session when switching agent setActiveSession(null); + requestInputFocus(); }, - [activeAgent, selectedAgentId, activeSessionId, setSelectedAgentId, setActiveSession], + [activeAgent, selectedAgentId, activeSessionId, setSelectedAgentId, setActiveSession, requestInputFocus], ); const handleNewChat = useCallback(() => { @@ -673,7 +682,8 @@ export function ChatWindow() { previousPendingTask: pendingTaskId, }); setActiveSession(null); - }, [activeSessionId, pendingTaskId, setActiveSession]); + requestInputFocus(); + }, [activeSessionId, pendingTaskId, setActiveSession, requestInputFocus]); const handleSelectSession = useCallback( (session: ChatSession) => { @@ -864,6 +874,7 @@ export function ChatWindow() { /> } contextItems={contextItems} + focusRequest={focusRequest} /> ); diff --git a/packages/views/chat/components/use-chat-controller.ts b/packages/views/chat/components/use-chat-controller.ts index 20d23b7ff4..cc5839fef2 100644 --- a/packages/views/chat/components/use-chat-controller.ts +++ b/packages/views/chat/components/use-chat-controller.ts @@ -241,6 +241,14 @@ export function useChatController(opts?: { isActive?: boolean }) { const handleRestoreDraftConsumed = useCallback(() => { setRestoreDraftRequest(null); }, []); + // Nonce handed to ChatInput to pull focus into the compose box when a new + // chat starts. Bumped by handleNewChat / handleStartNewChat only, so + // selecting an existing chat or a deep link never steals focus. + const [focusInputRequest, setFocusInputRequest] = useState(0); + const requestInputFocus = useCallback( + () => setFocusInputRequest((n) => n + 1), + [], + ); const currentSession = activeSessionId ? sessions.find((s) => s.id === activeSessionId) @@ -575,7 +583,8 @@ export function useChatController(opts?: { isActive?: boolean }) { previousPendingTask: pendingTaskId, }); setActiveSession(null); - }, [activeSessionId, pendingTaskId, setActiveSession]); + requestInputFocus(); + }, [activeSessionId, pendingTaskId, setActiveSession, requestInputFocus]); // Start a fresh chat bound to a chosen agent. Unlike handleSelectAgent this // does not no-op when the agent is unchanged — "new chat" always clears the @@ -589,8 +598,9 @@ export function useChatController(opts?: { isActive?: boolean }) { }); setSelectedAgentId(agent.id); setActiveSession(null); + requestInputFocus(); }, - [activeSessionId, setSelectedAgentId, setActiveSession], + [activeSessionId, setSelectedAgentId, setActiveSession, requestInputFocus], ); const handleSelectSession = useCallback( @@ -667,6 +677,8 @@ export function useChatController(opts?: { isActive?: boolean }) { // draft restore restoreDraftRequest, handleRestoreDraftConsumed, + // compose-box focus nonce (bumped on new chat) + focusInputRequest, // actions handleSend, handleStop, diff --git a/packages/views/editor/content-editor.tsx b/packages/views/editor/content-editor.tsx index f65a9edff5..87206c5f86 100644 --- a/packages/views/editor/content-editor.tsx +++ b/packages/views/editor/content-editor.tsx @@ -305,6 +305,11 @@ const ContentEditor = forwardRef( const queryClient = useQueryClient(); const initialContent = defaultValue ? preprocessMarkdown(defaultValue) : ""; + // With `immediatelyRender: false` the Tiptap instance is created after + // mount, so an imperative `focus()` fired on the same tick (e.g. chat + // auto-focusing a brand-new conversation) would hit a null editor and no-op. + // Latch the intent here and honor it in `onCreate` once the editor exists. + const focusOnReadyRef = useRef(false); // Large markdown is parsed in chunks to dodge marked's O(n²) tokenizer (see // parseMarkdownChunked). Small docs stay on the single-parse fast path. const mountChunked = initialContent.length > MARKDOWN_CHUNK_THRESHOLD; @@ -337,6 +342,10 @@ const ContentEditor = forwardRef( // repair it so the mounted editor has a real cursor in the list. repairEmptyListItems(ed); lastEmittedRef.current = normalizeEditorMarkdown(ed); + if (focusOnReadyRef.current) { + focusOnReadyRef.current = false; + ed.commands.focus("end"); + } }, content: mountChunked ? "" : initialContent, contentType: mountChunked @@ -519,7 +528,9 @@ const ContentEditor = forwardRef( editor?.commands.clearContent(); }, focus: () => { - editor?.commands.focus(); + if (editor) editor.commands.focus(); + // Editor not mounted yet — defer the focus to `onCreate`. + else focusOnReadyRef.current = true; }, blur: () => { editor?.commands.blur();