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.
This commit is contained in:
Kelvin Amoaba
2026-10-15 01:15:23 +00:00
committed by GitHub
parent c7cb5d154a
commit fbb93b7385
14 changed files with 502 additions and 50 deletions
@@ -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(
@@ -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(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-1"
sessionId="session-1"
target={{ kind: 'local' }}
agent="claude"
/>
)
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(
<NativeChatStructuredSession
@@ -16,7 +16,7 @@ import { useStructuredNativeChatSubmitReveal } from './use-structured-native-cha
import { useStructuredPromptResponseHold } from './use-structured-prompt-response-hold'
import { NativeChatStructuredQuestionCard } from './NativeChatStructuredQuestionCard'
import { selectNativeChatViewState, structuredChatHistoryPhase } from './native-chat-view-state'
import { useNativeChatComposerRevealFocus } from './use-native-chat-composer-reveal-focus'
import { useNativeChatRootInputRouting } from './use-native-chat-root-input-routing'
import { useNativeChatFontSize } from './use-native-chat-font-size'
import { useNativeChatFind } from './use-native-chat-find'
import { NativeChatFindBar } from './NativeChatFindBar'
@@ -200,9 +200,11 @@ export function NativeChatStructuredSession(
})
}
}
useNativeChatComposerRevealFocus({
const rootInput = useNativeChatRootInputRouting({
tabId: props.tabId,
rootRef,
composerRef,
questionAnswerInputRef,
isVisible: props.isVisible,
isFocusedGroup: props.isFocusedGroup,
composerReady: composerShown
@@ -258,10 +260,14 @@ export function NativeChatStructuredSession(
data-native-chat-root="true"
data-native-chat-working={controller.isWorking ? 'true' : 'false'}
tabIndex={-1}
onPointerDownCapture={paneCommands.onPointerDownCapture}
onPointerDownCapture={(event) => {
paneCommands.onPointerDownCapture(event)
rootInput.onPointerDownCapture(event)
}}
onKeyDownCapture={(event) => {
find.onKeyDownCapture(event)
paneCommands.onKeyDownCapture(event)
rootInput.onKeyDownCapture(event)
}}
onContextMenuCapture={paneCommands.onContextMenuCapture}
className={cn(
@@ -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()
@@ -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)) {
@@ -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)
}
})
})
@@ -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 {
@@ -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<string, () => 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<HTMLElement | null>
composerRef: RefObject<NativeChatComposerHandle | null>
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])
}
@@ -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<HTMLDivElement>(null)
const composerRef = useRef<NativeChatComposerHandle | null>(composer)
const answerRef = useRef<HTMLInputElement | null>(null)
const routing = useNativeChatRootInputRouting({
tabId: 'chat-tab',
rootRef,
composerRef,
questionAnswerInputRef: answerRef,
isVisible: true,
isFocusedGroup: revealed,
composerReady: true
})
return (
<div
ref={rootRef}
tabIndex={-1}
onKeyDownCapture={routing.onKeyDownCapture}
onPointerDownCapture={routing.onPointerDownCapture}
>
<div data-testid="transcript">Reply text</div>
<button type="button">Copy code</button>
<a href="https://example.com">example.com</a>
<div role="option" tabIndex={0} aria-selected="false">
Option
</div>
<div data-native-chat-code-content tabIndex={0} data-testid="code" />
{createPortal(
<div role="dialog">
<button type="button">Close preview</button>
</div>,
document.body
)}
</div>
)
}
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(<Chat revealed />)
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(<Chat revealed />)
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(<Chat revealed />)
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(<Chat revealed />)
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(<Chat revealed={false} />)
fireEvent.keyDown(document.body, { key: 'a' })
view.rerender(<Chat revealed />)
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(
<>
<Chat revealed />
<Chat revealed />
</>
)
fireEvent.keyDown(document.body, { key: 'a' })
expect(insertTypedText).not.toHaveBeenCalled()
})
it('leaves keys aimed at controls outside the chat alone', () => {
render(<Chat revealed />)
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()
})
})
@@ -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<HTMLElement>()
/** 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<NativeChatComposerRevealFocusArgs, 'scheduleFrame'> & {
questionAnswerInputRef: RefObject<HTMLInputElement | null>
}
): {
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 <body> 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 }
}
@@ -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', () => {
@@ -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
@@ -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<string, unknown>
}))
@@ -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',
@@ -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),