From fbb93b73851462dd3d6967a1392d5bc0bb47d041 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:15:23 +0000 Subject: [PATCH] fix(native-chat): send typing after a click to the chat's composer (#28728) * fix(native-chat): send typing after a click to the chat's composer The default Claude/Codex chat never wired the root typing redirect, so a click in the transcript, on a toggle or link, on the chat's own tab, or on blank app chrome left typed keys with nowhere to go. The chat-view-over-TUI had the redirect but treated every button and link as a key owner. Both views now share one hook that runs the reveal focus, focuses the chat on blank clicks, routes keys typed inside the chat (but not in overlays it portals out), and, while it is the only chat visible in the focused group, routes keys typed with focus on the page body. Buttons and links pass letters but keep Space and Enter. Re-activating an already visible chat tab re-runs the composer claim, as a terminal tab refocuses xterm. * fix(native-chat): refocus the composer when its active tab is activated again Re-activating an already visible chat tab re-ran the ordinary reveal claim, which stops as soon as focus is anywhere inside the pane. With the transcript or a code-block Copy button focused, the tab shortcut left focus there and Space kept scrolling or pressing the button. The activation now moves focus to the composer from inside the pane too, unless a text field such as the find input holds it; an ordinary reveal still keeps the user's focus. --- .../native-chat/NativeChatResolvedView.tsx | 14 +- .../NativeChatStructuredSession.test.tsx | 21 +++ .../NativeChatStructuredSession.tsx | 12 +- ...native-chat-composer-reveal-focus.test.tsx | 97 +++++++++++- .../native-chat-root-key-routing.ts | 9 +- .../native-chat-typing-redirect.test.ts | 62 +++++++- .../native-chat-typing-redirect.ts | 38 +++-- .../use-native-chat-composer-reveal-focus.ts | 48 +++++- ...se-native-chat-root-input-routing.test.tsx | 147 ++++++++++++++++++ .../use-native-chat-root-input-routing.ts | 78 ++++++++++ .../lib/focus-terminal-tab-surface.test.ts | 11 +- .../src/lib/focus-terminal-tab-surface.ts | 5 + ...tured-agent-session-tab-activation.test.ts | 8 +- ...structured-agent-session-tab-activation.ts | 2 + 14 files changed, 502 insertions(+), 50 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-native-chat-root-input-routing.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-root-input-routing.ts diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index cbdf7751baf..ffceaa14959 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -2,7 +2,6 @@ import { cn } from '@/lib/utils' import { NATIVE_CHAT_APPEARANCE_ROOT_CLASS } from './native-chat-appearance-style' import { useNativeChatStoreAppearanceStyle } from './use-native-chat-store-appearance-style' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useNativeChatComposerRevealFocus } from './use-native-chat-composer-reveal-focus' import { useAppStore } from '../../store' import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption' import { useNativeChatRetainedSession } from './use-native-chat-retained-session' @@ -45,8 +44,7 @@ import { deriveNativeChatStreamingText, nativeChatStreamingMessage } from '../../../../shared/native-chat-streaming' -import { shouldFocusNativeChatPaneFromPointerTarget } from './native-chat-typing-redirect' -import { routeNativeChatRootKeyToInput } from './native-chat-root-key-routing' +import { useNativeChatRootInputRouting } from './use-native-chat-root-input-routing' import { emptyNativeChatContextMenuActions, useNativeChatContextMenu @@ -237,9 +235,11 @@ export function NativeChatResolvedView({ }) const { card: shownPromptCard, collapsedCard } = promptCardPresentation const mountedPromptCard = shownPromptCard ?? collapsedCard - useNativeChatComposerRevealFocus({ + const rootInput = useNativeChatRootInputRouting({ + tabId: terminalTabId, rootRef, composerRef, + questionAnswerInputRef, isVisible, isFocusedGroup, composerReady: shownPromptCard === null && targetPtyId !== null && canSend @@ -347,9 +347,7 @@ export function NativeChatResolvedView({ event.stopPropagation() return } - if (event.button === 0 && shouldFocusNativeChatPaneFromPointerTarget(event.target)) { - rootRef.current?.focus({ preventScroll: true }) - } + rootInput.onPointerDownCapture(event) }} onKeyDownCapture={(event) => { find.onKeyDownCapture(event) @@ -366,7 +364,7 @@ export function NativeChatResolvedView({ } return } - routeNativeChatRootKeyToInput(event, composerRef.current, questionAnswerInputRef.current) + rootInput.onKeyDownCapture(event) }} onContextMenuCapture={contextMenu.onContextMenuCapture} className={cn( diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index 0f8150db254..6263afeb4fb 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -247,6 +247,27 @@ describe('NativeChatStructuredSession', () => { act(() => useAppStore.getState().clearNativeChatLaunchDraft(draft.tabId)) }) + it('focuses the chat on a click outside its controls and sends typing there to the composer', () => { + const { container } = render( + + ) + const root = container.querySelector('[data-native-chat-root]') + if (!(root instanceof HTMLElement)) { + throw new Error('chat root missing') + } + fireEvent.pointerDown(root, { button: 0 }) + expect(document.activeElement).toBe(root) + + expect(fireEvent.keyDown(root, { key: 'h' })).toBe(false) + }) + it('wires remote structured file links through the host-aware native chat opener', () => { render( { + paneCommands.onPointerDownCapture(event) + rootInput.onPointerDownCapture(event) + }} onKeyDownCapture={(event) => { find.onKeyDownCapture(event) paneCommands.onKeyDownCapture(event) + rootInput.onKeyDownCapture(event) }} onContextMenuCapture={paneCommands.onContextMenuCapture} className={cn( diff --git a/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx index 466082fe0c7..daab2c0bf50 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx @@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { act, createElement, useRef } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { useNativeChatComposerRevealFocus } from './use-native-chat-composer-reveal-focus' +import { + requestNativeChatComposerFocus, + useNativeChatComposerRevealFocus +} from './use-native-chat-composer-reveal-focus' import type { NativeChatComposerHandle } from './NativeChatComposer' type HarnessProps = { @@ -58,6 +61,7 @@ function Harness(props: HarnessProps): React.JSX.Element { contains: () => false } useNativeChatComposerRevealFocus({ + tabId: 'chat-tab', rootRef, composerRef, isVisible: props.isVisible, @@ -71,7 +75,8 @@ function Harness(props: HarnessProps): React.JSX.Element { 'div', { ref: rootRef }, createElement('textarea', { ref: fieldRef, 'data-testid': 'composer' }), - createElement('button', { type: 'button', 'data-testid': 'in-pane-button' }) + createElement('button', { type: 'button', 'data-testid': 'in-pane-button' }), + createElement('input', { 'data-testid': 'find-input' }) ) } @@ -98,6 +103,86 @@ afterEach(() => { }) describe('useNativeChatComposerRevealFocus', () => { + it('claims the composer again when its revealed tab is activated', () => { + render({ isVisible: true, isFocusedGroup: true }) + drainFrames() + const tab = document.createElement('div') + tab.tabIndex = 0 + document.body.append(tab) + tab.focus() + + act(() => requestNativeChatComposerFocus('chat-tab')) + drainFrames() + + expect(container.querySelector('textarea')).toBe(document.activeElement) + }) + + it('takes focus from a control inside the pane when its tab is activated', () => { + render({ isVisible: true, isFocusedGroup: true }) + drainFrames() + const button = container.querySelector('button') + act(() => { + button?.focus() + }) + + act(() => requestNativeChatComposerFocus('chat-tab')) + drainFrames() + + expect(container.querySelector('textarea')).toBe(document.activeElement) + }) + + it('leaves the find input alone when its tab is activated', () => { + render({ isVisible: true, isFocusedGroup: true }) + drainFrames() + const findInput = container.querySelector('input') + act(() => { + findInput?.focus() + }) + const before = focusCalls + + act(() => requestNativeChatComposerFocus('chat-tab')) + drainFrames() + + expect(focusCalls).toBe(before) + expect(document.activeElement).toBe(findInput) + }) + + it('does not carry an unfinished tab activation into a later reveal', () => { + render({ isVisible: true, isFocusedGroup: true }) + drainFrames() + act(() => requestNativeChatComposerFocus('chat-tab')) + render({ isVisible: false, isFocusedGroup: true }) + const button = container.querySelector('button') + act(() => { + button?.focus() + }) + const before = focusCalls + + render({ isVisible: true, isFocusedGroup: true }) + drainFrames() + + expect(focusCalls).toBe(before) + expect(document.activeElement).toBe(button) + }) + + it('ignores a tab activation while the chat is not revealed or for another tab', () => { + render({ isVisible: true, isFocusedGroup: false }) + drainFrames() + act(() => requestNativeChatComposerFocus('chat-tab')) + drainFrames() + expect(focusCalls).toBe(0) + + render({ isVisible: true, isFocusedGroup: true }) + drainFrames() + const before = focusCalls + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } + act(() => requestNativeChatComposerFocus('other-tab')) + drainFrames() + expect(focusCalls).toBe(before) + }) + it('focuses when it mounts already revealed', () => { render({ isVisible: true, isFocusedGroup: true }) expect(focusCalls).toBe(0) @@ -228,7 +313,9 @@ describe('useNativeChatComposerRevealFocus', () => { render({ isVisible: false, isFocusedGroup: true }) drainFrames() act(() => { - ;(document.activeElement as HTMLElement | null)?.blur() + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } }) render({ isVisible: true, isFocusedGroup: true }) drainFrames() @@ -265,7 +352,9 @@ describe('useNativeChatComposerRevealFocus', () => { render({ isVisible: true, isFocusedGroup: true, composerReady: false }) drainFrames() act(() => { - ;(document.activeElement as HTMLElement | null)?.blur() + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } }) render({ isVisible: true, isFocusedGroup: true, composerReady: true }) drainFrames() diff --git a/src/renderer/src/components/native-chat/native-chat-root-key-routing.ts b/src/renderer/src/components/native-chat/native-chat-root-key-routing.ts index 532d08ab576..129762e1ca4 100644 --- a/src/renderer/src/components/native-chat/native-chat-root-key-routing.ts +++ b/src/renderer/src/components/native-chat/native-chat-root-key-routing.ts @@ -18,11 +18,11 @@ export function routeNativeChatRootKeyToInput( composer: NativeChatComposerHandle | null, questionAnswerInput: HTMLInputElement | null ): void { - // The focused transcript owns Space paging; typing elsewhere still reaches the composer. + // A focused transcript or code box owns Space paging; typing elsewhere still reaches the composer. if ( event.key === ' ' && event.target instanceof HTMLElement && - event.target.matches('[data-native-chat-scroll]') && + event.target.matches('[data-native-chat-scroll], [data-native-chat-code-content]') && event.target.ownerDocument.activeElement === event.target ) { return @@ -35,12 +35,13 @@ export function routeNativeChatRootKeyToInput( } // The question card replaces the composer, so at most one input exists. If it // cannot take focus (disabled), the paste reaches the bridge, which explains why. - if (shouldFocusNativeChatInputForPaste(event, getShortcutPlatform() === 'darwin')) { + const isMac = getShortcutPlatform() === 'darwin' + if (shouldFocusNativeChatInputForPaste(event, isMac)) { const input = composer ?? questionAnswerInput input?.focus() return } - if (!shouldRedirectNativeChatTyping(event)) { + if (!shouldRedirectNativeChatTyping(event, isMac)) { return } if (!composer?.insertTypedText(event.key)) { diff --git a/src/renderer/src/components/native-chat/native-chat-typing-redirect.test.ts b/src/renderer/src/components/native-chat/native-chat-typing-redirect.test.ts index b2e7f80b726..b357a40ef70 100644 --- a/src/renderer/src/components/native-chat/native-chat-typing-redirect.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-typing-redirect.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom import { describe, expect, it } from 'vitest' import { shouldFocusNativeChatComposerFromEditingKey, @@ -29,22 +30,69 @@ function interactiveTarget(): EventTarget { describe('shouldRedirectNativeChatTyping', () => { it('redirects printable typing from the native chat pane', () => { - expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'x' }))).toBe(true) + expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'x' }), true)).toBe(true) }) it('does not redirect shortcuts or non-printable keys', () => { - expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'Enter' }))).toBe(false) - expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'a', metaKey: true }))).toBe(false) - expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'a', ctrlKey: true }))).toBe(false) + expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'Enter' }), true)).toBe(false) + expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'a', metaKey: true }), true)).toBe(false) + expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'a', ctrlKey: true }), true)).toBe(false) }) it('does not redirect IME composition or already-handled events', () => { - expect(shouldRedirectNativeChatTyping(keyEvent({ isComposing: true }))).toBe(false) - expect(shouldRedirectNativeChatTyping(keyEvent({ defaultPrevented: true }))).toBe(false) + expect(shouldRedirectNativeChatTyping(keyEvent({ isComposing: true }), true)).toBe(false) + expect(shouldRedirectNativeChatTyping(keyEvent({ defaultPrevented: true }), true)).toBe(false) }) it('leaves interactive targets alone', () => { - expect(shouldRedirectNativeChatTyping(keyEvent({ target: interactiveTarget() }))).toBe(false) + expect(shouldRedirectNativeChatTyping(keyEvent({ target: interactiveTarget() }), true)).toBe( + false + ) + }) + + it('passes typed characters but not Space through a focused button or link', () => { + const button = document.createElement('button') + const link = document.createElement('a') + link.href = 'https://example.com' + const roleButton = document.createElement('div') + roleButton.setAttribute('role', 'button') + for (const target of [button, link, roleButton]) { + expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'h', target }), true)).toBe(true) + expect(shouldRedirectNativeChatTyping(keyEvent({ key: ' ', target }), true)).toBe(false) + } + }) + + it('lets Backspace/Delete and paste pass a focused button but not a text field', () => { + const button = document.createElement('button') + const field = document.createElement('input') + expect( + shouldFocusNativeChatComposerFromEditingKey(keyEvent({ key: 'Backspace', target: button })) + ).toBe(true) + expect( + shouldFocusNativeChatInputForPaste( + keyEvent({ key: 'v', metaKey: true, target: button }), + true + ) + ).toBe(true) + expect( + shouldFocusNativeChatComposerFromEditingKey(keyEvent({ key: 'Backspace', target: field })) + ).toBe(false) + expect(shouldFocusNativeChatPaneFromPointerTarget(button)).toBe(false) + }) + + it('types Option characters on macOS but leaves Alt chords elsewhere', () => { + expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'å', altKey: true }), true)).toBe(true) + expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'z', altKey: true }), false)).toBe(false) + }) + + it('leaves typing with controls that own letters', () => { + const option = document.createElement('div') + option.setAttribute('role', 'option') + const optionInButton = document.createElement('button') + optionInButton.setAttribute('role', 'menuitem') + for (const target of [document.createElement('input'), option, optionInButton]) { + expect(shouldRedirectNativeChatTyping(keyEvent({ key: 'h', target }), true)).toBe(false) + } }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-typing-redirect.ts b/src/renderer/src/components/native-chat/native-chat-typing-redirect.ts index 2a47c1669a7..61f9417043f 100644 --- a/src/renderer/src/components/native-chat/native-chat-typing-redirect.ts +++ b/src/renderer/src/components/native-chat/native-chat-typing-redirect.ts @@ -9,14 +9,14 @@ export type KeyboardRedirectEvent = { target: EventTarget | null } -const INTERACTIVE_TARGET_SELECTOR = [ - 'a[href]', - 'button', +// Plain pressables use only Space and Enter, so other keys may pass them to the chat's input. +const PRESSABLE_TARGET_SELECTOR = ['a[href]', 'button', '[role="button"]'].join(',') + +const KEY_OWNER_TARGET_SELECTOR = [ 'input', 'select', 'textarea', '[contenteditable]:not([contenteditable="false"])', - '[role="button"]', '[role="checkbox"]', '[role="combobox"]', '[role="menuitem"]', @@ -28,21 +28,33 @@ const INTERACTIVE_TARGET_SELECTOR = [ '[data-native-chat-typing-redirect-ignore="true"]' ].join(',') -export function shouldRedirectNativeChatTyping(event: KeyboardRedirectEvent): boolean { +export function shouldRedirectNativeChatTyping( + event: KeyboardRedirectEvent, + isMac: boolean +): boolean { if ( event.defaultPrevented || event.isComposing || event.ctrlKey || event.metaKey || + // Option types characters on macOS; elsewhere Alt+key is a shortcut. + (event.altKey === true && !isMac) || event.key.length !== 1 ) { return false } - return !isNativeChatInteractiveTarget(event.target) + if (targetMatches(event.target, KEY_OWNER_TARGET_SELECTOR)) { + return false + } + // Space presses a focused button or link. + return event.key !== ' ' || !targetMatches(event.target, PRESSABLE_TARGET_SELECTOR) } export function shouldFocusNativeChatPaneFromPointerTarget(target: EventTarget | null): boolean { - return !isNativeChatInteractiveTarget(target) + return ( + !targetMatches(target, PRESSABLE_TARGET_SELECTOR) && + !targetMatches(target, KEY_OWNER_TARGET_SELECTOR) + ) } /** Unmodified Backspace/Delete pressed outside any input should focus the @@ -62,7 +74,7 @@ export function shouldFocusNativeChatComposerFromEditingKey(event: KeyboardRedir ) { return false } - return !isNativeChatInteractiveTarget(event.target) + return !targetMatches(event.target, KEY_OWNER_TARGET_SELECTOR) } /** The platform paste chord pressed outside any input: the caller focuses an @@ -78,15 +90,11 @@ export function shouldFocusNativeChatInputForPaste( ? event.metaKey && !event.ctrlKey && event.key.toLowerCase() === 'v' : (event.ctrlKey && !event.metaKey && event.key.toLowerCase() === 'v') || (event.shiftKey === true && !event.ctrlKey && !event.metaKey && event.key === 'Insert') - return chord && !isNativeChatInteractiveTarget(event.target) + return chord && !targetMatches(event.target, KEY_OWNER_TARGET_SELECTOR) } -function isNativeChatInteractiveTarget(target: EventTarget | null): boolean { - const element = eventTargetElement(target) - if (!element) { - return false - } - return element.closest(INTERACTIVE_TARGET_SELECTOR) !== null +function targetMatches(target: EventTarget | null, selector: string): boolean { + return eventTargetElement(target)?.closest(selector) != null } function eventTargetElement(target: EventTarget | null): Element | null { diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts index 7bc41652b0b..054d32d743e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import type { RefObject } from 'react' import { shouldPreserveEditableFocus } from '@/components/terminal-pane/pane-helpers' import { scheduleNextFrame } from '@/components/terminal-pane/terminal-ime-input-context-refresh' @@ -7,7 +7,16 @@ import type { NativeChatComposerHandle } from './NativeChatComposer' /** Focus attempts per reveal, including recovery from delayed programmatic restoration. */ const REVEAL_FOCUS_FRAMES = 6 -type NativeChatComposerRevealFocusArgs = { +const revealedComposerReclaims = new Map void>() + +/** Re-clicking a chat's active tab claims its composer again, as a terminal tab refocuses xterm. */ +export function requestNativeChatComposerFocus(tabId: string): void { + revealedComposerReclaims.get(tabId)?.() +} + +export type NativeChatComposerRevealFocusArgs = { + /** The tab whose activation re-runs the claim (the terminal tab for a chat view over a TUI). */ + tabId: string rootRef: RefObject composerRef: RefObject isVisible: boolean @@ -25,6 +34,7 @@ type NativeChatComposerRevealFocusArgs = { * the reveal edge, not mount, is the signal. */ export function useNativeChatComposerRevealFocus({ + tabId, rootRef, composerRef, isVisible, @@ -34,10 +44,31 @@ export function useNativeChatComposerRevealFocus({ }: NativeChatComposerRevealFocusArgs): void { const revealed = isVisible && isFocusedGroup const claimedRef = useRef(false) + // An explicit tab activation takes focus from inside the pane too, short of a text field. + const explicitRef = useRef(false) + const [reclaimCount, setReclaimCount] = useState(0) + + useEffect(() => { + if (!revealed || !composerReady) { + return + } + const reclaim = (): void => { + claimedRef.current = false + explicitRef.current = true + setReclaimCount((count) => count + 1) + } + revealedComposerReclaims.set(tabId, reclaim) + return () => { + if (revealedComposerReclaims.get(tabId) === reclaim) { + revealedComposerReclaims.delete(tabId) + } + } + }, [composerReady, revealed, tabId]) useEffect(() => { if (!revealed || !composerReady) { claimedRef.current = false + explicitRef.current = false return } if (claimedRef.current) { @@ -61,6 +92,7 @@ export function useNativeChatComposerRevealFocus({ } const finish = (): void => { claimedRef.current = true + explicitRef.current = false stopWatchingUserIntent() } ownerDocument?.addEventListener('pointerdown', cancelForPointer, true) @@ -76,12 +108,16 @@ export function useNativeChatComposerRevealFocus({ const active = rootRef.current?.ownerDocument.activeElement ?? null // Focus already inside this pane is our own take landing or the user's click; either ends it. // A just-collapsed card is hidden and inert, so focus left in it is about to drop to body. - if (rootRef.current?.contains(active) === true && !active?.closest('[hidden], [inert]')) { + if ( + !explicitRef.current && + rootRef.current?.contains(active) === true && + !active?.closest('[hidden], [inert]') + ) { finish() return } - // Why: a live text field elsewhere is a user edit — a batch worktree-create keeps its - // next name field open behind the pane we just revealed. + // Why: a live text field is a user edit — a batch worktree-create keeps its next name + // field open behind the pane we just revealed, and the find bar keeps its query. if (shouldPreserveEditableFocus(active)) { finish() return @@ -101,5 +137,5 @@ export function useNativeChatComposerRevealFocus({ cancelled = true stopWatchingUserIntent() } - }, [composerReady, composerRef, revealed, rootRef, scheduleFrame]) + }, [composerReady, composerRef, reclaimCount, revealed, rootRef, scheduleFrame]) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-root-input-routing.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-root-input-routing.test.tsx new file mode 100644 index 00000000000..f85c5df20de --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-root-input-routing.test.tsx @@ -0,0 +1,147 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render } from '@testing-library/react' +import { useRef } from 'react' +import { createPortal } from 'react-dom' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatComposerHandle } from './native-chat-composer-types' +import { useNativeChatRootInputRouting } from './use-native-chat-root-input-routing' + +const overlay = vi.hoisted(() => ({ visible: false })) +vi.mock('@/lib/visible-overlay', () => ({ hasVisibleOverlay: () => overlay.visible })) + +const insertTypedText = vi.fn(() => true) +const composer: NativeChatComposerHandle = { + focus: () => true, + insertTypedText, + appendText: () => {}, + acceptsText: () => true, + handlePasteEvent: () => {}, + pasteFromClipboard: () => {}, + contains: () => false +} + +function Chat({ revealed }: { revealed: boolean }): React.JSX.Element { + const rootRef = useRef(null) + const composerRef = useRef(composer) + const answerRef = useRef(null) + const routing = useNativeChatRootInputRouting({ + tabId: 'chat-tab', + rootRef, + composerRef, + questionAnswerInputRef: answerRef, + isVisible: true, + isFocusedGroup: revealed, + composerReady: true + }) + return ( +
+
Reply text
+ + example.com +
+ Option +
+
+ {createPortal( +
+ +
, + document.body + )} +
+ ) +} + +afterEach(() => { + cleanup() + overlay.visible = false + insertTypedText.mockClear() + document.body.replaceChildren() +}) + +describe('useNativeChatRootInputRouting', () => { + it('types into the composer from the transcript and from a focused button or link', () => { + const view = render() + fireEvent.keyDown(view.getByTestId('transcript'), { key: 'h' }) + fireEvent.keyDown(view.getByRole('button', { name: 'Copy code' }), { key: 'i' }) + fireEvent.keyDown(view.getByRole('link', { name: 'example.com' }), { key: '!' }) + + expect(insertTypedText.mock.calls).toEqual([['h'], ['i'], ['!']]) + }) + + it('leaves Space with a focused button or link and letters with a composite control', () => { + const view = render() + expect(fireEvent.keyDown(view.getByRole('button', { name: 'Copy code' }), { key: ' ' })).toBe( + true + ) + expect(fireEvent.keyDown(view.getByRole('link', { name: 'example.com' }), { key: ' ' })).toBe( + true + ) + expect(fireEvent.keyDown(view.getByRole('option'), { key: 'o' })).toBe(true) + const code = view.getByTestId('code') + code.focus() + expect(fireEvent.keyDown(code, { key: ' ' })).toBe(true) + + expect(insertTypedText).not.toHaveBeenCalled() + }) + + it('leaves keys in a dialog the chat portals out with that dialog', () => { + const view = render() + fireEvent.keyDown(view.getByRole('button', { name: 'Close preview' }), { key: 'a' }) + + expect(insertTypedText).not.toHaveBeenCalled() + }) + + it('types into the composer when nothing has focus and the chat is revealed', () => { + render() + expect(fireEvent.keyDown(document.body, { key: 'q' })).toBe(false) + + expect(insertTypedText).toHaveBeenCalledWith('q') + }) + + it('leaves keys typed with nothing focused alone while hidden, under an overlay, or after unmount', () => { + const view = render() + fireEvent.keyDown(document.body, { key: 'a' }) + + view.rerender() + overlay.visible = true + fireEvent.keyDown(document.body, { key: 'b' }) + overlay.visible = false + + view.unmount() + fireEvent.keyDown(document.body, { key: 'c' }) + + expect(insertTypedText).not.toHaveBeenCalled() + }) + + it('leaves keys typed with nothing focused alone while another chat is also revealed', () => { + render( + <> + + + + ) + fireEvent.keyDown(document.body, { key: 'a' }) + + expect(insertTypedText).not.toHaveBeenCalled() + }) + + it('leaves keys aimed at controls outside the chat alone', () => { + render() + const field = document.createElement('input') + const list = document.createElement('div') + list.setAttribute('role', 'listbox') + list.tabIndex = 0 + document.body.append(field, list) + + fireEvent.keyDown(field, { key: 'a' }) + fireEvent.keyDown(list, { key: 'b' }) + + expect(insertTypedText).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-root-input-routing.ts b/src/renderer/src/components/native-chat/use-native-chat-root-input-routing.ts new file mode 100644 index 00000000000..cd88ce5da42 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-root-input-routing.ts @@ -0,0 +1,78 @@ +import { + useCallback, + useEffect, + type KeyboardEvent as ReactKeyboardEvent, + type PointerEvent as ReactPointerEvent, + type RefObject +} from 'react' +import { hasVisibleOverlay } from '@/lib/visible-overlay' +import { routeNativeChatRootKeyToInput } from './native-chat-root-key-routing' +import { shouldFocusNativeChatPaneFromPointerTarget } from './native-chat-typing-redirect' +import { + useNativeChatComposerRevealFocus, + type NativeChatComposerRevealFocusArgs +} from './use-native-chat-composer-reveal-focus' + +// Revealed chat roots in this window: a floating panel can reveal one beside the workspace's. +const revealedChatRoots = new Set() + +/** Brings focus to the chat's input when it is revealed, and sends keys typed in the chat, or + * with nothing focused around it, to that input. */ +export function useNativeChatRootInputRouting( + args: Omit & { + questionAnswerInputRef: RefObject + } +): { + onPointerDownCapture: (event: ReactPointerEvent) => void + onKeyDownCapture: (event: ReactKeyboardEvent) => void +} { + const { rootRef, composerRef, questionAnswerInputRef } = args + useNativeChatComposerRevealFocus(args) + // The one chat visible in the focused group takes keys typed with nothing focused. + const revealed = args.isVisible && args.isFocusedGroup + useEffect(() => { + const root = rootRef.current + if (!revealed || !root) { + return + } + const onKeyDown = (event: KeyboardEvent): void => { + // Focus on or the pane's own cover (a click on blank chrome) has no key owner; + // with two chats revealed it has no clear target either. + if ( + !(event.target instanceof Node) || + event.target === root || + !event.target.contains(root) || + revealedChatRoots.size !== 1 || + hasVisibleOverlay({ ignoreDismissed: true }) + ) { + return + } + routeNativeChatRootKeyToInput(event, composerRef.current, questionAnswerInputRef.current) + } + revealedChatRoots.add(root) + root.ownerDocument.addEventListener('keydown', onKeyDown) + return () => { + revealedChatRoots.delete(root) + root.ownerDocument.removeEventListener('keydown', onKeyDown) + } + }, [composerRef, questionAnswerInputRef, revealed, rootRef]) + + const onPointerDownCapture = useCallback( + (event: ReactPointerEvent) => { + if (event.button === 0 && shouldFocusNativeChatPaneFromPointerTarget(event.target)) { + rootRef.current?.focus({ preventScroll: true }) + } + }, + [rootRef] + ) + const onKeyDownCapture = useCallback( + (event: ReactKeyboardEvent) => { + // Menus and dialogs the chat portals out still bubble through its React tree. + if (event.target instanceof Node && rootRef.current?.contains(event.target)) { + routeNativeChatRootKeyToInput(event, composerRef.current, questionAnswerInputRef.current) + } + }, + [composerRef, questionAnswerInputRef, rootRef] + ) + return { onPointerDownCapture, onKeyDownCapture } +} diff --git a/src/renderer/src/lib/focus-terminal-tab-surface.test.ts b/src/renderer/src/lib/focus-terminal-tab-surface.test.ts index da28ad74eb3..a019e583ac5 100644 --- a/src/renderer/src/lib/focus-terminal-tab-surface.test.ts +++ b/src/renderer/src/lib/focus-terminal-tab-surface.test.ts @@ -2,7 +2,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { focusTerminalTabSurface } from './focus-terminal-tab-surface' const mocks = vi.hoisted(() => ({ - refreshTerminalImeInputContext: vi.fn() + refreshTerminalImeInputContext: vi.fn(), + requestNativeChatComposerFocus: vi.fn() +})) + +vi.mock('@/components/native-chat/use-native-chat-composer-reveal-focus', () => ({ + requestNativeChatComposerFocus: mocks.requestNativeChatComposerFocus })) vi.mock('@/components/terminal-pane/terminal-ime-input-context-refresh', () => ({ @@ -18,6 +23,7 @@ const GLOBAL_HELPER_SELECTOR = describe('focusTerminalTabSurface', () => { afterEach(() => { mocks.refreshTerminalImeInputContext.mockClear() + mocks.requestNativeChatComposerFocus.mockClear() vi.unstubAllGlobals() }) @@ -104,7 +110,7 @@ describe('focusTerminalTabSurface', () => { expect(textarea.focus).not.toHaveBeenCalled() }) - it('does not focus xterm while chat covers the terminal tab', () => { + it('hands the focus to the chat composer instead of xterm while chat covers the terminal tab', () => { flushAnimationFrames() const textarea = { focus: vi.fn() } vi.stubGlobal('document', { @@ -121,6 +127,7 @@ describe('focusTerminalTabSurface', () => { focusTerminalTabSurface('tab-1') expect(textarea.focus).not.toHaveBeenCalled() + expect(mocks.requestNativeChatComposerFocus).toHaveBeenCalledWith('tab-1') }) it('skips the chat leaf helper when a split chat tab has an active terminal leaf', () => { diff --git a/src/renderer/src/lib/focus-terminal-tab-surface.ts b/src/renderer/src/lib/focus-terminal-tab-surface.ts index bf7dc3ed991..f0230a5e6d9 100644 --- a/src/renderer/src/lib/focus-terminal-tab-surface.ts +++ b/src/renderer/src/lib/focus-terminal-tab-surface.ts @@ -1,5 +1,6 @@ import { refreshTerminalImeInputContext } from '@/components/terminal-pane/terminal-ime-input-context-refresh' import { UNCOVERED_TERMINAL_LEAF_SELECTOR } from '@/components/terminal-pane/native-chat-covered-pane' +import { requestNativeChatComposerFocus } from '@/components/native-chat/use-native-chat-composer-reveal-focus' /** * Move keyboard focus into the xterm instance for a freshly-mounted terminal @@ -73,6 +74,10 @@ export function focusTerminalTabSurface( const escapedTabId = cssAttributeString(tabId) const tabElement = document.querySelector(`[data-terminal-tab-id="${escapedTabId}"]`) if (tabElement?.getAttribute('data-terminal-chat-view') === 'true') { + // The chat over this terminal takes the focus in its composer instead. + if (!options.onlyIfFocusUnclaimed || document.activeElement === document.body) { + requestNativeChatComposerFocus(tabId) + } return } // Why: a split chat tab keeps a covered xterm under the chat leaf; the diff --git a/src/renderer/src/lib/structured-agent-session-tab-activation.test.ts b/src/renderer/src/lib/structured-agent-session-tab-activation.test.ts index 866669438ae..75a71085a82 100644 --- a/src/renderer/src/lib/structured-agent-session-tab-activation.test.ts +++ b/src/renderer/src/lib/structured-agent-session-tab-activation.test.ts @@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({ callRuntimeRpc: vi.fn(async () => ({ ok: true })), focusGroup: vi.fn(), setActiveTabType: vi.fn(), + requestNativeChatComposerFocus: vi.fn(), state: { unifiedTabsByWorktree: {} } as Record })) @@ -13,6 +14,10 @@ vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } })) +vi.mock('@/components/native-chat/use-native-chat-composer-reveal-focus', () => ({ + requestNativeChatComposerFocus: mocks.requestNativeChatComposerFocus +})) + vi.mock('./worktree-runtime-owner', () => ({ getRuntimeEnvironmentIdForWorktree: () => 'env-1' })) vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) @@ -97,7 +102,7 @@ describe('activateStructuredAgentSessionTab', () => { } }) - it('selects the unified tab and synchronizes host focus', () => { + it('selects the unified tab, focuses its composer, and synchronizes host focus', () => { expect( activateStructuredAgentSessionTab({ worktreeId: 'wt-1', tabId: 'structured-tab-1' }) ).toBe(true) @@ -105,6 +110,7 @@ describe('activateStructuredAgentSessionTab', () => { expect(mocks.focusGroup).toHaveBeenCalledWith('wt-1', 'group-1') expect(mocks.activateTab).toHaveBeenCalledWith('structured-tab-1', { worktreeId: 'wt-1' }) expect(mocks.setActiveTabType).toHaveBeenCalledWith('agent-session', 'wt-1') + expect(mocks.requestNativeChatComposerFocus).toHaveBeenCalledWith('structured-tab-1') expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( { kind: 'environment', environmentId: 'env-1' }, 'session.tabs.activate', diff --git a/src/renderer/src/lib/structured-agent-session-tab-activation.ts b/src/renderer/src/lib/structured-agent-session-tab-activation.ts index 02854e825b5..090f6426c31 100644 --- a/src/renderer/src/lib/structured-agent-session-tab-activation.ts +++ b/src/renderer/src/lib/structured-agent-session-tab-activation.ts @@ -1,4 +1,5 @@ import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' +import { requestNativeChatComposerFocus } from '@/components/native-chat/use-native-chat-composer-reveal-focus' import { useAppStore } from '@/store' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' @@ -47,6 +48,7 @@ export function activateStructuredAgentSessionTab(args: { state.focusGroup(args.worktreeId, tab.groupId) state.activateTab(tab.id, { worktreeId: args.worktreeId }) state.setActiveTabType('agent-session', args.worktreeId) + requestNativeChatComposerFocus(tab.id) const environmentId = getRuntimeEnvironmentIdForWorktree(state, args.worktreeId) void callRuntimeRpc( args.target ?? runtimeTargetForOwnerEnvironment(environmentId),