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 }); } });