Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/web/src/components/ChatView.drafts.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>('[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();
}
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/components/ChatView.palette.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>('[data-testid="composer-editor"]');
expect(editor).not.toBeNull();
expect(document.activeElement).toBe(editor);
});
} finally {
await mounted.cleanup();
}
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 ||
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/hooks/useHandleNewThread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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. */
Expand All @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -229,6 +240,7 @@ function useNewThreadState() {
});
placeFromSettings(currentDraftId);
writeInitialPrompt(currentDraftId);
focusComposer(currentDraftId);
return applyNewThreadDefaultsToDraft(currentDraftId, projectRef.environmentId);
}

Expand All @@ -247,6 +259,7 @@ function useNewThreadState() {
});
placeFromSettings(draftId);
writeInitialPrompt(draftId);
focusComposer(draftId);
const defaultsApplied = applyNewThreadDefaultsToDraft(draftId, projectRef.environmentId);

await router.navigate({
Expand Down
55 changes: 55 additions & 0 deletions apps/web/src/newThreadComposerFocus.ts
Original file line number Diff line number Diff line change
@@ -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<NewThreadComposerFocusState>()(() => ({
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]);
}
3 changes: 2 additions & 1 deletion apps/web/src/routes/_chat.index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});

Expand Down
Loading