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