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),