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 daab2c0bf50f..d3f1092c1aa3 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 @@ -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 @@ -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, @@ -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' }) ) } @@ -95,6 +98,7 @@ beforeEach(() => { }) afterEach(() => { + document.dispatchEvent(new Event('pointerdown')) act(() => { root.unmount() }) @@ -165,15 +169,45 @@ 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() @@ -181,6 +215,8 @@ describe('useNativeChatComposerRevealFocus', () => { act(() => requestNativeChatComposerFocus('other-tab')) drainFrames() expect(focusCalls).toBe(before) + act(() => destinationRoot.unmount()) + destination.remove() }) it('focuses when it mounts already revealed', () => { @@ -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(`[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() }) 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 054d32d743e7..189214ee492c 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 @@ -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 void>() +const mountedComposers = new Set>() +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 | 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 = { @@ -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 @@ -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) @@ -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 @@ -101,6 +167,10 @@ export function useNativeChatComposerRevealFocus({ if (cancelled) { return } + if (requestGeneration !== focusRequestGeneration) { + stopWatchingUserIntent() + return + } if (userInteracted) { finish() return @@ -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 } diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 8622e567160e..c00dcc07f01b 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -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 { @@ -141,6 +142,7 @@ function QuickLaunchAgentMenuItemsInner({ const label = entry?.label ?? agent const result = launchAgentInNewTab({ requestId: newAgentLaunchRequestId(), + focusIntent: captureNativeChatComposerFocusIntent(), agent, worktreeId, groupId, diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts index dcbc45df788e..4ea998ac0b30 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts @@ -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' @@ -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, diff --git a/src/renderer/src/components/use-terminal-create-actions.ts b/src/renderer/src/components/use-terminal-create-actions.ts index 2801af6e2a9b..4fcdb07ed40b 100644 --- a/src/renderer/src/components/use-terminal-create-actions.ts +++ b/src/renderer/src/components/use-terminal-create-actions.ts @@ -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' @@ -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, diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts index 6616205f6897..8fff2f533d9d 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts @@ -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() @@ -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' } }) }) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.ts b/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.ts index b78f34856d44..9b24abef455e 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.ts @@ -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(() => @@ -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' diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts b/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts index 46d12f15db18..30574b1fca1c 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts @@ -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 @@ -24,6 +25,7 @@ export function launchStructuredAgentFromNewTab(args: { plan: AgentSessionLaunchPlan worktreeId: string groupId?: string + focusIntent?: NativeChatComposerFocusIntent beforeSurfaceOpen?: LaunchAgentInNewTabArgs['beforeSurfaceOpen'] onHostDeclined?: () => StructuredLaunchTerminal openTerminal: (terminalPlan: AgentSessionLaunchPlan) => { @@ -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: () => { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 51c011a54d3b..885be5b6ae87 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -213,9 +213,16 @@ describe('launchAgentInNewTab', () => { it('stamps the launched agent on the new tab for immediate provider icon bootstrap', async () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + const cancelFocus = vi.fn() - launchAgentInNewTab({ requestId: 'request-1', agent: 'codex', worktreeId: 'wt-1' }) + launchAgentInNewTab({ + requestId: 'request-1', + agent: 'codex', + worktreeId: 'wt-1', + focusIntent: { request: vi.fn(), cancel: cancelFocus } + }) + expect(cancelFocus).toHaveBeenCalledOnce() expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { launchAgent: 'codex', pendingStartup: expect.anything() diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index c8988afffc0d..4697207b1853 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -30,6 +30,7 @@ import { routeNewTabLaunch } from '@/lib/launch-agent-in-new-tab-host-agents' import type { AgentLaunchRequestId } from '@/lib/agent-launch-request-id' +import type { NativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' /** The user action this launch serves: minted where that action is handled, or carried by the * route the caller already planned for it. */ @@ -44,6 +45,8 @@ type LaunchAgentInNewTabRequest = export type LaunchAgentInNewTabArgs = LaunchAgentInNewTabRequest & { agent: TuiAgent worktreeId: string + /** Captured at a new-chat action before the host can delay route selection. */ + focusIntent?: NativeChatComposerFocusIntent /** Tab group the user launched from; keeps split-group launches in that pane instead of the active group. */ groupId?: string /** Optional initial prompt; delivery depends on `promptDelivery` and the agent's prompt mode. */ @@ -132,6 +135,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent const store = useAppStore.getState() const executionContext = resolveAgentLaunchExecutionContext(store, { worktreeId, launchPlatform }) if (!executionContext) { + args.focusIntent?.cancel() return null } const { resolvedLaunchPlatform, isRemote, queuedShell } = executionContext @@ -166,6 +170,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent let promptDeliveryResult: Promise<{ delivered: boolean; failureNotified: boolean }> | undefined if (!startupPlan) { + args.focusIntent?.cancel() return null } @@ -188,6 +193,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent const structured = launchStructuredAgentFromNewTab({ plan, worktreeId, + ...(args.focusIntent ? { focusIntent: args.focusIntent } : {}), ...(groupId ? { groupId } : {}), ...(beforeSurfaceOpen ? { beforeSurfaceOpen } : {}), ...(args.onStructuredHostDeclined ? { onHostDeclined: args.onStructuredHostDeclined } : {}), @@ -200,9 +206,14 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent agentSessionLaunchPlan: terminalPlan }) }) - return structured && { ...structured, startupPlan } + if (!structured) { + args.focusIntent?.cancel() + return null + } + return { ...structured, startupPlan } } + args.focusIntent?.cancel() const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, worktreeId) if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { if (beforeSurfaceOpen?.({ kind: 'host-published' }) === false) { diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts index 87bbac89df74..9acc430be544 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts @@ -5,7 +5,9 @@ import type { StructuredAgentLaunchSettlement } from './structured-agent-launch- const mocks = vi.hoisted(() => ({ createUnifiedTab: vi.fn(), - createSupport: vi.fn() + createSupport: vi.fn(), + requestFocus: vi.fn(), + cancelFocus: vi.fn() })) vi.mock('@/store', () => ({ @@ -55,11 +57,13 @@ describe('a provisional structured chat tab', () => { const launch = beginStructuredAgentSessionProvisionalLaunch({ plan: planSettlingAs({ kind: 'structured', sessionId: 'claude_1' }), hooks: {}, + focusIntent: { request: mocks.requestFocus, cancel: mocks.cancelFocus }, targetGroupId: 'group-2' }) expect(launch?.tab).toBeNull() expect(mocks.createUnifiedTab).not.toHaveBeenCalled() + expect(mocks.requestFocus).not.toHaveBeenCalled() await expect(launch?.settlement).resolves.toEqual({ kind: 'structured', sessionId: 'claude_1' }) expect(mocks.createSupport).toHaveBeenCalledOnce() expect(mocks.createUnifiedTab).toHaveBeenCalledWith( @@ -67,5 +71,7 @@ describe('a provisional structured chat tab', () => { 'agent-session', expect.objectContaining({ executionHostId: 'local', targetGroupId: 'group-2' }) ) + expect(mocks.requestFocus).toHaveBeenCalledExactlyOnceWith('structured-agent-session-claude_1') + expect(mocks.cancelFocus).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts index ab416b89298f..8235e0a2ba2e 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts @@ -24,6 +24,7 @@ import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import type { NativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' type LocalProvisionalLaunch = StructuredAgentLaunchHandle & { tab: Tab } @@ -92,6 +93,7 @@ export function structuredLaunchOwner( type ProvisionalLaunchArgs = { plan: AgentSessionLaunchPlan hooks: StructuredAgentLaunchHooks + focusIntent?: NativeChatComposerFocusIntent target?: AgentSessionLaunchTarget targetGroupId?: string activate?: boolean @@ -110,42 +112,73 @@ type ProvisionalLaunchArgs = { export function beginStructuredAgentSessionProvisionalLaunch( args: ProvisionalLaunchArgs ): StructuredAgentSessionProvisionalLaunch | null { + const focusIntent = args.focusIntent + if (args.activate === false) { + focusIntent?.cancel() + } const worktreeId = args.target?.worktreeId ?? args.plan.worktreeId - const owner = worktreeId ? structuredLaunchOwner(args.plan, worktreeId, args.target) : null - if (!owner || !worktreeId) { - // No host to ask: the launch path refuses it with its own message. - return beginLocalProvisionalLaunch(args) + let tabOpened = false + const cancelIfNoTab = (): void => { + if (!tabOpened) { + focusIntent?.cancel() + } } - if (args.beforeOpen?.() === false) { - return null + const open = (launch: LocalProvisionalLaunch | null): LocalProvisionalLaunch | null => { + if (launch) { + tabOpened = true + if (args.activate !== false) { + focusIntent?.request(launch.tab.id) + } + } else { + cancelIfNoTab() + } + return launch + } + try { + const owner = worktreeId ? structuredLaunchOwner(args.plan, worktreeId, args.target) : null + if (owner && worktreeId && args.beforeOpen?.() === false) { + cancelIfNoTab() + return null + } + if (!owner || !worktreeId) { + // No host to ask: the launch path refuses it with its own message. + return open(beginLocalProvisionalLaunch(args)) + } + const launch = beginHostAdmittedStructuredLaunch({ + plan: args.plan, + hooks: args.hooks, + worktreeId, + executionHostId: owner.executionHostId, + target: owner.target, + openAdmitted: (seedOptions) => + open( + beginLocalProvisionalLaunch({ + ...args, + target: { + ...args.target, + worktreeId, + executionHostId: owner.executionHostId, + ...(seedOptions ? { seedOptions } : {}) + }, + beforeOpen: undefined + }) + ), + onHostDeclined: + args.onHostDeclined ?? + (() => + openDeclinedStructuredLaunchTerminal({ + plan: args.plan, + worktreeId, + ...(args.targetGroupId ? { targetGroupId: args.targetGroupId } : {}), + ...(args.declinedTerminal ? { terminal: args.declinedTerminal } : {}) + })) + }) + void launch.settlement.then(cancelIfNoTab, cancelIfNoTab) + return launch + } catch (error) { + cancelIfNoTab() + throw error } - return beginHostAdmittedStructuredLaunch({ - plan: args.plan, - hooks: args.hooks, - worktreeId, - executionHostId: owner.executionHostId, - target: owner.target, - openAdmitted: (seedOptions) => - beginLocalProvisionalLaunch({ - ...args, - target: { - ...args.target, - worktreeId, - executionHostId: owner.executionHostId, - ...(seedOptions ? { seedOptions } : {}) - }, - beforeOpen: undefined - }), - onHostDeclined: - args.onHostDeclined ?? - (() => - openDeclinedStructuredLaunchTerminal({ - plan: args.plan, - worktreeId, - ...(args.targetGroupId ? { targetGroupId: args.targetGroupId } : {}), - ...(args.declinedTerminal ? { terminal: args.declinedTerminal } : {}) - })) - }) } function beginLocalProvisionalLaunch(args: ProvisionalLaunchArgs): LocalProvisionalLaunch | null {