From f1de9812ae9c47c5e840f491f19952ff7abfe547 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 14 Nov 2026 00:30:24 -0500 Subject: [PATCH] fix(web): a new thread you ask for puts the cursor in the message box A new empty thread opened without focus in its composer, so after Cmd+N (or the New thread button, or the command palette) typing went nowhere until a click in the box. Asking for a new thread now requests focus for that draft, and the ChatView showing it answers once, whether it was already on screen or mounts after the navigation. The draft the app opens by itself at launch still leaves the keyboard alone, and so do touch devices, where focus would open the keyboard. --- .../components/ChatView.drafts.browser.tsx | 18 ++++++ .../components/ChatView.palette.browser.tsx | 8 +++ apps/web/src/components/ChatView.tsx | 7 +++ apps/web/src/hooks/useHandleNewThread.ts | 13 +++++ apps/web/src/newThreadComposerFocus.ts | 55 +++++++++++++++++++ apps/web/src/routes/_chat.index.tsx | 3 +- 6 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/newThreadComposerFocus.ts diff --git a/apps/web/src/components/ChatView.drafts.browser.tsx b/apps/web/src/components/ChatView.drafts.browser.tsx index 87e8c499f..eac7bf8b7 100644 --- a/apps/web/src/components/ChatView.drafts.browser.tsx +++ b/apps/web/src/components/ChatView.drafts.browser.tsx @@ -620,6 +620,24 @@ describe("ChatView (full app): new threads and drafts", () => { expect( useComposerDraftStore.getState().getDraftSession(draftIdFromPath(newThreadPath)), ).toMatchObject({ envMode: "worktree", worktreePath: null }); + + // Whoever asks for a new thread is about to type: its composer has the + // keyboard, though an empty draft that just opens leaves it alone. + const expectNewThreadComposerFocused = () => + vi.waitFor(() => { + const editor = document.querySelector('[data-testid="composer-editor"]'); + expect(editor).not.toBeNull(); + expect(document.activeElement).toBe(editor); + }); + await expectNewThreadComposerFocused(); + + // Asking again on the same empty draft navigates nowhere and still + // brings the keyboard back. + (document.activeElement as HTMLElement).blur(); + expect(document.activeElement).toBe(document.body); + dispatchChatNewShortcut(); + await expectNewThreadComposerFocused(); + expect(mounted.router.state.location.pathname).toBe(newThreadPath); } finally { await mounted.cleanup(); } diff --git a/apps/web/src/components/ChatView.palette.browser.tsx b/apps/web/src/components/ChatView.palette.browser.tsx index c4158908e..aa0eab1cd 100644 --- a/apps/web/src/components/ChatView.palette.browser.tsx +++ b/apps/web/src/components/ChatView.palette.browser.tsx @@ -1079,6 +1079,14 @@ describe("ChatView (full app): command palette and search", () => { const draftThread = useComposerDraftStore.getState().getDraftSession(nextDraftId); expect(draftThread?.projectId).toBe(SECOND_PROJECT_ID); expect(draftThread?.envMode).toBe("worktree"); + + // The new thread's composer keeps the keyboard once the palette is gone. + await expect.element(palette).not.toBeInTheDocument(); + await vi.waitFor(() => { + const editor = document.querySelector('[data-testid="composer-editor"]'); + expect(editor).not.toBeNull(); + expect(document.activeElement).toBe(editor); + }); } finally { await mounted.cleanup(); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8ed2c80bd..3f3647cfa 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -226,6 +226,7 @@ import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { formatProviderDriverKindLabel } from "../providerModels"; import { useSettings, useUpdateSettings } from "../hooks/useSettings"; import { roomsEnabledFor } from "../hooks/useRoomsEnabled"; +import { useNewThreadComposerFocus } from "../newThreadComposerFocus"; import { applyMovedDraftDefaults, buildNewThreadParticipants, @@ -4848,7 +4849,13 @@ export default function ChatView(props: ChatViewProps) { // Pristine drafts (the home surface) open without stealing focus; the // composer takes focus once a conversation exists or via an explicit action. + // Asking for a new thread is one: useNewThreadHandler requests focus for it. const skipEntryComposerFocus = isLocalDraftThread && (activeThread?.messages.length ?? 0) === 0; + useNewThreadComposerFocus({ + draftId, + enabled: !isCoarsePointer, + focus: scheduleComposerFocus, + }); useEffect(() => { if ( !activeThread?.id || diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index becc76229..3b23ca1a6 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -20,6 +20,7 @@ import { import { preserveRightPanelSearchParamsForDraftNavigation } from "../diffRouteSearch"; import { resolveNewThreadPlacement } from "../lib/chatThreadActions"; import { newDraftId, newThreadId } from "../lib/utils"; +import { requestNewThreadComposerFocus } from "../newThreadComposerFocus"; import { applyNewThreadDefaultsToDraft, placeDraftFromSettings, @@ -68,6 +69,10 @@ function useNewThreadState() { * from one in the same project can continue in its checkout. */ continueActiveCheckout?: boolean; replace?: boolean; + /** False leaves the keyboard where it is, for a draft the app opens + * by itself (the launch landing). By default the new thread's + * composer takes focus: whoever asked for it is about to type. */ + focusComposer?: boolean; /** Words the draft opens with, for surfaces that hand work over. Only * ever written into a draft with nothing of the user's in it. A * trailing space is kept, for a sentence the user finishes. */ @@ -93,6 +98,11 @@ function useNewThreadState() { setPrompt(draftId, initialPrompt); } }; + const focusComposer = (draftId: DraftId) => { + if (options?.focusComposer !== false) { + requestNewThreadComposerFocus(draftId); + } + }; const currentRouteTarget = getCurrentRouteTarget(); // Read projects at call time: a project created moments ago reaches the // store before this callback is recreated, and a stale list here means @@ -186,6 +196,7 @@ function useNewThreadState() { ); placeFromSettings(emptyStoredDraftThread.draftId); writeInitialPrompt(emptyStoredDraftThread.draftId); + focusComposer(emptyStoredDraftThread.draftId); // A reused draft can hold the model and agents it was set up with // long ago; "new thread" means today's, so the defaults are applied // again, after the draft is on its project (moving it drops a room). @@ -229,6 +240,7 @@ function useNewThreadState() { }); placeFromSettings(currentDraftId); writeInitialPrompt(currentDraftId); + focusComposer(currentDraftId); return applyNewThreadDefaultsToDraft(currentDraftId, projectRef.environmentId); } @@ -247,6 +259,7 @@ function useNewThreadState() { }); placeFromSettings(draftId); writeInitialPrompt(draftId); + focusComposer(draftId); const defaultsApplied = applyNewThreadDefaultsToDraft(draftId, projectRef.environmentId); await router.navigate({ diff --git a/apps/web/src/newThreadComposerFocus.ts b/apps/web/src/newThreadComposerFocus.ts new file mode 100644 index 000000000..a2bf6441a --- /dev/null +++ b/apps/web/src/newThreadComposerFocus.ts @@ -0,0 +1,55 @@ +import { useEffect, useEffectEvent } from "react"; +import { create } from "zustand"; + +import type { DraftId } from "./composerDraftStore"; + +/** + * Focus for the composer of a new thread someone asked for. + * + * A pristine draft opens without taking focus (ChatView), so the launch + * landing and a trip back to an empty draft leave the keyboard alone. A new + * thread the user asked for (the shortcut, a New thread button, the command + * palette) is different: they are about to type. `useNewThreadHandler` asks + * here, naming the draft, and the ChatView showing that draft answers once, + * whether it was already on screen or mounts after the navigation. + */ +interface NewThreadComposerFocusState { + readonly request: { readonly draftId: DraftId; readonly token: number } | null; +} + +const useNewThreadComposerFocusStore = create()(() => ({ + request: null, +})); + +/** Asks the composer of `draftId` to take focus when its ChatView is on screen. */ +export function requestNewThreadComposerFocus(draftId: DraftId): void { + useNewThreadComposerFocusStore.setState((state) => ({ + request: { draftId, token: (state.request?.token ?? 0) + 1 }, + })); +} + +/** + * Runs `focus` once for each request naming `draftId`. `enabled: false` + * (touch devices, where focus opens the keyboard) drops the request instead. + */ +export function useNewThreadComposerFocus(input: { + readonly draftId: DraftId | null; + readonly enabled: boolean; + readonly focus: () => void; +}): void { + const { draftId, enabled } = input; + const token = useNewThreadComposerFocusStore((state) => + draftId !== null && state.request?.draftId === draftId ? state.request.token : 0, + ); + const answer = useEffectEvent(() => { + // Taken before focusing: the request is answered once, never again on a + // later visit to the same draft. + useNewThreadComposerFocusStore.setState((state) => + state.request?.token === token ? { request: null } : state, + ); + if (enabled) input.focus(); + }); + useEffect(() => { + if (token !== 0) answer(); + }, [token]); +} diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index e9c1501d4..6cd9dca0d 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -135,7 +135,8 @@ function DefaultProjectDraftRedirect() { return; } if (defaultProjectRef) { - void handleNewThread(defaultProjectRef, { replace: true }); + // The app opened this draft, not the user: it leaves the keyboard alone. + void handleNewThread(defaultProjectRef, { replace: true, focusComposer: false }); } });