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