Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { act, createElement, useRef } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import {
captureNativeChatComposerFocusIntent,
requestNativeChatComposerFocus,
useNativeChatComposerRevealFocus
} from './use-native-chat-composer-reveal-focus'
import type { NativeChatComposerHandle } from './NativeChatComposer'

type HarnessProps = {
tabId?: string
isVisible: boolean
isFocusedGroup: boolean
composerReady?: boolean
Expand Down Expand Up @@ -58,10 +60,10 @@ function Harness(props: HarnessProps): React.JSX.Element {
acceptsText: () => true,
handlePasteEvent: () => {},
pasteFromClipboard: () => {},
contains: () => false
contains: (node) => fieldRef.current?.contains(node) === true
}
useNativeChatComposerRevealFocus({
tabId: 'chat-tab',
tabId: props.tabId ?? 'chat-tab',
rootRef,
composerRef,
isVisible: props.isVisible,
Expand All @@ -76,7 +78,8 @@ function Harness(props: HarnessProps): React.JSX.Element {
{ ref: rootRef },
createElement('textarea', { ref: fieldRef, 'data-testid': 'composer' }),
createElement('button', { type: 'button', 'data-testid': 'in-pane-button' }),
createElement('input', { 'data-testid': 'find-input' })
createElement('input', { 'data-testid': 'find-input' }),
createElement('input', { 'data-testid': 'question-input' })
)
}

Expand All @@ -95,6 +98,7 @@ beforeEach(() => {
})

afterEach(() => {
document.dispatchEvent(new Event('pointerdown'))
act(() => {
root.unmount()
})
Expand Down Expand Up @@ -165,22 +169,54 @@ describe('useNativeChatComposerRevealFocus', () => {
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 })
it('delivers a created tab request after its composer mounts and becomes ready', () => {
render({ tabId: 'source-tab', isVisible: true, isFocusedGroup: true })
drainFrames()
act(() => requestNativeChatComposerFocus('chat-tab'))
const sourceComposer = container.querySelector('textarea')
expect(document.activeElement).toBe(sourceComposer)

const intent = captureNativeChatComposerFocusIntent()
intent.request('chat-tab')
drainFrames()
expect(focusCalls).toBe(0)
expect(document.activeElement).toBe(sourceComposer)

render({ isVisible: true, isFocusedGroup: true })
const destination = document.createElement('div')
document.body.appendChild(destination)
const destinationRoot = createRoot(destination)
act(() => {
root.render(
createElement(Harness, { tabId: 'source-tab', isVisible: true, isFocusedGroup: false })
)
destinationRoot.render(
createElement(Harness, { isVisible: true, isFocusedGroup: false, composerReady: false })
)
})
drainFrames()
expect(document.activeElement).toBe(sourceComposer)

act(() => {
destinationRoot.render(
createElement(Harness, { isVisible: true, isFocusedGroup: true, composerReady: false })
)
})
drainFrames()
expect(document.activeElement).toBe(sourceComposer)
act(() => {
destinationRoot.render(
createElement(Harness, { isVisible: true, isFocusedGroup: true, composerReady: true })
)
})
drainFrames()
expect(document.activeElement).toBe(destination.querySelector('textarea'))
const before = focusCalls
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
act(() => requestNativeChatComposerFocus('other-tab'))
drainFrames()
expect(focusCalls).toBe(before)
act(() => destinationRoot.unmount())
destination.remove()
})

it('focuses when it mounts already revealed', () => {
Expand Down Expand Up @@ -209,16 +245,44 @@ describe('useNativeChatComposerRevealFocus', () => {
expect(focusCalls).toBe(1)
})

it('lets only the focused group claim when two panes are revealed together', () => {
it('hands focus from a sibling composer to the newly focused pane', () => {
const other = document.createElement('div')
other.setAttribute('data-retained-pane-host', '')
document.body.appendChild(other)
const otherRoot = createRoot(other)
act(() => {
root.render(createElement(Harness, { isVisible: true, isFocusedGroup: true }))
otherRoot.render(createElement(Harness, { isVisible: true, isFocusedGroup: false }))
otherRoot.render(
createElement(Harness, { tabId: 'other-tab', isVisible: true, isFocusedGroup: false })
)
})
other.querySelector('textarea')?.focus()
drainFrames()
expect(focusCalls).toBe(0)
expect(other.querySelector('textarea')).toBe(document.activeElement)

act(() => requestNativeChatComposerFocus('chat-tab'))
drainFrames()
expect(focusCalls).toBe(1)
expect(container.querySelector('textarea')).toBe(document.activeElement)

for (const field of ['find-input', 'question-input']) {
const sourceInput = other.querySelector<HTMLInputElement>(`[data-testid="${field}"]`)
sourceInput?.focus()
act(() => requestNativeChatComposerFocus('chat-tab'))
drainFrames()
expect(document.activeElement).toBe(sourceInput)
}
expect(focusCalls).toBe(1)

const sourceComposer = other.querySelector('textarea')
sourceComposer?.focus()
const delayedIntent = captureNativeChatComposerFocusIntent()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' }))
delayedIntent.request('chat-tab')
drainFrames()
expect(document.activeElement).toBe(sourceComposer)

act(() => {
otherRoot.unmount()
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,65 @@ import type { NativeChatComposerHandle } from './NativeChatComposer'

/** Focus attempts per reveal, including recovery from delayed programmatic restoration. */
const REVEAL_FOCUS_FRAMES = 6
const PENDING_FOCUS_MS = 1000

const revealedComposerReclaims = new Map<string, () => void>()
const mountedComposers = new Set<RefObject<NativeChatComposerHandle | null>>()
let pendingFocusRequest: { tabId: string | null; cancel: () => void } | null = null
let focusRequestGeneration = 0

export type NativeChatComposerFocusIntent = {
request: (tabId: string) => void
cancel: () => void
}

/** Keep a new-chat action's focus intent through destination claim, navigation, or expiry. */
export function captureNativeChatComposerFocusIntent(): NativeChatComposerFocusIntent {
focusRequestGeneration += 1
pendingFocusRequest?.cancel()
if (typeof document === 'undefined') {
return { request: () => undefined, cancel: () => undefined }
}
const cancel = (): void => {
if (pendingFocusRequest !== pending) {
return
}
pendingFocusRequest = null
clearTimeout(timeout)
document.removeEventListener('pointerdown', cancel, true)
document.removeEventListener('keydown', cancelForNavigation, true)
}
const cancelForNavigation = (event: KeyboardEvent): void => {
if (event.key === 'Tab') {
cancel()
}
}
const pending: { tabId: string | null; cancel: () => void } = { tabId: null, cancel }
let timeout: ReturnType<typeof setTimeout> | undefined
pendingFocusRequest = pending
document.addEventListener('pointerdown', cancel, true)
document.addEventListener('keydown', cancelForNavigation, true)
return {
request: (tabId) => {
if (pendingFocusRequest !== pending) {
return
}
pending.tabId = tabId
clearTimeout(timeout)
timeout = setTimeout(cancel, PENDING_FOCUS_MS)
const reclaim = revealedComposerReclaims.get(tabId)
if (reclaim) {
cancel()
reclaim()
}
},
cancel
}
}

/** 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)?.()
captureNativeChatComposerFocusIntent().request(tabId)
}

export type NativeChatComposerRevealFocusArgs = {
Expand Down Expand Up @@ -48,6 +101,13 @@ export function useNativeChatComposerRevealFocus({
const explicitRef = useRef(false)
const [reclaimCount, setReclaimCount] = useState(0)

useEffect(() => {
mountedComposers.add(composerRef)
return () => {
mountedComposers.delete(composerRef)
}
}, [composerRef])

useEffect(() => {
if (!revealed || !composerReady) {
return
Expand All @@ -58,6 +118,11 @@ export function useNativeChatComposerRevealFocus({
setReclaimCount((count) => count + 1)
}
revealedComposerReclaims.set(tabId, reclaim)
if (pendingFocusRequest?.tabId === tabId) {
pendingFocusRequest.cancel()
claimedRef.current = false
explicitRef.current = true
}
return () => {
if (revealedComposerReclaims.get(tabId) === reclaim) {
revealedComposerReclaims.delete(tabId)
Expand All @@ -77,6 +142,7 @@ export function useNativeChatComposerRevealFocus({
let cancelled = false
let userInteracted = false
let attempts = 0
const requestGeneration = focusRequestGeneration
const ownerDocument = rootRef.current?.ownerDocument
const cancelForPointer = (): void => {
userInteracted = true
Expand All @@ -101,6 +167,10 @@ export function useNativeChatComposerRevealFocus({
if (cancelled) {
return
}
if (requestGeneration !== focusRequestGeneration) {
stopWatchingUserIntent()
return
}
if (userInteracted) {
finish()
return
Expand All @@ -118,7 +188,11 @@ export function useNativeChatComposerRevealFocus({
}
// 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)) {
const editingSiblingComposer =
explicitRef.current &&
rootRef.current?.contains(active) !== true &&
[...mountedComposers].some((ownerRef) => ownerRef.current?.contains(active))
if (shouldPreserveEditableFocus(active) && !editingSiblingComposer) {
finish()
return
}
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/src/components/tab-bar/QuickLaunchButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useDetectedAgents } from '@/hooks/useDetectedAgents'
import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id'
import { captureNativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { LaunchSource } from '../../../../shared/telemetry-events'
import {
Expand Down Expand Up @@ -141,6 +142,7 @@ function QuickLaunchAgentMenuItemsInner({
const label = entry?.label ?? agent
const result = launchAgentInNewTab({
requestId: newAgentLaunchRequestId(),
focusIntent: captureNativeChatComposerFocusIntent(),
agent,
worktreeId,
groupId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
shouldQueueTerminalFocusAfterMenuClose
} from '@/lib/launch-agent-in-new-tab'
import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id'
import { captureNativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus'
import type { WindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities'
import { useAppStore } from '../../store'
import type { TabAgentLaunchOption } from './tab-agent-launch-options'
Expand Down Expand Up @@ -218,13 +219,13 @@ export function useTabBarCreateMenuController({
case 'new-simulator':
case 'go-to-simulator':
onNewSimulatorTab?.()
break
}
}
const launchAgentFromNewTabEntry = (agent: TuiAgent): void => {
const option = agentLaunchOptions.find((candidate) => candidate.agent === agent)
const result = launchAgentInNewTab({
requestId: newAgentLaunchRequestId(),
focusIntent: captureNativeChatComposerFocusIntent(),
agent,
worktreeId,
groupId: resolvedGroupId,
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/src/components/use-terminal-create-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { openMobileEmulatorTab } from '@/lib/open-mobile-emulator-tab'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import { persistAgentLaunchTabOrder } from '@/lib/launch-agent-tab-order'
import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id'
import { captureNativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus'
import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-options'
import { browserWorkspaceHasRemoteOwner } from '@/runtime/remote-browser-tab-ownership'
import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy'
Expand Down Expand Up @@ -73,6 +74,7 @@ export function useTerminalCreateActions(controller: TerminalColdActivationContr
state.groupsByWorktree[activeWorktreeId]?.[0]?.id
const result = launchAgentInNewTab({
requestId: newAgentLaunchRequestId(),
focusIntent: captureNativeChatComposerFocusIntent(),
agent,
worktreeId: activeWorktreeId,
groupId: targetGroupId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,9 @@ describe('a new tab whose chat route waits on the host agent list', () => {
throw new Error('expected the launch to wait for the host agent list')
}
const relaunch = fakeRelaunch()
const focusIntent = { request: vi.fn(), cancel: vi.fn() }

const result = launchOnceHostAnswered(route, args, startupPlan, relaunch)
const result = launchOnceHostAnswered(route, { ...args, focusIntent }, startupPlan, relaunch)

expect(result?.surface).toEqual({ kind: 'host-published' })
expect(relaunch).not.toHaveBeenCalled()
Expand All @@ -114,6 +115,7 @@ describe('a new tab whose chat route waits on the host agent list', () => {
})
expect(relaunch.mock.calls[0]?.[0]).toMatchObject({
requestId: undefined,
focusIntent,
agentSessionLaunchPlan: { route: 'structured-native-chat', requestId: 'request-1' }
})
})
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/src/lib/launch-agent-in-new-tab-host-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export function launchOnceHostAnswered(
relaunch: (args: LaunchAgentInNewTabArgs) => LaunchAgentInNewTabResult
): LaunchAgentInNewTabResult {
if (args.beforeSurfaceOpen?.({ kind: 'host-published' }) === false) {
args.focusIntent?.cancel()
return null
}
const opened = route.awaited.then(() =>
Expand All @@ -70,7 +71,10 @@ export function launchOnceHostAnswered(
!result
? { kind: 'cancelled', sessionId: null }
: (result.structuredSettlement ?? { kind: 'terminal' }),
(error: unknown): StructuredAgentLaunchSettlement => ({ kind: 'failed', error })
(error: unknown): StructuredAgentLaunchSettlement => {
args.focusIntent?.cancel()
return { kind: 'failed', error }
}
)
const deliversPrompt =
Boolean(args.prompt?.trim()) && (args.promptDelivery ?? 'auto-submit') !== 'draft'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-lau
import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt'
import type { StructuredLaunchTerminal } from '@/lib/structured-agent-session-launch-admission'
import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab'
import type { NativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus'

type StructuredFromNewTab = {
surface: AgentLaunchSurface
Expand All @@ -24,6 +25,7 @@ export function launchStructuredAgentFromNewTab(args: {
plan: AgentSessionLaunchPlan
worktreeId: string
groupId?: string
focusIntent?: NativeChatComposerFocusIntent
beforeSurfaceOpen?: LaunchAgentInNewTabArgs['beforeSurfaceOpen']
onHostDeclined?: () => StructuredLaunchTerminal
openTerminal: (terminalPlan: AgentSessionLaunchPlan) => {
Expand All @@ -37,6 +39,7 @@ export function launchStructuredAgentFromNewTab(args: {
const launch = beginStructuredAgentSessionProvisionalLaunch({
plan,
hooks: {},
...(args.focusIntent ? { focusIntent: args.focusIntent } : {}),
target: { worktreeId: args.worktreeId },
...(args.groupId ? { targetGroupId: args.groupId } : {}),
onHostDeclined: () => {
Expand Down
Loading
Loading