From f086c3ac3da7bc642c952968cb818afb039dda5f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:09:16 -0700 Subject: [PATCH 01/42] Transfer keyboard focus when a sibling chat pane opens --- .../native-chat/native-chat-composer-reveal-focus.test.tsx | 5 ++++- .../native-chat/use-native-chat-composer-reveal-focus.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) 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..840b0283f8d4 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 @@ -209,16 +209,19 @@ 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 })) }) + other.querySelector('textarea')?.focus() drainFrames() expect(focusCalls).toBe(1) + expect(container.querySelector('textarea')).toBe(document.activeElement) 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..8241d1eccafa 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 @@ -118,7 +118,10 @@ 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)) { + // A sibling pane's composer must yield when this pane becomes focused. + const editingSiblingPane = + active?.closest('[data-retained-pane-host]')?.contains(rootRef.current) === false + if (shouldPreserveEditableFocus(active) && !editingSiblingPane) { finish() return } From 0ed5bb4bd4b53d62e244d07c9d71ffcb83068ef3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:34:58 -0700 Subject: [PATCH 02/42] fix(native-chat): hand focus to newly opened chat composer --- ...native-chat-composer-reveal-focus.test.tsx | 79 +++++++++++++-- .../use-native-chat-composer-reveal-focus.ts | 79 ++++++++++++++- .../components/tab-bar/QuickLaunchButton.tsx | 2 + .../use-tab-bar-create-menu-controller.ts | 2 + .../components/use-terminal-create-actions.ts | 2 + ...aunch-agent-in-new-tab-host-agents.test.ts | 4 +- .../launch-agent-in-new-tab-host-agents.ts | 6 +- ...aunch-agent-in-new-tab-structured-route.ts | 3 + .../src/lib/launch-agent-in-new-tab.test.ts | 9 +- .../src/lib/launch-agent-in-new-tab.ts | 13 ++- ...ured-agent-session-provisional-tab.test.ts | 8 +- ...tructured-agent-session-provisional-tab.ts | 99 ++++++++++++------- 12 files changed, 254 insertions(+), 52 deletions(-) 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 840b0283f8d4..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', () => { @@ -216,12 +252,37 @@ describe('useNativeChatComposerRevealFocus', () => { 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 8241d1eccafa..06e3972a8e97 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,63 @@ 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 } + const timeout = setTimeout(cancel, PENDING_FOCUS_MS) + pendingFocusRequest = pending + document.addEventListener('pointerdown', cancel, true) + document.addEventListener('keydown', cancelForNavigation, true) + return { + request: (tabId) => { + if (pendingFocusRequest !== pending) { + return + } + pending.tabId = tabId + 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 +99,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 +116,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 +140,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 +165,10 @@ export function useNativeChatComposerRevealFocus({ if (cancelled) { return } + if (requestGeneration !== focusRequestGeneration) { + stopWatchingUserIntent() + return + } if (userInteracted) { finish() return @@ -118,10 +186,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. - // A sibling pane's composer must yield when this pane becomes focused. - const editingSiblingPane = - active?.closest('[data-retained-pane-host]')?.contains(rootRef.current) === false - if (shouldPreserveEditableFocus(active) && !editingSiblingPane) { + 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..204c0665d1e8 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' @@ -225,6 +226,7 @@ export function useTabBarCreateMenuController({ 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 { From 6167d52041119c97a546a4222ec8159353940a2c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:44:40 -0700 Subject: [PATCH 03/42] fix(native-chat): keep tab bar controller within line limit --- .../src/components/tab-bar/use-tab-bar-create-menu-controller.ts | 1 - 1 file changed, 1 deletion(-) 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 204c0665d1e8..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 @@ -219,7 +219,6 @@ export function useTabBarCreateMenuController({ case 'new-simulator': case 'go-to-simulator': onNewSimulatorTab?.() - break } } const launchAgentFromNewTabEntry = (agent: TuiAgent): void => { From a66078ce1e3301bf852e199de5c254339856e6d6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:39:40 -0700 Subject: [PATCH 04/42] fix(chat): split into a fresh conversation beside the current chat --- .../native-chat-layout-actions.test.ts | 8 +- .../native-chat/native-chat-layout-actions.ts | 74 ++++++++++++++--- .../native-chat-split-flow.test.ts | 82 +++++++++++++++++++ 3 files changed, 147 insertions(+), 17 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-split-flow.test.ts diff --git a/src/renderer/src/components/native-chat/native-chat-layout-actions.test.ts b/src/renderer/src/components/native-chat/native-chat-layout-actions.test.ts index 6f3a3868bdf1..41ad141f0cad 100644 --- a/src/renderer/src/components/native-chat/native-chat-layout-actions.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-layout-actions.test.ts @@ -25,18 +25,18 @@ function stateWithActiveTab(tab: Record, tabOrder = ['chat', 'o } describe('native chat layout actions', () => { - it('resolves structured chats to the reusable workspace-tab move path', () => { - const state = stateWithActiveTab({ contentType: 'agent-session' }) + it('can split a structured chat even when it is the only tab', () => { + const state = stateWithActiveTab({ contentType: 'agent-session', agentSessionAgent: 'codex' }) const target = resolveActiveNativeChatSplitTarget(state, 'workspace', 'group') expect(target).toEqual({ kind: 'workspace-tab', unifiedTabId: 'chat', groupId: 'group' }) expect(canRunNativeChatSplitTarget(state, target)).toBe(true) expect( canRunNativeChatSplitTarget( - stateWithActiveTab({ contentType: 'agent-session' }, ['chat']), + stateWithActiveTab({ contentType: 'agent-session', agentSessionAgent: 'codex' }, ['chat']), target ) - ).toBe(false) + ).toBe(true) }) it('resolves terminal-backed chat mode to the existing pane split path', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-layout-actions.ts b/src/renderer/src/components/native-chat/native-chat-layout-actions.ts index 4f24a8a663c5..c4562069a363 100644 --- a/src/renderer/src/components/native-chat/native-chat-layout-actions.ts +++ b/src/renderer/src/components/native-chat/native-chat-layout-actions.ts @@ -1,9 +1,10 @@ import type { AppState } from '@/store/types' import { useAppStore } from '@/store' -import { - canMoveTabToNewPaneColumnFromState, - moveTabToNewPaneColumn -} from '@/components/tab-bar/tab-move-to-pane-column' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { structuredAgentSessionOwnerForTab } from '@/runtime/structured-agent-session-owner' +import { adoptAgentSessionLaunchVerdict } from '@/lib/agent-session-launch-plan' +import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' +import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' import { requestActiveTerminalPaneSplit } from '@/components/tab-bar/request-active-terminal-pane-split' import type { NativeChatSplitDirection } from './native-chat-split-shortcut' @@ -39,10 +40,26 @@ export function canRunNativeChatSplitTarget( if (!target) { return false } - return ( - target.kind === 'terminal-pane' || - canMoveTabToNewPaneColumnFromState(state, target.unifiedTabId, target.groupId) - ) + return target.kind === 'terminal-pane' || findSplitSource(state, target) !== null +} + +function findSplitSource( + state: Pick, + target: Extract +) { + for (const [worktreeId, tabs] of Object.entries(state.unifiedTabsByWorktree)) { + const tab = tabs.find( + (candidate) => candidate.id === target.unifiedTabId && candidate.groupId === target.groupId + ) + if ( + tab?.contentType === 'agent-session' && + isTuiAgent(tab.agentSessionAgent) && + state.groupsByWorktree[worktreeId]?.some((group) => group.id === target.groupId) + ) { + return { tab, worktreeId, agent: tab.agentSessionAgent } + } + } + return null } export function runNativeChatSplitTarget( @@ -56,11 +73,42 @@ export function runNativeChatSplitTarget( }) return true } - return moveTabToNewPaneColumn({ - unifiedTabId: target.unifiedTabId, - groupId: target.groupId, - direction - }) + const state = useAppStore.getState() + const source = findSplitSource(state, target) + if (!source) { + return false + } + const executionHostId = structuredAgentSessionOwnerForTab(state, source.tab) + if (!executionHostId) { + return false + } + const groupId = state.createEmptySplitGroup(source.worktreeId, target.groupId, direction) + if (!groupId) { + return false + } + try { + const launch = beginStructuredAgentSessionProvisionalLaunch({ + plan: adoptAgentSessionLaunchVerdict({ + route: 'structured-native-chat', + requestId: newAgentLaunchRequestId(), + agent: source.agent, + worktreeId: source.worktreeId, + executionHostId + }), + hooks: {}, + targetGroupId: groupId + }) + if (!launch) { + state.closeEmptyGroup(source.worktreeId, groupId) + return false + } + // The normal new-chat launch founds an idle conversation; sending starts the agent. + void launch.settlement.finally(() => state.closeEmptyGroup(source.worktreeId, groupId)) + return true + } catch (error) { + state.closeEmptyGroup(source.worktreeId, groupId) + throw error + } } export function runActiveNativeChatSplit( diff --git a/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts b/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts new file mode 100644 index 000000000000..4fdd6a0260af --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts @@ -0,0 +1,82 @@ +import { expect, it, vi } from 'vitest' +import { createTestStore } from '@/store/slices/store-test-helpers' +import { createTabsSliceMockApi } from '@/store/slices/tabs-slice-test-harness' +import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' + +const current = vi.hoisted(() => ({ + store: null as ReturnType | null, + launch: vi.fn() +})) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => { + if (!current.store) { + throw new Error('No test store') + } + return current.store.getState() + } + } +})) +vi.mock('@/lib/agent-session-launch-plan', () => ({ + adoptAgentSessionLaunchVerdict: (plan: unknown) => plan +})) +vi.mock('@/lib/structured-agent-session-provisional-tab', () => ({ + beginStructuredAgentSessionProvisionalLaunch: current.launch +})) +createTabsSliceMockApi() + +import { runNativeChatSplitTarget } from './native-chat-layout-actions' + +it.each(['right', 'down'] as const)( + 'keeps a lone chat and focuses a fresh same-agent chat %s on its owning host', + async (direction) => { + const store = createTestStore() + current.store = store + const worktreeId = 'folder-workspace' + const source = store.getState().createUnifiedTab(worktreeId, 'agent-session', { + entityId: 'original-session', + agentSessionAgent: 'codex', + executionHostId: 'runtime:remote-host' + }) + current.launch.mockImplementation( + ({ plan, targetGroupId }: { plan: AgentSessionLaunchPlan; targetGroupId: string }) => { + expect(plan).toMatchObject({ + agent: 'codex', + worktreeId, + executionHostId: 'runtime:remote-host' + }) + expect(plan.prompt).toBeUndefined() + store.getState().createUnifiedTab(worktreeId, 'agent-session', { + entityId: 'fresh-session', + agentSessionAgent: plan.agent, + executionHostId: plan.executionHostId, + targetGroupId + }) + return { settlement: Promise.resolve({ kind: 'structured', sessionId: 'fresh-session' }) } + } + ) + + expect( + runNativeChatSplitTarget( + { + kind: 'workspace-tab', + unifiedTabId: source.id, + groupId: source.groupId + }, + direction + ) + ).toBe(true) + await Promise.resolve() + const state = store.getState() + const [original, fresh] = state.unifiedTabsByWorktree[worktreeId] ?? [] + expect(original?.groupId).toBe(source.groupId) + expect(state.groupsByWorktree[worktreeId]).toHaveLength(2) + expect(state.activeGroupIdByWorktree[worktreeId]).toBe(fresh?.groupId) + expect(state.layoutByWorktree[worktreeId]).toMatchObject({ + type: 'split', + direction: direction === 'right' ? 'horizontal' : 'vertical', + first: { type: 'leaf', groupId: source.groupId }, + second: { type: 'leaf', groupId: fresh?.groupId } + }) + } +) From 7a09e4a02fefd3c5e4061004fa969ce131a29fb7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:44:58 -0700 Subject: [PATCH 05/42] test(chat): declare the split fixture store without an assertion --- .../components/native-chat/native-chat-split-flow.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts b/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts index 4fdd6a0260af..bc00cedf7128 100644 --- a/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts @@ -3,10 +3,10 @@ import { createTestStore } from '@/store/slices/store-test-helpers' import { createTabsSliceMockApi } from '@/store/slices/tabs-slice-test-harness' import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' -const current = vi.hoisted(() => ({ - store: null as ReturnType | null, - launch: vi.fn() -})) +const current = vi.hoisted((): { + store: ReturnType | null + launch: ReturnType +} => ({ store: null, launch: vi.fn() })) vi.mock('@/store', () => ({ useAppStore: { getState: () => { From 9895b773baea6ecb5d60d0ca32087d2546d7f182 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:47:16 -0700 Subject: [PATCH 06/42] fix(chat): route the split to the source conversation host --- .../components/native-chat/native-chat-layout-actions.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-layout-actions.ts b/src/renderer/src/components/native-chat/native-chat-layout-actions.ts index c4562069a363..639f6d503058 100644 --- a/src/renderer/src/components/native-chat/native-chat-layout-actions.ts +++ b/src/renderer/src/components/native-chat/native-chat-layout-actions.ts @@ -1,7 +1,7 @@ import type { AppState } from '@/store/types' import { useAppStore } from '@/store' import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { structuredAgentSessionOwnerForTab } from '@/runtime/structured-agent-session-owner' +import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' import { adoptAgentSessionLaunchVerdict } from '@/lib/agent-session-launch-plan' import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' @@ -78,8 +78,8 @@ export function runNativeChatSplitTarget( if (!source) { return false } - const executionHostId = structuredAgentSessionOwnerForTab(state, source.tab) - if (!executionHostId) { + const executionHostId = source.tab.executionHostId + if (!executionHostId || !structuredAgentSessionTargetForHost(executionHostId)) { return false } const groupId = state.createEmptySplitGroup(source.worktreeId, target.groupId, direction) From 55b97cf4feb4dafa8d7df62dd3297ec6ea30bdcd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:54:09 -0700 Subject: [PATCH 07/42] test(chat): keep the split regression focused on the launch contract --- .../native-chat-split-flow.test.ts | 75 +++++++------------ 1 file changed, 25 insertions(+), 50 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts b/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts index bc00cedf7128..58919de1f2b4 100644 --- a/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts @@ -1,27 +1,15 @@ import { expect, it, vi } from 'vitest' import { createTestStore } from '@/store/slices/store-test-helpers' import { createTabsSliceMockApi } from '@/store/slices/tabs-slice-test-harness' -import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' -const current = vi.hoisted((): { - store: ReturnType | null - launch: ReturnType -} => ({ store: null, launch: vi.fn() })) -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => { - if (!current.store) { - throw new Error('No test store') - } - return current.store.getState() - } - } -})) +let store: ReturnType +const launch = vi.hoisted(() => vi.fn()) +vi.mock('@/store', () => ({ useAppStore: { getState: () => store.getState() } })) vi.mock('@/lib/agent-session-launch-plan', () => ({ adoptAgentSessionLaunchVerdict: (plan: unknown) => plan })) vi.mock('@/lib/structured-agent-session-provisional-tab', () => ({ - beginStructuredAgentSessionProvisionalLaunch: current.launch + beginStructuredAgentSessionProvisionalLaunch: launch })) createTabsSliceMockApi() @@ -29,54 +17,41 @@ import { runNativeChatSplitTarget } from './native-chat-layout-actions' it.each(['right', 'down'] as const)( 'keeps a lone chat and focuses a fresh same-agent chat %s on its owning host', - async (direction) => { - const store = createTestStore() - current.store = store + (direction) => { + store = createTestStore() const worktreeId = 'folder-workspace' const source = store.getState().createUnifiedTab(worktreeId, 'agent-session', { entityId: 'original-session', agentSessionAgent: 'codex', executionHostId: 'runtime:remote-host' }) - current.launch.mockImplementation( - ({ plan, targetGroupId }: { plan: AgentSessionLaunchPlan; targetGroupId: string }) => { - expect(plan).toMatchObject({ + launch.mockReturnValue({ settlement: new Promise(() => {}) }) + + const target = { + kind: 'workspace-tab', + unifiedTabId: source.id, + groupId: source.groupId + } as const + expect(runNativeChatSplitTarget(target, direction)).toBe(true) + const state = store.getState() + const groupId = state.activeGroupIdByWorktree[worktreeId] + expect(state.unifiedTabsByWorktree[worktreeId]).toEqual([source]) + expect(launch).toHaveBeenLastCalledWith( + expect.objectContaining({ + targetGroupId: groupId, + plan: expect.objectContaining({ agent: 'codex', worktreeId, - executionHostId: 'runtime:remote-host' + executionHostId: source.executionHostId }) - expect(plan.prompt).toBeUndefined() - store.getState().createUnifiedTab(worktreeId, 'agent-session', { - entityId: 'fresh-session', - agentSessionAgent: plan.agent, - executionHostId: plan.executionHostId, - targetGroupId - }) - return { settlement: Promise.resolve({ kind: 'structured', sessionId: 'fresh-session' }) } - } + }) ) - - expect( - runNativeChatSplitTarget( - { - kind: 'workspace-tab', - unifiedTabId: source.id, - groupId: source.groupId - }, - direction - ) - ).toBe(true) - await Promise.resolve() - const state = store.getState() - const [original, fresh] = state.unifiedTabsByWorktree[worktreeId] ?? [] - expect(original?.groupId).toBe(source.groupId) - expect(state.groupsByWorktree[worktreeId]).toHaveLength(2) - expect(state.activeGroupIdByWorktree[worktreeId]).toBe(fresh?.groupId) + expect(launch.mock.lastCall?.[0].plan.prompt).toBeUndefined() expect(state.layoutByWorktree[worktreeId]).toMatchObject({ type: 'split', direction: direction === 'right' ? 'horizontal' : 'vertical', first: { type: 'leaf', groupId: source.groupId }, - second: { type: 'leaf', groupId: fresh?.groupId } + second: { type: 'leaf', groupId } }) } ) From 64f584397058b559558e12a9189e0cb91e1d8b36 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:10:31 -0700 Subject: [PATCH 08/42] test(chat): give the command palette split fixture its conversation agent --- .../src/components/cmd-j/quick-action-context.test.ts | 3 ++- .../native-chat/native-chat-layout-actions.test.ts | 9 ++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/cmd-j/quick-action-context.test.ts b/src/renderer/src/components/cmd-j/quick-action-context.test.ts index 843634309dfe..52e107590d26 100644 --- a/src/renderer/src/components/cmd-j/quick-action-context.test.ts +++ b/src/renderer/src/components/cmd-j/quick-action-context.test.ts @@ -286,7 +286,8 @@ describe('Cmd+J quick action context', () => { entityId: 'session-1', groupId: 'group-1', worktreeId: 'wt-1', - contentType: 'agent-session' + contentType: 'agent-session', + agentSessionAgent: 'codex' } ] }, diff --git a/src/renderer/src/components/native-chat/native-chat-layout-actions.test.ts b/src/renderer/src/components/native-chat/native-chat-layout-actions.test.ts index 41ad141f0cad..53f6c7c6a557 100644 --- a/src/renderer/src/components/native-chat/native-chat-layout-actions.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-layout-actions.test.ts @@ -5,7 +5,7 @@ import { resolveActiveNativeChatSplitTarget } from './native-chat-layout-actions' -function stateWithActiveTab(tab: Record, tabOrder = ['chat', 'other']) { +function stateWithActiveTab(tab: Record, tabOrder = ['chat']) { return { groupsByWorktree: { workspace: [{ id: 'group', worktreeId: 'workspace', activeTabId: 'chat', tabOrder }] @@ -31,12 +31,7 @@ describe('native chat layout actions', () => { expect(target).toEqual({ kind: 'workspace-tab', unifiedTabId: 'chat', groupId: 'group' }) expect(canRunNativeChatSplitTarget(state, target)).toBe(true) - expect( - canRunNativeChatSplitTarget( - stateWithActiveTab({ contentType: 'agent-session', agentSessionAgent: 'codex' }, ['chat']), - target - ) - ).toBe(true) + }) it('resolves terminal-backed chat mode to the existing pane split path', () => { From c41c2fadbf00331f300b04365e863b9744b17d06 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:13:02 -0700 Subject: [PATCH 09/42] test(chat): seed the legacy command fixture without expanding its asserted shape --- .../src/components/cmd-j/quick-action-context.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/cmd-j/quick-action-context.test.ts b/src/renderer/src/components/cmd-j/quick-action-context.test.ts index 52e107590d26..7197f293fbe7 100644 --- a/src/renderer/src/components/cmd-j/quick-action-context.test.ts +++ b/src/renderer/src/components/cmd-j/quick-action-context.test.ts @@ -286,14 +286,16 @@ describe('Cmd+J quick action context', () => { entityId: 'session-1', groupId: 'group-1', worktreeId: 'wt-1', - contentType: 'agent-session', - agentSessionAgent: 'codex' + contentType: 'agent-session' } ] }, activeView: 'settings', settings: null } as unknown as AppState + state.unifiedTabsByWorktree['wt-1']?.forEach((tab) => { + tab.agentSessionAgent = 'codex' + }) const buildContext = (activeView: AppState['activeView']) => buildCmdJQuickActionContext({ From 4302dc3abd08d5a451d16bdbd4785e6f7fb27962 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:31:00 -0700 Subject: [PATCH 10/42] test(chat): isolate pane identity commands from the split launch boundary --- .../use-structured-native-chat-pane-commands.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.test.tsx b/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.test.tsx index 0975e1be319e..05cafca4a31c 100644 --- a/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.test.tsx @@ -30,6 +30,7 @@ vi.mock('@/components/ui/dropdown-menu', () => { DropdownMenuTrigger: Pass } }) +vi.mock('./native-chat-layout-actions', () => ({ runNativeChatSplitTarget: vi.fn() })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) From e70e185dbcd3e1ffb85ca413b8257d848a13e12f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:53:29 -0700 Subject: [PATCH 11/42] Prevent chat splits from starting a terminal on host refusal --- .../native-chat/native-chat-layout-actions.ts | 1 + .../native-chat-split-flow.test.ts | 69 +++++++++++++------ ...ructured-agent-session-launch-admission.ts | 29 +++++--- ...tructured-agent-session-provisional-tab.ts | 27 ++++---- 4 files changed, 84 insertions(+), 42 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-layout-actions.ts b/src/renderer/src/components/native-chat/native-chat-layout-actions.ts index 639f6d503058..abaf933d3780 100644 --- a/src/renderer/src/components/native-chat/native-chat-layout-actions.ts +++ b/src/renderer/src/components/native-chat/native-chat-layout-actions.ts @@ -96,6 +96,7 @@ export function runNativeChatSplitTarget( executionHostId }), hooks: {}, + onHostDeclined: null, targetGroupId: groupId }) if (!launch) { diff --git a/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts b/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts index 58919de1f2b4..cc9bb8027051 100644 --- a/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts @@ -3,29 +3,45 @@ import { createTestStore } from '@/store/slices/store-test-helpers' import { createTabsSliceMockApi } from '@/store/slices/tabs-slice-test-harness' let store: ReturnType -const launch = vi.hoisted(() => vi.fn()) +const mocks = vi.hoisted(() => ({ + begin: vi.fn(), + createSupport: vi.fn(), + launchCli: vi.fn(), + error: vi.fn(), + info: vi.fn() +})) vi.mock('@/store', () => ({ useAppStore: { getState: () => store.getState() } })) -vi.mock('@/lib/agent-session-launch-plan', () => ({ - adoptAgentSessionLaunchVerdict: (plan: unknown) => plan +vi.mock('sonner', () => ({ toast: { error: mocks.error, info: mocks.info } })) +vi.mock('@/lib/structured-agent-launch-settlement', () => ({ + beginStructuredAgentLaunchSettlement: mocks.begin })) -vi.mock('@/lib/structured-agent-session-provisional-tab', () => ({ - beginStructuredAgentSessionProvisionalLaunch: launch +vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: mocks.launchCli })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.createSupport })) createTabsSliceMockApi() import { runNativeChatSplitTarget } from './native-chat-layout-actions' it.each(['right', 'down'] as const)( - 'keeps a lone chat and focuses a fresh same-agent chat %s on its owning host', - (direction) => { + 'splits %s on the owning host without launching a CLI when it admits or declines', + async (direction) => { + vi.clearAllMocks() store = createTestStore() const worktreeId = 'folder-workspace' + const executionHostId = 'runtime:remote-host' const source = store.getState().createUnifiedTab(worktreeId, 'agent-session', { entityId: 'original-session', agentSessionAgent: 'codex', - executionHostId: 'runtime:remote-host' + executionHostId + }) + mocks.createSupport.mockResolvedValue({ supported: true }) + mocks.begin.mockReturnValue({ + sessionId: 'fresh-session', + executionHostId, + settlement: Promise.resolve({ kind: 'structured', sessionId: 'fresh-session' }), + cancel: vi.fn() }) - launch.mockReturnValue({ settlement: new Promise(() => {}) }) const target = { kind: 'workspace-tab', @@ -35,23 +51,34 @@ it.each(['right', 'down'] as const)( expect(runNativeChatSplitTarget(target, direction)).toBe(true) const state = store.getState() const groupId = state.activeGroupIdByWorktree[worktreeId] - expect(state.unifiedTabsByWorktree[worktreeId]).toEqual([source]) - expect(launch).toHaveBeenLastCalledWith( - expect.objectContaining({ - targetGroupId: groupId, - plan: expect.objectContaining({ - agent: 'codex', - worktreeId, - executionHostId: source.executionHostId - }) - }) - ) - expect(launch.mock.lastCall?.[0].plan.prompt).toBeUndefined() expect(state.layoutByWorktree[worktreeId]).toMatchObject({ type: 'split', direction: direction === 'right' ? 'horizontal' : 'vertical', first: { type: 'leaf', groupId: source.groupId }, second: { type: 'leaf', groupId } }) + await vi.waitFor(() => + expect(store.getState().unifiedTabsByWorktree[worktreeId]).toMatchObject([ + source, + { entityId: 'fresh-session', agentSessionAgent: 'codex', executionHostId, groupId } + ]) + ) + expect(store.getState().activeGroupIdByWorktree[worktreeId]).toBe(groupId) + expect(mocks.begin.mock.lastCall?.[2].executionHostId).toBe(executionHostId) + expect(mocks.begin.mock.lastCall?.[2].prompt).toBeUndefined() + const splitTabs = store.getState().unifiedTabsByWorktree[worktreeId] + + mocks.createSupport.mockResolvedValue({ supported: false }) + expect(runNativeChatSplitTarget(target, direction)).toBe(true) + await vi.waitFor(() => { + expect(mocks.error).toHaveBeenCalledExactlyOnceWith('Chat could not be started.') + expect(store.getState().layoutByWorktree[worktreeId]).toEqual( + state.layoutByWorktree[worktreeId] + ) + }) + expect(store.getState().unifiedTabsByWorktree[worktreeId]).toEqual(splitTabs) + expect(mocks.begin).toHaveBeenCalledOnce() + expect(mocks.launchCli).not.toHaveBeenCalled() + expect(mocks.info).not.toHaveBeenCalled() } ) diff --git a/src/renderer/src/lib/structured-agent-session-launch-admission.ts b/src/renderer/src/lib/structured-agent-session-launch-admission.ts index d44f0ea4ca84..2e7b9bcfe574 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-admission.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-admission.ts @@ -127,8 +127,8 @@ export async function openDeclinedStructuredLaunchTerminal(args: { /** * Asks the host that would run a chat, this machine or a paired server, before committing any of it * here: no tab, launch record, queued prompt or focus intent exists until it answers. Admitted opens - * the chat; declined opens the caller's terminal (a resume, which has no terminal equivalent, - * fails); an unreachable server opens nothing and says so. There is nothing to undo either way. + * the chat; declined opens the caller's terminal, or fails when none is allowed (including resume); + * an unreachable server opens nothing and says so. There is nothing to undo either way. * This machine's "can't answer" or "not resolvable yet" opens the chat, whose own create reports. */ export function beginHostAdmittedStructuredLaunch(args: { @@ -140,10 +140,12 @@ export function beginHostAdmittedStructuredLaunch(args: { /** Commits the admitted chat: the local launch path, told which host admitted it and the saved * selection that host said create will seed. */ openAdmitted: (seedOptions?: Readonly>) => AdmittedLaunch | null - /** Told which host declined, since where its terminal opens can depend on it. */ - onHostDeclined: ( - target: RuntimeClientTarget - ) => Promise | StructuredLaunchTerminal + /** Told which host declined; null refuses without opening a terminal. */ + onHostDeclined: + | (( + target: RuntimeClientTarget + ) => Promise | StructuredLaunchTerminal) + | null }): HostAdmittedStructuredLaunch { const { plan } = args const paired = args.target.kind === 'environment' @@ -188,13 +190,22 @@ export function beginHostAdmittedStructuredLaunch(args: { } } if (admission.kind === 'declined') { - if (plan.resumeFrom) { - resolveDelivery(NOT_DELIVERED) + if (plan.resumeFrom || args.onHostDeclined === null) { + if (args.onHostDeclined === null) { + toast.error( + translate( + 'auto.components.native.chat.NativeChatLaunchRetry.failed', + 'Chat could not be started.' + ) + ) + } + resolveDelivery({ delivered: false, failureNotified: args.onHostDeclined === null }) return { kind: 'failed', error: new StructuredAgentSessionCreateRefusalError( 'structured_agent_session_unsupported' - ) + ), + ...(args.onHostDeclined === null ? { notified: true as const } : {}) } } // The notice explains a server's refusal; on this machine the terminal opening is the answer. 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 8235e0a2ba2e..a9937ee16326 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts @@ -100,10 +100,12 @@ type ProvisionalLaunchArgs = { /** Lets workspace flows reveal before the chat's tab is owned: before its host is asked, when no * session id exists yet. */ beforeOpen?: (sessionId?: string) => boolean | void - /** The terminal the host's "no" opens; a caller without one gets a new agent tab's. */ - onHostDeclined?: ( - target: RuntimeClientTarget - ) => Promise | StructuredLaunchTerminal + /** The terminal the host's "no" opens; omitted uses a new agent tab, null refuses. */ + onHostDeclined?: + | (( + target: RuntimeClientTarget + ) => Promise | StructuredLaunchTerminal) + | null /** What that default terminal carries from the caller, e.g. a recipe's saved CLI arguments. */ declinedTerminal?: DeclinedStructuredLaunchTerminalOptions } @@ -164,14 +166,15 @@ export function beginStructuredAgentSessionProvisionalLaunch( }) ), onHostDeclined: - args.onHostDeclined ?? - (() => - openDeclinedStructuredLaunchTerminal({ - plan: args.plan, - worktreeId, - ...(args.targetGroupId ? { targetGroupId: args.targetGroupId } : {}), - ...(args.declinedTerminal ? { terminal: args.declinedTerminal } : {}) - })) + args.onHostDeclined === undefined + ? () => + openDeclinedStructuredLaunchTerminal({ + plan: args.plan, + worktreeId, + ...(args.targetGroupId ? { targetGroupId: args.targetGroupId } : {}), + ...(args.declinedTerminal ? { terminal: args.declinedTerminal } : {}) + }) + : args.onHostDeclined }) void launch.settlement.then(cancelIfNoTab, cancelIfNoTab) return launch From 90d2d89c60d85a1410207544d52ad005b544c5d8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:10:41 -0700 Subject: [PATCH 12/42] Cancel structured launches when their destination pane closes --- .../native-chat/native-chat-split-flow.test.ts | 16 ++++++++++++++-- src/renderer/src/lib/agent-launch-destination.ts | 11 +++++++++++ ...ctured-agent-session-launch-admission.test.ts | 5 +++-- .../structured-agent-session-launch-admission.ts | 7 ++++++- ...uctured-agent-session-provisional-tab.test.ts | 1 + .../structured-agent-session-provisional-tab.ts | 7 +++++++ 6 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 src/renderer/src/lib/agent-launch-destination.ts diff --git a/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts b/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts index cc9bb8027051..1ed9929a6f32 100644 --- a/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts @@ -1,6 +1,7 @@ import { expect, it, vi } from 'vitest' import { createTestStore } from '@/store/slices/store-test-helpers' import { createTabsSliceMockApi } from '@/store/slices/tabs-slice-test-harness' +import { isEmptyWorkspaceDefaultSurfacePending } from '@/lib/empty-workspace-default-surface-claims' let store: ReturnType const mocks = vi.hoisted(() => ({ @@ -24,7 +25,7 @@ createTabsSliceMockApi() import { runNativeChatSplitTarget } from './native-chat-layout-actions' it.each(['right', 'down'] as const)( - 'splits %s on the owning host without launching a CLI when it admits or declines', + 'splits %s on the owning host, refusing declines and abandoning closed destinations', async (direction) => { vi.clearAllMocks() store = createTestStore() @@ -63,7 +64,6 @@ it.each(['right', 'down'] as const)( { entityId: 'fresh-session', agentSessionAgent: 'codex', executionHostId, groupId } ]) ) - expect(store.getState().activeGroupIdByWorktree[worktreeId]).toBe(groupId) expect(mocks.begin.mock.lastCall?.[2].executionHostId).toBe(executionHostId) expect(mocks.begin.mock.lastCall?.[2].prompt).toBeUndefined() const splitTabs = store.getState().unifiedTabsByWorktree[worktreeId] @@ -77,6 +77,18 @@ it.each(['right', 'down'] as const)( ) }) expect(store.getState().unifiedTabsByWorktree[worktreeId]).toEqual(splitTabs) + const admission = Promise.withResolvers<{ supported: boolean }>() + mocks.createSupport.mockReturnValueOnce(admission.promise) + runNativeChatSplitTarget(target, direction) + store + .getState() + .closeEmptyGroup(worktreeId, store.getState().activeGroupIdByWorktree[worktreeId]) + admission.resolve({ supported: true }) + await vi.waitFor(() => expect(isEmptyWorkspaceDefaultSurfacePending(worktreeId)).toBe(false)) + expect(store.getState().unifiedTabsByWorktree[worktreeId]).toEqual(splitTabs) + expect(store.getState().layoutByWorktree[worktreeId]).toEqual( + state.layoutByWorktree[worktreeId] + ) expect(mocks.begin).toHaveBeenCalledOnce() expect(mocks.launchCli).not.toHaveBeenCalled() expect(mocks.info).not.toHaveBeenCalled() diff --git a/src/renderer/src/lib/agent-launch-destination.ts b/src/renderer/src/lib/agent-launch-destination.ts new file mode 100644 index 000000000000..187c7a5aad06 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-destination.ts @@ -0,0 +1,11 @@ +import { useAppStore } from '@/store' + +/** A named pane is required; only launches without one may use default placement. */ +export function isAgentLaunchDestinationLive(worktreeId: string, groupId?: string): boolean { + return ( + groupId === undefined || + (useAppStore.getState().groupsByWorktree[worktreeId] ?? []).some( + (group) => group.id === groupId + ) + ) +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts b/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts index 2cdb6dbe5012..91b6c38bd5ec 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts @@ -263,11 +263,12 @@ describe('a structured chat launch on this machine', () => { it("opens a new agent tab's terminal with the typed prompt when the caller names none", async () => { mocks.createSupport.mockResolvedValue({ supported: false }) mocks.launchAgentInNewTab.mockReturnValue({ surface: { kind: 'local-terminal', tabId: 't' } }) + const groupId = useAppStore.getState().ensureWorktreeRootGroup(WORKTREE) const launch = beginStructuredAgentSessionProvisionalLaunch({ plan: localPlan(), hooks: {}, - targetGroupId: 'group-1', + targetGroupId: groupId, declinedTerminal: { agentArgs: '--model opus' } }) @@ -276,7 +277,7 @@ describe('a structured chat launch on this machine', () => { expect.objectContaining({ agent: 'claude', worktreeId: WORKTREE, - groupId: 'group-1', + groupId, prompt: 'fix the flaky test', agentArgs: '--model opus', agentSessionLaunchPlan: expect.objectContaining({ route: 'terminal-tui' }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-admission.ts b/src/renderer/src/lib/structured-agent-session-launch-admission.ts index 2e7b9bcfe574..0bf781dcff79 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-admission.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-admission.ts @@ -23,6 +23,7 @@ import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-sess import type { LaunchAgentInNewTabArgs } from '@/lib/launch-agent-in-new-tab' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import { isAgentLaunchDestinationLive } from '@/lib/agent-launch-destination' /** What a host's "no" opens instead: the terminal the launch would have opened had it known. */ export type StructuredLaunchTerminal = { @@ -137,6 +138,7 @@ export function beginHostAdmittedStructuredLaunch(args: { worktreeId: string executionHostId: ExecutionHostId target: RuntimeClientTarget + targetGroupId?: string /** Commits the admitted chat: the local launch path, told which host admitted it and the saved * selection that host said create will seed. */ openAdmitted: (seedOptions?: Readonly>) => AdmittedLaunch | null @@ -151,7 +153,10 @@ export function beginHostAdmittedStructuredLaunch(args: { const paired = args.target.kind === 'environment' let cancelled = false let admitted: AdmittedLaunch | null = null - const isCancelled = (): boolean => cancelled || args.hooks.signal?.aborted === true + const isCancelled = (): boolean => + cancelled || + args.hooks.signal?.aborted === true || + !isAgentLaunchDestinationLive(args.worktreeId, args.targetGroupId) let resolveDelivery: (result: StructuredPromptDeliveryResult) => void = () => undefined const deliversPrompt = Boolean(plan.prompt?.trim()) && plan.promptDelivery !== 'draft' const promptDeliveryResult = deliversPrompt 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 9acc430be544..6b19d9b1196b 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 @@ -14,6 +14,7 @@ vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ unifiedTabsByWorktree: {}, + groupsByWorktree: { 'wt-1': [{ id: 'group-2' }] }, activeGroupIdByWorktree: {}, createUnifiedTab: mocks.createUnifiedTab, setActiveTabType: vi.fn() 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 a9937ee16326..037c8adb8015 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 { isAgentLaunchDestinationLive } from '@/lib/agent-launch-destination' import type { NativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' type LocalProvisionalLaunch = StructuredAgentLaunchHandle & { tab: Tab } @@ -95,6 +96,7 @@ type ProvisionalLaunchArgs = { hooks: StructuredAgentLaunchHooks focusIntent?: NativeChatComposerFocusIntent target?: AgentSessionLaunchTarget + /** A named pane must survive admission; omission keeps default placement. */ targetGroupId?: string activate?: boolean /** Lets workspace flows reveal before the chat's tab is owned: before its host is asked, when no @@ -137,6 +139,10 @@ export function beginStructuredAgentSessionProvisionalLaunch( return launch } try { + if (worktreeId && !isAgentLaunchDestinationLive(worktreeId, args.targetGroupId)) { + cancelIfNoTab() + return null + } const owner = worktreeId ? structuredLaunchOwner(args.plan, worktreeId, args.target) : null if (owner && worktreeId && args.beforeOpen?.() === false) { cancelIfNoTab() @@ -152,6 +158,7 @@ export function beginStructuredAgentSessionProvisionalLaunch( worktreeId, executionHostId: owner.executionHostId, target: owner.target, + targetGroupId: args.targetGroupId, openAdmitted: (seedOptions) => open( beginLocalProvisionalLaunch({ From c2fcf4bf5f7101f03d3c6e7347654488dd749f00 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:27:32 -0700 Subject: [PATCH 13/42] Model live destination groups in agent launch test fixture --- src/renderer/src/lib/agent-launch-funnel-test-harness.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/renderer/src/lib/agent-launch-funnel-test-harness.ts b/src/renderer/src/lib/agent-launch-funnel-test-harness.ts index ec26a151a58d..d96f5e364db0 100644 --- a/src/renderer/src/lib/agent-launch-funnel-test-harness.ts +++ b/src/renderer/src/lib/agent-launch-funnel-test-harness.ts @@ -40,6 +40,7 @@ export function createLaunchFunnelStore(): { repos: { id: string; connectionId: string | null; path: string }[] allWorktrees: ReturnType tabsByWorktree: Record + groupsByWorktree: Record openFiles: { id: string; worktreeId: string }[] browserTabsByWorktree: Record tabBarOrderByWorktree: Record @@ -59,6 +60,7 @@ export function createLaunchFunnelStore(): { repos: [], allWorktrees: vi.fn(() => []), tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, + groupsByWorktree: { 'wt-1': [{ id: 'group-1' }] }, openFiles: [], browserTabsByWorktree: {}, tabBarOrderByWorktree: {}, @@ -109,6 +111,7 @@ export function resetLaunchFunnelStore( store.settings = launchFunnelSettings(settingsOverrides) store.repos = [] store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } + store.groupsByWorktree = { 'wt-1': [{ id: 'group-1' }] } store.openFiles = [] store.browserTabsByWorktree = {} store.tabBarOrderByWorktree = {} From d56006f427443fbeae6689d9bf95f900ae502114 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:40:20 -0700 Subject: [PATCH 14/42] Capture split chat focus intent at the user action --- .../src/components/native-chat/native-chat-layout-actions.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/renderer/src/components/native-chat/native-chat-layout-actions.ts b/src/renderer/src/components/native-chat/native-chat-layout-actions.ts index abaf933d3780..2a5b8988059a 100644 --- a/src/renderer/src/components/native-chat/native-chat-layout-actions.ts +++ b/src/renderer/src/components/native-chat/native-chat-layout-actions.ts @@ -5,6 +5,7 @@ import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent- import { adoptAgentSessionLaunchVerdict } from '@/lib/agent-session-launch-plan' import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' +import { captureNativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' import { requestActiveTerminalPaneSplit } from '@/components/tab-bar/request-active-terminal-pane-split' import type { NativeChatSplitDirection } from './native-chat-split-shortcut' @@ -82,8 +83,10 @@ export function runNativeChatSplitTarget( if (!executionHostId || !structuredAgentSessionTargetForHost(executionHostId)) { return false } + const focusIntent = captureNativeChatComposerFocusIntent() const groupId = state.createEmptySplitGroup(source.worktreeId, target.groupId, direction) if (!groupId) { + focusIntent.cancel() return false } try { @@ -96,6 +99,7 @@ export function runNativeChatSplitTarget( executionHostId }), hooks: {}, + focusIntent, onHostDeclined: null, targetGroupId: groupId }) From 72dfe8bbd2ee25164e1ab4c5209668decdba09c6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:47:44 -0700 Subject: [PATCH 15/42] feat(chat): add the terminal pane hover action cluster --- .../native-chat/NativeChatPaneHeader.tsx | 66 +++++++++++++++++++ .../NativeChatResumeInCliButton.tsx | 62 +++++++++++++++++ .../NativeChatStructuredSession.tsx | 8 ++- .../use-structured-chat-pane-actions.ts | 26 ++++++++ .../TabSessionSurfaceSwitchMenuItems.tsx | 36 +++++----- src/renderer/src/i18n/locales/en.json | 1 + 6 files changed, 182 insertions(+), 17 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-chat-pane-actions.ts diff --git a/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx b/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx new file mode 100644 index 000000000000..09f31fd19c32 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx @@ -0,0 +1,66 @@ +import { SquareSplitVertical, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' +import { agentForksConversations } from '../../../../shared/agent-resume-argv' +import type { NativeChatTabSession } from '../tab-bar/tab-session-history-switch' +import { useSystemPrefersDark } from '../terminal-pane/use-system-prefers-dark' +import { useStructuredChatPaneActions } from './use-structured-chat-pane-actions' +import { NativeChatResumeInCliButton } from './NativeChatResumeInCliButton' + +const HEADER_STYLE: React.CSSProperties & Record<'--orca-pane-title-bg', string> = { + '--orca-pane-title-bg': 'var(--chat-canvas)' +} + +export function NativeChatPaneHeader({ tabId, worktreeId, groupId, nativeChat, focused, colorScheme }: { + tabId: string + worktreeId: string + groupId: string + nativeChat: NativeChatTabSession + focused: boolean + colorScheme: React.CSSProperties['colorScheme'] +}): React.JSX.Element { + const actions = useStructuredChatPaneActions(tabId, worktreeId, groupId) + const theme = useAppStore((state) => state.settings?.theme) + const systemDark = useSystemPrefersDark() + const light = colorScheme ? colorScheme === 'light' : theme === 'light' || (theme === 'system' && !systemDark) + const split = translate('components.native-chat.splitRight', 'Split Chat Right') + const splitShortcut = useShortcutKeyDetails('terminal.splitRight') + const close = translate('auto.components.terminal.pane.TerminalContextMenu.8c17d6786d', 'Close Pane') + return ( +
+
+ {nativeChat.agent && agentForksConversations(nativeChat.agent) ? ( + + ) : null} + + + + + + {split} + + + {actions.canClosePane ? ( + + + + + {close} + + ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx new file mode 100644 index 000000000000..82b9f0bf5a0d --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react' +import { SquareTerminal } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useAppStore } from '@/store' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { translate } from '@/i18n/i18n' +import { useTabSessionLaunchActions } from '../tab-bar/TabSessionSurfaceSwitchMenuItems' +import { + lookupTabSessionHistoryRow, resolveTabSessionHistorySubject, resolveTabSessionSwitch, + type NativeChatTabSession +} from '../tab-bar/tab-session-history-switch' + +export function NativeChatResumeInCliButton({ tabId, worktreeId, nativeChat }: { + tabId: string + worktreeId: string + nativeChat: NativeChatTabSession +}): React.JSX.Element { + const [pending, setPending] = useState(false) + const launch = useTabSessionLaunchActions(worktreeId) + const label = translate('auto.components.right.sidebar.AiVaultSessionRow.resumeInNewCli', 'Resume in New CLI') + const unavailable = () => toast.error(translate( + 'auto.components.right.sidebar.AiVaultPanel.resumeInNewCliUnavailable', + 'This session cannot be opened in the CLI.' + )) + const resume = async (): Promise => { + const isOpen = () => useAppStore.getState().unifiedTabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId) ?? false + const subject = resolveTabSessionHistorySubject(useAppStore.getState(), { + tab: { id: tabId, worktreeId }, nativeChat + }) + if (!subject) { + unavailable() + return + } + const row = await lookupTabSessionHistoryRow(subject, (args) => window.api.aiVault.listSessions(args), { + requestToken: createBrowserUuid(), isCancelled: () => !isOpen(), userRequested: true + }) + if (!isOpen()) { + return + } + const move = row ? resolveTabSessionSwitch(useAppStore.getState(), row, subject) : null + if (row && move?.action === 'resume-in-new-cli') { + launch.handleResumeInNewCli(row, move.worktreeId) + } else { unavailable() } + } + return ( + + + + + {label} + + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 8fd1c62768e4..bcf552485a18 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -47,6 +47,7 @@ import { useAppStore } from '../../store' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { useNativeChatStructuredComposerTransport } from './use-native-chat-structured-composer-transport' import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner' +import { NativeChatPaneHeader } from './NativeChatPaneHeader' import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice' import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices' import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection' @@ -272,11 +273,16 @@ export function NativeChatStructuredSession( onContextMenuCapture={paneCommands.onContextMenuCapture} className={cn( NATIVE_CHAT_APPEARANCE_ROOT_CLASS, - 'flex h-full min-h-0 w-full flex-col focus:outline-none' + 'relative flex h-full min-h-0 w-full flex-col focus:outline-none' )} style={appearanceStyle} data-native-chat-scheme={appearanceStyle.colorScheme} > + {ownerWorktreeId && props.groupId && !props.contextMenuActions ? ( + + ) : null}
{find.isOpen ? : null} {viewState.kind === 'loading' || (viewState.kind === 'error' && !readFailure) ? ( diff --git a/src/renderer/src/components/native-chat/use-structured-chat-pane-actions.ts b/src/renderer/src/components/native-chat/use-structured-chat-pane-actions.ts new file mode 100644 index 000000000000..8577e74c2d90 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-chat-pane-actions.ts @@ -0,0 +1,26 @@ +import { useMemo } from 'react' +import { useAppStore } from '@/store' +import { useTabGroupTabCloseCommands } from '../tab-group/useTabGroupTabCloseCommands' +import { useTabGroupCloseScopeCommands } from '../tab-group/useTabGroupCloseScopeCommands' +import { runNativeChatSplitTarget } from './native-chat-layout-actions' +import type { Tab, TabGroup } from '../../../../shared/tab-types' + +const EMPTY_TABS: Tab[] = [] +const EMPTY_GROUPS: TabGroup[] = [] + +export function useStructuredChatPaneActions(tabId: string, worktreeId: string, groupId: string) { + const tabs = useAppStore((state) => state.unifiedTabsByWorktree[worktreeId] ?? EMPTY_TABS) + const groups = useAppStore((state) => state.groupsByWorktree[worktreeId] ?? EMPTY_GROUPS) + const groupTabs = useMemo(() => tabs.filter((tab) => tab.groupId === groupId), [tabs, groupId]) + const { closeItem, closeMany } = useTabGroupTabCloseCommands({ worktreeId }) + const { closeGroup } = useTabGroupCloseScopeCommands({ + worktreeId, groupId, groupTabs, group: groups.find((group) => group.id === groupId) ?? null, + closeItem, closeMany + }) + return { + onSplitRight: () => runNativeChatSplitTarget({ kind: 'workspace-tab', unifiedTabId: tabId, groupId }, 'right'), + onSplitDown: () => runNativeChatSplitTarget({ kind: 'workspace-tab', unifiedTabId: tabId, groupId }, 'down'), + canClosePane: groups.length > 1, + onClosePane: closeGroup + } +} diff --git a/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.tsx b/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.tsx index 5daf01e0399d..464190205579 100644 --- a/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.tsx +++ b/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.tsx @@ -79,22 +79,7 @@ export function TabSessionSurfaceSwitchMenuItems({ leadingSeparator: boolean }): React.JSX.Element | null { const resolved = useTabSessionSwitch(tab, nativeChat) - const targetState = useAppStore( - useShallow((state) => ({ - projects: state.projects, - settings: state.settings, - folderWorkspaces: state.folderWorkspaces, - projectGroups: state.projectGroups, - repos: state.repos, - worktreesByRepo: state.worktreesByRepo - })) - ) - const launchActions = useAiVaultSessionLaunchActions({ - activeWorktree: null, - activeWorktreeId: tab.worktreeId, - targetState, - agentCmdOverrides: targetState.settings?.agentCmdOverrides - }) + const launchActions = useTabSessionLaunchActions(tab.worktreeId) if (!resolved) { return null } @@ -119,3 +104,22 @@ export function TabSessionSurfaceSwitchMenuItems({ ) } + +export function useTabSessionLaunchActions(worktreeId: string) { + const targetState = useAppStore( + useShallow((state) => ({ + projects: state.projects, + settings: state.settings, + folderWorkspaces: state.folderWorkspaces, + projectGroups: state.projectGroups, + repos: state.repos, + worktreesByRepo: state.worktreesByRepo + })) + ) + return useAiVaultSessionLaunchActions({ + activeWorktree: null, + activeWorktreeId: worktreeId, + targetState, + agentCmdOverrides: targetState.settings?.agentCmdOverrides + }) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 738942905e81..df8f79429a4a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18161,6 +18161,7 @@ "empty": "No users found" }, "native-chat": { + "splitRight": "Split Chat Right", "reasoning": "Reasoning", "failureWords": { "theAgent": "The agent", From 2ceba76722bc06744e2c550a66fe2d7a138c7f79 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:56:25 -0700 Subject: [PATCH 16/42] fix(chat): keep pane controls above the conversation surface --- .../src/components/native-chat/NativeChatPaneHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx b/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx index 09f31fd19c32..228c1b08d69c 100644 --- a/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx @@ -31,7 +31,7 @@ export function NativeChatPaneHeader({ tabId, worktreeId, groupId, nativeChat, f const splitShortcut = useShortcutKeyDetails('terminal.splitRight') const close = translate('auto.components.terminal.pane.TerminalContextMenu.8c17d6786d', 'Close Pane') return ( -
From 00bf68ca36df7cc1c210faca1a380f538cef30c9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:59:40 -0700 Subject: [PATCH 17/42] refactor(chat): share the tab resume launch hook and format pane controls --- .../native-chat/NativeChatPaneHeader.tsx | 65 +++++++++++++---- .../NativeChatResumeInCliButton.tsx | 72 ++++++++++++++----- .../NativeChatStructuredSession.tsx | 11 ++- .../use-structured-chat-pane-actions.ts | 14 ++-- .../TabSessionSurfaceSwitchMenuItems.tsx | 22 +----- .../tab-bar/use-tab-session-launch-actions.ts | 22 ++++++ 6 files changed, 147 insertions(+), 59 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/use-tab-session-launch-actions.ts diff --git a/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx b/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx index 228c1b08d69c..d51affbfc3b5 100644 --- a/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx @@ -15,7 +15,14 @@ const HEADER_STYLE: React.CSSProperties & Record<'--orca-pane-title-bg', string> '--orca-pane-title-bg': 'var(--chat-canvas)' } -export function NativeChatPaneHeader({ tabId, worktreeId, groupId, nativeChat, focused, colorScheme }: { +export function NativeChatPaneHeader({ + tabId, + worktreeId, + groupId, + nativeChat, + focused, + colorScheme +}: { tabId: string worktreeId: string groupId: string @@ -26,38 +33,72 @@ export function NativeChatPaneHeader({ tabId, worktreeId, groupId, nativeChat, f const actions = useStructuredChatPaneActions(tabId, worktreeId, groupId) const theme = useAppStore((state) => state.settings?.theme) const systemDark = useSystemPrefersDark() - const light = colorScheme ? colorScheme === 'light' : theme === 'light' || (theme === 'system' && !systemDark) + const light = colorScheme + ? colorScheme === 'light' + : theme === 'light' || (theme === 'system' && !systemDark) const split = translate('components.native-chat.splitRight', 'Split Chat Right') const splitShortcut = useShortcutKeyDetails('terminal.splitRight') - const close = translate('auto.components.terminal.pane.TerminalContextMenu.8c17d6786d', 'Close Pane') + const close = translate( + 'auto.components.terminal.pane.TerminalContextMenu.8c17d6786d', + 'Close Pane' + ) return ( -
+ style={HEADER_STYLE} + >
{nativeChat.agent && agentForksConversations(nativeChat.agent) ? ( - + ) : null} - - {split} + {split} + {actions.canClosePane ? ( - - {close} + + {close} + ) : null}
diff --git a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx index 82b9f0bf5a0d..08a4cd87d6f5 100644 --- a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx @@ -6,57 +6,91 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { useAppStore } from '@/store' import { createBrowserUuid } from '@/lib/browser-uuid' import { translate } from '@/i18n/i18n' -import { useTabSessionLaunchActions } from '../tab-bar/TabSessionSurfaceSwitchMenuItems' +import { useTabSessionLaunchActions } from '../tab-bar/use-tab-session-launch-actions' import { - lookupTabSessionHistoryRow, resolveTabSessionHistorySubject, resolveTabSessionSwitch, + lookupTabSessionHistoryRow, + resolveTabSessionHistorySubject, + resolveTabSessionSwitch, type NativeChatTabSession } from '../tab-bar/tab-session-history-switch' -export function NativeChatResumeInCliButton({ tabId, worktreeId, nativeChat }: { +export function NativeChatResumeInCliButton({ + tabId, + worktreeId, + nativeChat +}: { tabId: string worktreeId: string nativeChat: NativeChatTabSession }): React.JSX.Element { const [pending, setPending] = useState(false) const launch = useTabSessionLaunchActions(worktreeId) - const label = translate('auto.components.right.sidebar.AiVaultSessionRow.resumeInNewCli', 'Resume in New CLI') - const unavailable = () => toast.error(translate( - 'auto.components.right.sidebar.AiVaultPanel.resumeInNewCliUnavailable', - 'This session cannot be opened in the CLI.' - )) + const label = translate( + 'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewCli', + 'Resume in New CLI' + ) + const unavailable = () => + toast.error( + translate( + 'auto.components.right.sidebar.AiVaultPanel.resumeInNewCliUnavailable', + 'This session cannot be opened in the CLI.' + ) + ) const resume = async (): Promise => { - const isOpen = () => useAppStore.getState().unifiedTabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId) ?? false + const isOpen = () => + useAppStore.getState().unifiedTabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId) ?? + false const subject = resolveTabSessionHistorySubject(useAppStore.getState(), { - tab: { id: tabId, worktreeId }, nativeChat + tab: { id: tabId, worktreeId }, + nativeChat }) if (!subject) { unavailable() return } - const row = await lookupTabSessionHistoryRow(subject, (args) => window.api.aiVault.listSessions(args), { - requestToken: createBrowserUuid(), isCancelled: () => !isOpen(), userRequested: true - }) + const row = await lookupTabSessionHistoryRow( + subject, + (args) => window.api.aiVault.listSessions(args), + { + requestToken: createBrowserUuid(), + isCancelled: () => !isOpen(), + userRequested: true + } + ) if (!isOpen()) { return } const move = row ? resolveTabSessionSwitch(useAppStore.getState(), row, subject) : null if (row && move?.action === 'resume-in-new-cli') { launch.handleResumeInNewCli(row, move.worktreeId) - } else { unavailable() } + } else { + unavailable() + } } return ( - - {label} + + {label} + ) } diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index bcf552485a18..452fce66c2cc 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -279,9 +279,14 @@ export function NativeChatStructuredSession( data-native-chat-scheme={appearanceStyle.colorScheme} > {ownerWorktreeId && props.groupId && !props.contextMenuActions ? ( - + ) : null}
{find.isOpen ? : null} diff --git a/src/renderer/src/components/native-chat/use-structured-chat-pane-actions.ts b/src/renderer/src/components/native-chat/use-structured-chat-pane-actions.ts index 8577e74c2d90..64218e67c826 100644 --- a/src/renderer/src/components/native-chat/use-structured-chat-pane-actions.ts +++ b/src/renderer/src/components/native-chat/use-structured-chat-pane-actions.ts @@ -14,12 +14,18 @@ export function useStructuredChatPaneActions(tabId: string, worktreeId: string, const groupTabs = useMemo(() => tabs.filter((tab) => tab.groupId === groupId), [tabs, groupId]) const { closeItem, closeMany } = useTabGroupTabCloseCommands({ worktreeId }) const { closeGroup } = useTabGroupCloseScopeCommands({ - worktreeId, groupId, groupTabs, group: groups.find((group) => group.id === groupId) ?? null, - closeItem, closeMany + worktreeId, + groupId, + groupTabs, + group: groups.find((group) => group.id === groupId) ?? null, + closeItem, + closeMany }) return { - onSplitRight: () => runNativeChatSplitTarget({ kind: 'workspace-tab', unifiedTabId: tabId, groupId }, 'right'), - onSplitDown: () => runNativeChatSplitTarget({ kind: 'workspace-tab', unifiedTabId: tabId, groupId }, 'down'), + onSplitRight: () => + runNativeChatSplitTarget({ kind: 'workspace-tab', unifiedTabId: tabId, groupId }, 'right'), + onSplitDown: () => + runNativeChatSplitTarget({ kind: 'workspace-tab', unifiedTabId: tabId, groupId }, 'down'), canClosePane: groups.length > 1, onClosePane: closeGroup } diff --git a/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.tsx b/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.tsx index 464190205579..9f337df2c205 100644 --- a/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.tsx +++ b/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.tsx @@ -1,12 +1,11 @@ import { useEffect, useState } from 'react' -import { useShallow } from 'zustand/react/shallow' import { DropdownMenuSeparator } from '@/components/ui/dropdown-menu' import { createBrowserUuid } from '@/lib/browser-uuid' import { useAppStore } from '../../store' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { AiVaultSessionSurfaceSwitchMenuItems } from '../right-sidebar/AiVaultSessionSurfaceSwitchMenuItems' -import { useAiVaultSessionLaunchActions } from '../right-sidebar/ai-vault-session-launch-actions' +import { useTabSessionLaunchActions } from './use-tab-session-launch-actions' import { lookupTabSessionHistoryRow, resolveTabSessionHistorySubject, @@ -104,22 +103,3 @@ export function TabSessionSurfaceSwitchMenuItems({ ) } - -export function useTabSessionLaunchActions(worktreeId: string) { - const targetState = useAppStore( - useShallow((state) => ({ - projects: state.projects, - settings: state.settings, - folderWorkspaces: state.folderWorkspaces, - projectGroups: state.projectGroups, - repos: state.repos, - worktreesByRepo: state.worktreesByRepo - })) - ) - return useAiVaultSessionLaunchActions({ - activeWorktree: null, - activeWorktreeId: worktreeId, - targetState, - agentCmdOverrides: targetState.settings?.agentCmdOverrides - }) -} diff --git a/src/renderer/src/components/tab-bar/use-tab-session-launch-actions.ts b/src/renderer/src/components/tab-bar/use-tab-session-launch-actions.ts new file mode 100644 index 000000000000..3dc9fe0d6a09 --- /dev/null +++ b/src/renderer/src/components/tab-bar/use-tab-session-launch-actions.ts @@ -0,0 +1,22 @@ +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '@/store' +import { useAiVaultSessionLaunchActions } from '../right-sidebar/ai-vault-session-launch-actions' + +export function useTabSessionLaunchActions(worktreeId: string) { + const targetState = useAppStore( + useShallow((state) => ({ + projects: state.projects, + settings: state.settings, + folderWorkspaces: state.folderWorkspaces, + projectGroups: state.projectGroups, + repos: state.repos, + worktreesByRepo: state.worktreesByRepo + })) + ) + return useAiVaultSessionLaunchActions({ + activeWorktree: null, + activeWorktreeId: worktreeId, + targetState, + agentCmdOverrides: targetState.settings?.agentCmdOverrides + }) +} From 02dbb1e52db72db9c153207fdbcab85b1d085e64 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:08:30 -0700 Subject: [PATCH 18/42] style(chat): document reuse of the terminal header button styles --- .../src/components/native-chat/NativeChatPaneHeader.tsx | 2 ++ .../src/components/native-chat/NativeChatResumeInCliButton.tsx | 1 + 2 files changed, 3 insertions(+) diff --git a/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx b/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx index d51affbfc3b5..5e7087daf422 100644 --- a/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx @@ -64,6 +64,7 @@ export function NativeChatPaneHeader({ type="button" variant="ghost" size="icon-xs" + // oxlint-disable-next-line shadcn/no-restyle -- Reuses the terminal header control geometry and hover/active reveal. className="pane-title-split-trigger" aria-label={split} onClick={(event) => { @@ -86,6 +87,7 @@ export function NativeChatPaneHeader({ type="button" variant="ghost" size="icon-xs" + // oxlint-disable-next-line shadcn/no-restyle -- Reuses the terminal header control geometry and hover/active reveal. className="pane-title-close" aria-label={close} onClick={(event) => { diff --git a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx index 08a4cd87d6f5..21f24782e230 100644 --- a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx @@ -74,6 +74,7 @@ export function NativeChatResumeInCliButton({ type="button" variant="ghost" size="icon-xs" + // oxlint-disable-next-line shadcn/no-restyle -- Reuses the terminal header control geometry and hover/active reveal. className="pane-title-split-trigger" aria-label={label} disabled={pending} From 7ee4392691d6ecc257116ccb0e9e2ae61d9eb9f2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:10:01 -0700 Subject: [PATCH 19/42] refactor(ui): share the pane title button variants between terminals and chats --- .../native-chat/NativeChatPaneHeader.tsx | 8 ++------ .../native-chat/NativeChatResumeInCliButton.tsx | 4 +--- .../terminal-pane/TerminalPaneHeaderOverlay.tsx | 16 +++++----------- .../TerminalPaneResumeInChatButton.tsx | 4 +--- src/renderer/src/components/ui/button.tsx | 4 +++- 5 files changed, 12 insertions(+), 24 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx b/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx index 5e7087daf422..fabfbe519add 100644 --- a/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx @@ -62,10 +62,8 @@ export function NativeChatPaneHeader({ - {close} + {actions.closeLabel} ) : null} diff --git a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx index 6ed44cf56089..5bf78bf48d1a 100644 --- a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx @@ -17,10 +17,12 @@ import { export function NativeChatResumeInCliButton({ tabId, worktreeId, + groupId, nativeChat }: { tabId: string worktreeId: string + groupId: string nativeChat: NativeChatTabSession }): React.JSX.Element { const [pending, setPending] = useState(false) @@ -37,6 +39,7 @@ export function NativeChatResumeInCliButton({ ) ) const resume = async (): Promise => { + const targetGroupId = groupId const isOpen = () => useAppStore.getState().unifiedTabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId) ?? false @@ -62,7 +65,7 @@ export function NativeChatResumeInCliButton({ } const move = row ? resolveTabSessionSwitch(useAppStore.getState(), row, subject) : null if (row && move?.action === 'resume-in-new-cli') { - launch.handleResumeInNewCli(row, move.worktreeId) + launch.handleResumeInNewCli(row, move.worktreeId, targetGroupId) } else { unavailable() } diff --git a/src/renderer/src/components/native-chat/use-structured-chat-pane-actions.ts b/src/renderer/src/components/native-chat/use-structured-chat-pane-actions.ts index 64218e67c826..5cc935683a98 100644 --- a/src/renderer/src/components/native-chat/use-structured-chat-pane-actions.ts +++ b/src/renderer/src/components/native-chat/use-structured-chat-pane-actions.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' import { useTabGroupTabCloseCommands } from '../tab-group/useTabGroupTabCloseCommands' -import { useTabGroupCloseScopeCommands } from '../tab-group/useTabGroupCloseScopeCommands' import { runNativeChatSplitTarget } from './native-chat-layout-actions' import type { Tab, TabGroup } from '../../../../shared/tab-types' @@ -12,21 +12,18 @@ export function useStructuredChatPaneActions(tabId: string, worktreeId: string, const tabs = useAppStore((state) => state.unifiedTabsByWorktree[worktreeId] ?? EMPTY_TABS) const groups = useAppStore((state) => state.groupsByWorktree[worktreeId] ?? EMPTY_GROUPS) const groupTabs = useMemo(() => tabs.filter((tab) => tab.groupId === groupId), [tabs, groupId]) - const { closeItem, closeMany } = useTabGroupTabCloseCommands({ worktreeId }) - const { closeGroup } = useTabGroupCloseScopeCommands({ - worktreeId, - groupId, - groupTabs, - group: groups.find((group) => group.id === groupId) ?? null, - closeItem, - closeMany - }) + const { closeItem } = useTabGroupTabCloseCommands({ worktreeId }) + const closeLabel = + groupTabs.length > 1 + ? translate('auto.components.tab.bar.SortableTab.95db5f2f7d', 'Close tab') + : translate('auto.components.terminal.pane.TerminalContextMenu.8c17d6786d', 'Close Pane') return { onSplitRight: () => runNativeChatSplitTarget({ kind: 'workspace-tab', unifiedTabId: tabId, groupId }, 'right'), onSplitDown: () => runNativeChatSplitTarget({ kind: 'workspace-tab', unifiedTabId: tabId, groupId }, 'down'), canClosePane: groups.length > 1, - onClosePane: closeGroup + closeLabel, + onClosePane: () => closeItem(tabId) } } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts index 36b9ba0dc510..63c2aacd0288 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts @@ -153,6 +153,30 @@ describe('Resume in New CLI launch', () => { expect(mocks.toastError).not.toHaveBeenCalled() }) + it('keeps the chosen chat group while fork preparation and focus changes are pending', async () => { + let finishFork: ((value: { session: AiVaultSession; copied: boolean }) => void) | undefined + mocks.prepareAiVaultSessionForFork.mockReturnValue( + new Promise((resolve) => { + finishFork = resolve + }) + ) + renderActions().handleResumeInNewCli(owned, 'worktree-1', 'chat-group') + mocks.getState.mockReturnValue({ + activeWorktreeId: 'worktree-1', + activeGroupIdByWorktree: { 'worktree-1': 'other-group' }, + settings: {} + }) + finishFork?.({ session: owned, copied: false }) + + await waitFor(() => expect(mocks.launchAiVaultSessionInNewTab).toHaveBeenCalledOnce()) + expect(mocks.launchAiVaultSessionInNewTab).toHaveBeenCalledWith({ + agent: 'claude', + worktreeId: 'worktree-1', + targetGroupId: 'chat-group', + ...FORK_STARTUP + }) + }) + it.each(['omp', 'opencode2'] as const)( 'opens the %s copy with the actual CLI in the chosen workspace', async (agent) => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index d31800c40fa8..e2443ec3f463 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -104,7 +104,8 @@ export function useAiVaultSessionLaunchActions({ session: AiVaultSession, targetWorktreeId: string | undefined, prepareStartup: (worktreeId: string) => Promise, - describeFailure: (message: string) => string = (message) => message + describeFailure: (message: string) => string = (message) => message, + targetGroupId?: string ): void => { const targetId = resolveAiVaultSessionLaunchTargetOrNotify({ sessionFilePath: session.filePath, @@ -121,6 +122,7 @@ export function useAiVaultSessionLaunchActions({ const launchResult = launchAiVaultSessionInNewTab({ agent: session.agent, worktreeId: targetId.worktreeId, + ...(targetGroupId ? { targetGroupId } : {}), ...startup }) if (launchResult.tabId === null) { @@ -176,7 +178,7 @@ export function useAiVaultSessionLaunchActions({ // Native chat keeps the conversation it owns; the terminal gets a copy (see the fork builder). const handleResumeInNewCli = useCallback( - (session: AiVaultSession, targetWorktreeId: string): void => { + (session: AiVaultSession, targetWorktreeId: string, targetGroupId?: string): void => { const prepareStartup = async (worktreeId: string): Promise => { assertAiVaultCopiedSessionLaunchAllowed({ state: useAppStore.getState(), @@ -213,7 +215,13 @@ export function useAiVaultSessionLaunchActions({ } return startup } - launchInTerminal(session, targetWorktreeId, prepareStartup, describeAiVaultCliForkFailure) + launchInTerminal( + session, + targetWorktreeId, + prepareStartup, + describeAiVaultCliForkFailure, + targetGroupId + ) }, [agentCmdOverrides, launchInTerminal] ) From 591e3938d4d7dacce42759cd2af7667031de6bf5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:31:01 -0700 Subject: [PATCH 21/42] fix(chat): abandon Resume when its destination pane closes --- .../NativeChatResumeInCliButton.test.tsx | 86 ++++++++++++++ .../NativeChatResumeInCliButton.tsx | 10 +- .../ai-vault-session-launch-actions.test.ts | 1 + .../ai-vault-session-launch-actions.ts | 4 + .../tab-group/AiVaultSessionDropLayer.tsx | 3 + src/renderer/src/i18n/locales/en.json | 3 +- .../src/lib/launch-agent-web-host-tab.ts | 3 + .../src/lib/launch-ai-vault-session.test.ts | 21 ++++ .../src/lib/launch-ai-vault-session.ts | 7 ++ .../web-runtime-session-tab-lifecycle.ts | 21 +++- ...me-session-terminal-host-authority.test.ts | 57 ++++++++++ .../src/runtime/web-runtime-session-types.ts | 3 + .../web-runtime-terminal-create-operation.ts | 105 +++++++----------- .../runtime/web-runtime-terminal-creation.ts | 4 +- .../web-runtime-terminal-legacy-create.ts | 40 +++++++ ...me-terminal-lost-destination-retirement.ts | 62 +++++++++++ ...b-runtime-terminal-placement-settlement.ts | 59 +++++++++- 17 files changed, 412 insertions(+), 77 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatResumeInCliButton.test.tsx create mode 100644 src/renderer/src/runtime/web-runtime-terminal-legacy-create.ts create mode 100644 src/renderer/src/runtime/web-runtime-terminal-lost-destination-retirement.ts diff --git a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.test.tsx b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.test.tsx new file mode 100644 index 000000000000..39e330514c48 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.test.tsx @@ -0,0 +1,86 @@ +// @vitest-environment happy-dom +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' + +const mocks = vi.hoisted(() => ({ + getState: vi.fn(), + lookup: vi.fn(), + resolveSubject: vi.fn(), + resolveSwitch: vi.fn(), + resume: vi.fn(), + toastError: vi.fn() +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: mocks.getState } })) +vi.mock('@/lib/browser-uuid', () => ({ createBrowserUuid: () => 'request-1' })) +vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) +vi.mock('../tab-bar/use-tab-session-launch-actions', () => ({ + useTabSessionLaunchActions: () => ({ handleResumeInNewCli: mocks.resume }) +})) +vi.mock('../tab-bar/tab-session-history-switch', () => ({ + lookupTabSessionHistoryRow: mocks.lookup, + resolveTabSessionHistorySubject: mocks.resolveSubject, + resolveTabSessionSwitch: mocks.resolveSwitch +})) + +import { NativeChatResumeInCliButton } from './NativeChatResumeInCliButton' + +beforeEach(() => { + vi.clearAllMocks() + mocks.resolveSubject.mockReturnValue({ workspaceId: 'worktree-1', kind: 'chat' }) + mocks.resolveSwitch.mockReturnValue({ action: 'resume-in-new-cli', worktreeId: 'worktree-1' }) +}) + +it.each([ + { groupSurvives: true, launches: 1 }, + { groupSurvives: false, launches: 0 } +])('uses the clicked pane during history lookup (survives=$groupSurvives)', async ({ + groupSurvives, + launches +}) => { + const state = { + groupsByWorktree: { 'worktree-1': [{ id: 'chat-group' }] }, + unifiedTabsByWorktree: { 'worktree-1': [{ id: 'chat-tab' }, { id: 'sibling-tab' }] } + } + mocks.getState.mockReturnValue(state) + let releaseHistory: () => void = () => undefined + const history = new Promise((resolve) => { + releaseHistory = resolve + }) + mocks.lookup.mockImplementation( + async (_subject: unknown, _list: unknown, options: { isCancelled: () => boolean }) => { + await history + expect(options.isCancelled()).toBe(!groupSurvives) + return { id: 'saved-row' } + } + ) + render( + + + + ) + const button = screen.getByRole('button', { name: 'Resume in New CLI' }) + fireEvent.click(button) + await waitFor(() => expect(mocks.lookup).toHaveBeenCalledOnce()) + state.unifiedTabsByWorktree['worktree-1'] = [{ id: 'sibling-tab' }] + if (!groupSurvives) { + state.groupsByWorktree['worktree-1'] = [] + } + releaseHistory() + + await waitFor(() => expect(button).not.toBeDisabled()) + await waitFor(() => expect(mocks.resume).toHaveBeenCalledTimes(launches)) + if (groupSurvives) { + expect(mocks.resume).toHaveBeenCalledWith( + { id: 'saved-row' }, + 'worktree-1', + 'chat-group' + ) + } +}) diff --git a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx index 5bf78bf48d1a..65f99d382f07 100644 --- a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useAppStore } from '@/store' import { createBrowserUuid } from '@/lib/browser-uuid' +import { isAgentLaunchDestinationLive } from '@/lib/agent-launch-destination' import { translate } from '@/i18n/i18n' import { useTabSessionLaunchActions } from '../tab-bar/use-tab-session-launch-actions' import { @@ -40,9 +41,8 @@ export function NativeChatResumeInCliButton({ ) const resume = async (): Promise => { const targetGroupId = groupId - const isOpen = () => - useAppStore.getState().unifiedTabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId) ?? - false + const isDestinationLive = () => + isAgentLaunchDestinationLive(worktreeId, targetGroupId) const subject = resolveTabSessionHistorySubject(useAppStore.getState(), { tab: { id: tabId, worktreeId }, nativeChat @@ -56,11 +56,11 @@ export function NativeChatResumeInCliButton({ (args) => window.api.aiVault.listSessions(args), { requestToken: createBrowserUuid(), - isCancelled: () => !isOpen(), + isCancelled: () => !isDestinationLive(), userRequested: true } ) - if (!isOpen()) { + if (!isDestinationLive()) { return } const move = row ? resolveTabSessionSwitch(useAppStore.getState(), row, subject) : null diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts index 63c2aacd0288..a6834db09296 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts @@ -173,6 +173,7 @@ describe('Resume in New CLI launch', () => { agent: 'claude', worktreeId: 'worktree-1', targetGroupId: 'chat-group', + strictDestination: true, ...FORK_STARTUP }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index e2443ec3f463..4e46062b25be 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -123,6 +123,7 @@ export function useAiVaultSessionLaunchActions({ agent: session.agent, worktreeId: targetId.worktreeId, ...(targetGroupId ? { targetGroupId } : {}), + ...(targetGroupId ? { strictDestination: true } : {}), ...startup }) if (launchResult.tabId === null) { @@ -138,6 +139,9 @@ export function useAiVaultSessionLaunchActions({ ) return } + if (outcome.status === 'abandoned') { + return + } if (useAppStore.getState().activeWorktreeId !== targetId.worktreeId) { activateAiVaultResumeWorkspace(targetId.worktreeId) } diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index e785bb9de1af..ae90ddbf5b12 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -292,6 +292,9 @@ export default function AiVaultSessionDropLayer({ ) return } + if (outcome.status === 'abandoned') { + return + } showQueuedToast() }) return diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index df8f79429a4a..1f91c3faa7f8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18073,7 +18073,8 @@ "reconnectTimeout": "The server did not reconnect on the updated version." }, "webRuntimeSession": { - "remoteHostDisconnected": "The workspace is not connected to a remote Orca host." + "remoteHostDisconnected": "The workspace is not connected to a remote Orca host.", + "abandonedTerminalCleanupFailed": "The pane closed, but Orca could not confirm its terminal closed on the host." }, "gitlabJobTraceClient": { "loadFailed": "Failed to load the GitLab job log.", diff --git a/src/renderer/src/lib/launch-agent-web-host-tab.ts b/src/renderer/src/lib/launch-agent-web-host-tab.ts index e23851eb3b93..707dcec7a467 100644 --- a/src/renderer/src/lib/launch-agent-web-host-tab.ts +++ b/src/renderer/src/lib/launch-agent-web-host-tab.ts @@ -104,6 +104,9 @@ export function launchAgentInWebHostTab(args: { // Why: created means the host accepted the launch, not that a local tab // exists; keep pruning stale local rows until the snapshot mirrors. removeStaleLocalAgentTabsForWebHostLaunch(worktreeId) + if (outcome.status === 'abandoned') { + return { delivered: false, failureNotified: false } + } if (outcome.status === 'failed') { toast.error( outcome.message || diff --git a/src/renderer/src/lib/launch-ai-vault-session.test.ts b/src/renderer/src/lib/launch-ai-vault-session.test.ts index e61bccaacf87..f9c4aefecdd2 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.test.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.test.ts @@ -19,6 +19,7 @@ const mockState = { createTab: mockCreateTab, createEmptySplitGroup: mockCreateEmptySplitGroup, closeEmptyGroup: mockCloseEmptyGroup, + groupsByWorktree: { 'wt-1': [{ id: 'group-1' }] } as Record, setActiveTabType: mockSetActiveTabType, setTabBarOrder: mockSetTabBarOrder, tabsByWorktree: {} as Record, @@ -66,6 +67,7 @@ describe('launchAiVaultSessionInNewTab', () => { runtimeMocks.isWebRuntimeSessionActive.mockReturnValue(false) runtimeMocks.createWebRuntimeSessionTerminal.mockResolvedValue({ status: 'created' }) mockState.tabsByWorktree = {} + mockState.groupsByWorktree = { 'wt-1': [{ id: 'group-1' }] } mockState.openFiles = [] mockState.browserTabsByWorktree = {} mockState.tabBarOrderByWorktree = {} @@ -102,6 +104,23 @@ describe('launchAiVaultSessionInNewTab', () => { expect(result).toEqual({ tabId: 'tab-1', groupId: 'group-1' }) }) + it('abandons a named pane that disappeared before the prepared resume commits', async () => { + mockState.groupsByWorktree['wt-1'] = [] + const result = launchAiVaultSessionInNewTab({ + agent: 'claude', + worktreeId: 'wt-1', + targetGroupId: 'group-1', + strictDestination: true, + command: 'claude --resume session-1' + }) + + expect(mockCreateTab).not.toHaveBeenCalled() + expect(runtimeMocks.createWebRuntimeSessionTerminal).not.toHaveBeenCalled() + if (result.tabId === null) { + await expect(result.runtimeLaunch).resolves.toEqual({ status: 'abandoned' }) + } + }) + it('queues configured resume startup details for agent history resumes', () => { launchAiVaultSessionInNewTab({ agent: 'claude', @@ -164,6 +183,7 @@ describe('launchAiVaultSessionInNewTab', () => { agent: 'codex', worktreeId: 'wt-1', targetGroupId: 'group-1', + strictDestination: true, command: "codex resume 'session-1'", env: { CODEX_PROFILE: 'runtime' }, envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], @@ -180,6 +200,7 @@ describe('launchAiVaultSessionInNewTab', () => { worktreeId: 'wt-1', environmentId: 'env-1', targetGroupId: 'group-1', + strictDestination: true, agentSessionKind: 'resume', launchAgent: 'codex', command: "codex resume 'session-1'", diff --git a/src/renderer/src/lib/launch-ai-vault-session.ts b/src/renderer/src/lib/launch-ai-vault-session.ts index 342c5ccc9b4a..21795fede45f 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.ts @@ -4,6 +4,7 @@ import { tuiAgentToAgentKind } from '@/lib/telemetry' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { createWebRuntimeSessionTerminal } from '@/runtime/web-runtime-terminal-creation' import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session-environment' +import { isAgentLaunchDestinationLive } from '@/lib/agent-launch-destination' import type { AiVaultAgent } from '../../../shared/ai-vault-types' import type { AgentProviderSessionMetadata, @@ -26,8 +27,13 @@ export function launchAiVaultSessionInNewTab(args: { launchConfig?: SleepingAgentLaunchConfig providerSession?: AgentProviderSessionMetadata targetGroupId?: string + /** Pane-scoped callers abandon the launch if their named destination disappears. */ + strictDestination?: boolean splitDirection?: TabSplitDirection }): LaunchAiVaultSessionInNewTabResult { + if (args.strictDestination && !isAgentLaunchDestinationLive(args.worktreeId, args.targetGroupId)) { + return { tabId: null, runtimeLaunch: Promise.resolve({ status: 'abandoned' }) } + } const store = useAppStore.getState() let targetGroupId = args.targetGroupId // Why before both paths: the runtime path used to return first and drop the split (#22791). @@ -51,6 +57,7 @@ export function launchAiVaultSessionInNewTab(args: { ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), ...(args.providerSession ? { providerSession: args.providerSession } : {}), ...(args.launchConfig ? { agentArgs: args.launchConfig.agentArgs } : {}), + ...(args.strictDestination ? { strictDestination: true } : {}), activate: true }) const observedRuntimeLaunch = runtimeLaunch.then((outcome) => { diff --git a/src/renderer/src/runtime/web-runtime-session-tab-lifecycle.ts b/src/renderer/src/runtime/web-runtime-session-tab-lifecycle.ts index f308587fd543..55dfa636c5c7 100644 --- a/src/renderer/src/runtime/web-runtime-session-tab-lifecycle.ts +++ b/src/renderer/src/runtime/web-runtime-session-tab-lifecycle.ts @@ -21,8 +21,10 @@ import { toHostSessionTabId } from '../../../shared/terminal-surface-id' import { captureRuntimeEnvironmentCall, captureWebSessionIntentOwner, - isWebRuntimeSessionActive + isWebRuntimeSessionActive, + matchesWebSessionIntentOwner } from './web-runtime-session-environment' +import type { WebSessionIntentOwner } from './web-session-intent-owner' import { refreshWebRuntimeSessionTabsSnapshot } from './web-runtime-session-snapshot' export async function activateWebRuntimeSessionTab(args: { @@ -71,6 +73,8 @@ export async function closeWebRuntimeSessionTab(args: { worktreeId: string tabId: string environmentId?: string | null + intentOwner?: WebSessionIntentOwner + requireConfirmedClose?: boolean reason: RuntimeSessionTabCloseReason publicationEpoch?: string | null terminalHandle?: string | null @@ -92,6 +96,8 @@ async function callWebRuntimeSessionTabMethod( worktreeId: string tabId: string environmentId?: string | null + intentOwner?: WebSessionIntentOwner + requireConfirmedClose?: boolean reason?: RuntimeSessionTabCloseReason publicationEpoch?: string | null terminalHandle?: string | null @@ -103,7 +109,15 @@ async function callWebRuntimeSessionTabMethod( if (!environmentId || !isWebRuntimeSessionActive(environmentId)) { return 'failed' } - const intentOwner = captureWebSessionIntentOwner(environmentId) + if ( + args.intentOwner && + ((args.requireConfirmedClose && args.intentOwner.pairingRevision === undefined) || + args.intentOwner.environmentId !== environmentId || + !matchesWebSessionIntentOwner(args.intentOwner)) + ) { + return 'failed' + } + const intentOwner = args.intentOwner ?? captureWebSessionIntentOwner(environmentId) const callEnvironment = captureRuntimeEnvironmentCall(environmentId, intentOwner.pairingRevision) const closeIntentTabIds = new Set() let activationHostTabId: string | null = null @@ -200,6 +214,9 @@ async function callWebRuntimeSessionTabMethod( await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId, { expectedEnvironmentPairingRevision: intentOwner.pairingRevision }) + if (args.requireConfirmedClose && result?.refused === true) { + return 'failed' + } } return 'applied' } catch (error) { diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts b/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts index 5e148cdeda8b..827695a4bc85 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts @@ -17,6 +17,7 @@ import { stubTerminalCreateEnvironment } from './web-runtime-session-test-harness' import { stubRuntimeEnvironmentBridge } from './runtime-environment-bridge-fixture' +import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' const mocks = vi.hoisted(() => ({ getState: vi.fn(), @@ -95,9 +96,65 @@ describe('createWebRuntimeSessionTerminal', () => { }) afterEach(() => { + replaceRuntimeEnvironmentRevisions([]) resetTerminalCreateEnvironment() }) + it.each([ + { pairedHostChanged: false, status: 'abandoned' }, + { pairedHostChanged: true, status: 'failed' } + ])('settles a closed destination on its original host ($pairedHostChanged)', async ({ + pairedHostChanged, + status + }) => { + replaceRuntimeEnvironmentRevisions([{ id: ENVIRONMENT_ID, createdAt: 1 }]) + const state = mocks.getState() + state.groupsByWorktree = { [WORKTREE_ID]: [{ id: 'chat-group' }] } + let releaseCreate: () => void = () => undefined + const createReply = new Promise((resolve) => { + releaseCreate = resolve + }) + const runtimeCall = vi.fn(async (request: { method: string }) => { + if (request.method === 'session.tabs.createTerminal') { + await createReply + return { id: 'create', ok: true, result: { tab: { id: 'host-tab-1' } } } + } + if (request.method === 'session.tabs.close') { + return { id: 'close', ok: true, result: { closed: true } } + } + return { id: 'list', ok: true, result: makeSnapshot() } + }) + stubRuntimeEnvironmentBridge({ call: runtimeCall }) + + const launch = createWebRuntimeSessionTerminal({ + worktreeId: WORKTREE_ID, + environmentId: ENVIRONMENT_ID, + targetGroupId: 'chat-group', + strictDestination: true + }) + await vi.waitFor(() => + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'session.tabs.createTerminal' }) + ) + ) + state.groupsByWorktree[WORKTREE_ID] = [] + if (pairedHostChanged) { + replaceRuntimeEnvironmentRevisions([{ id: ENVIRONMENT_ID, createdAt: 2 }]) + } + releaseCreate() + + expect((await launch).status).toBe(status) + const closes = runtimeCall.mock.calls.filter(([request]) => request.method === 'session.tabs.close') + expect(closes).toHaveLength(pairedHostChanged ? 0 : 1) + if (!pairedHostChanged) { + expect(closes[0]?.[0]).toMatchObject({ + selector: ENVIRONMENT_ID, + expectedEnvironmentPairingRevision: 1, + params: { tabId: 'host-tab-1', reason: 'user' } + }) + } + }) + it('keeps same-ID local and runtime worktrees on the selected runtime owner', async () => { const selectedHosts: (string | undefined)[] = [] mocks.setActiveWorktree.mockImplementation((_worktreeId: string, executionHostId?: string) => { diff --git a/src/renderer/src/runtime/web-runtime-session-types.ts b/src/renderer/src/runtime/web-runtime-session-types.ts index 8664c9192ce9..13c9152fdacf 100644 --- a/src/renderer/src/runtime/web-runtime-session-types.ts +++ b/src/renderer/src/runtime/web-runtime-session-types.ts @@ -12,6 +12,7 @@ import type { TuiAgent } from '../../../shared/tui-agent' export type WebRuntimeTerminalCreateOutcome = | { status: 'created' } + | { status: 'abandoned' } | { status: 'failed'; message: string } export type CreateWebRuntimeSessionTerminalArgs = { @@ -19,6 +20,8 @@ export type CreateWebRuntimeSessionTerminalArgs = { environmentId?: string | null afterTabId?: string targetGroupId?: string + /** A pane-scoped launch must retire a host terminal if this target closes in flight. */ + strictDestination?: boolean command?: string cwd?: string env?: Record diff --git a/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts b/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts index bd46cc8eff1f..6ec9d6833d99 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts @@ -14,20 +14,16 @@ import { runRemoteAgentSessionLaunch } from './remote-agent-session-launch' import { unwrapRuntimeRpcResult } from './runtime-rpc-result' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' import { resolveWebRuntimeSessionEnvironmentId } from './web-runtime-session-workspace-routing' -import { recordWebSessionFocusIntent } from './web-session-focus-intent' import { forgetWebSessionTerminalPlacement, - recordWebSessionTerminalPlacement, webTerminalPlacementParentTabId } from './web-session-terminal-placement' import { toHostSessionTabId } from '../../../shared/terminal-surface-id' import { captureRuntimeEnvironmentCall, captureWebSessionIntentOwner, - isWebRuntimeSessionActive, - matchesWebSessionIntentOwner + isWebRuntimeSessionActive } from './web-runtime-session-environment' -import { refreshWebRuntimeSessionTabsSnapshot } from './web-runtime-session-snapshot' import type { CreatedAgentTerminalIdentity, CreatedWebRuntimeSessionTerminal, @@ -43,7 +39,10 @@ import { createdTerminalLeafId, readCreatedAgentTerminalIdentity } from './web-runtime-terminal-identity' -import { settleWebRuntimeTerminalPlacement } from './web-runtime-terminal-placement-settlement' +import { publishWebRuntimeTerminalPlacement } from './web-runtime-terminal-placement-settlement' +import { isAgentLaunchDestinationLive } from '@/lib/agent-launch-destination' +import { retireWebRuntimeTerminalForLostDestination } from './web-runtime-terminal-lost-destination-retirement' +import { createLegacyWebRuntimeTerminal } from './web-runtime-terminal-legacy-create' export async function createWebRuntimeSessionTerminalResult( args: CreateWebRuntimeSessionTerminalArgs @@ -66,6 +65,13 @@ export async function createWebRuntimeSessionTerminalResult( const intentOwner = captureWebSessionIntentOwner(environmentId) const callEnvironment = captureRuntimeEnvironmentCall(environmentId, intentOwner.pairingRevision) + if ( + args.strictDestination && + !isAgentLaunchDestinationLive(args.worktreeId, args.targetGroupId) + ) { + return { outcome: { status: 'abandoned' } } + } + let workspaceSelectionRollback: WebRuntimeSessionWorkspaceSelectionRollback | null = null if (args.selectWorktree !== false) { const previous = readActiveWorkspaceSelection() @@ -81,6 +87,14 @@ export async function createWebRuntimeSessionTerminalResult( let hostCreated = false let createdTabId: string | undefined let createdLeafId: string | undefined + const retireIfDestinationGone = (): Promise => + retireWebRuntimeTerminalForLostDestination({ + ...args, + environmentId, + intentOwner, + createdTabId, + workspaceSelectionRollback + }) try { const agent = args.launchAgent ?? args.agent const agentArgsOverride = @@ -206,6 +220,10 @@ export async function createWebRuntimeSessionTerminalResult( createdLeafId = legacyAlreadyPlacedInGroup ? created.terminal.leafId : createdTerminalLeafId(created.terminal) + const abandoned = await retireIfDestinationGone() + if (abandoned) { + return abandoned + } if (args.targetGroupId && createdTabId && !legacyAlreadyPlacedInGroup) { await callEnvironment({ method: 'session.tabs.move', @@ -219,76 +237,31 @@ export async function createWebRuntimeSessionTerminalResult( }) } } else { - const response = await callEnvironment({ - method: 'session.tabs.createTerminal', - params: { - worktree: toRuntimeWorktreeSelector(args.worktreeId), - afterTabId: args.afterTabId ? toHostSessionTabId(args.afterTabId) : undefined, - targetGroupId: args.targetGroupId, - command: args.command, - cwd: args.cwd, - ...(args.env ? { env: args.env } : {}), - ...(args.envToDelete ? { envToDelete: args.envToDelete } : {}), - startupCommandDelivery: args.startupCommandDelivery, - ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), - ...(args.launchToken ? { launchToken: args.launchToken } : {}), - ...(args.viewMode ? { viewMode: args.viewMode } : {}), - // Why: old hosts understand activate:false; new hosts use select/navigation for caller-local focus. - activate: false, - select: args.activate !== false, - navigation: 'caller' - }, - timeoutMs: 15_000 - }) - const created = unwrapRuntimeRpcResult( - response as RuntimeRpcResponse - ) + const created = await createLegacyWebRuntimeTerminal(args, callEnvironment) hostCreated = true createdTabId = created.tab.id createdLeafId = created.tab.leafId + const abandoned = await retireIfDestinationGone() + if (abandoned) { + return abandoned + } } - if (args.targetGroupId && createdTabId) { - // Why: the host drops client-minted group ids, so this client's own record is what - // lands the mirrored tab in the requested pane under client-owned placement. - recordWebSessionTerminalPlacement({ - environmentId, - worktreeId: args.worktreeId, - hostTabId: webTerminalPlacementParentTabId(createdTabId), - groupId: args.targetGroupId - }) - } - if (args.activate !== false && createdTabId && matchesWebSessionIntentOwner(intentOwner)) { - // Why: record focus intent so the reconcile follows the snapshot's active - // tab to THIS new terminal, instead of sticky-keeping the prior tab. - recordWebSessionFocusIntent(intentOwner, args.worktreeId, createdTabId, createdLeafId) - } - const placementTabId = - createdTabId && (args.targetGroupId || args.afterTabId) ? createdTabId : undefined - await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId, { - expectedEnvironmentPairingRevision: intentOwner.pairingRevision, - // Why: the publication can beat the RPC response; replay it once after caller intent exists. - acceptCurrentSnapshot: - Boolean(createdTabId) && (args.activate !== false || Boolean(placementTabId)), - // Why: a placement record needs a post-create list; a deduped in-flight one can predate it. - ...(placementTabId ? { afterCurrentInFlight: true } : {}) - }) - if (placementTabId) { - await settleWebRuntimeTerminalPlacement( - environmentId, - args.worktreeId, - webTerminalPlacementParentTabId(placementTabId), - { - groupId: args.targetGroupId, - afterTabId: args.afterTabId, - activate: args.activate !== false - } - ) + await publishWebRuntimeTerminalPlacement(args, environmentId, intentOwner, createdTabId, createdLeafId) + const abandoned = await retireIfDestinationGone() + if (abandoned) { + return abandoned } return { outcome: { status: 'created' }, ...(createdTabId ? { hostTabId: createdTabId } : {}) } } catch (error) { + if (hostCreated) { + const abandoned = await retireIfDestinationGone() + if (abandoned) { + return abandoned + } + } const message = error instanceof Error ? error.message : String(error) console.warn( hostCreated diff --git a/src/renderer/src/runtime/web-runtime-terminal-creation.ts b/src/renderer/src/runtime/web-runtime-terminal-creation.ts index 42567f76ba52..bbe81a65c7a9 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-creation.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-creation.ts @@ -28,7 +28,7 @@ export async function createWebRuntimeAgentSessionTerminal( promptDelivered: boolean }> { const created = await createWebRuntimeSessionTerminalResult(args) - if (created.outcome.status === 'failed' || !created.hostTabId) { + if (created.outcome.status !== 'created' || !created.hostTabId) { return { outcome: created.outcome, promptDelivered: false } } @@ -54,7 +54,7 @@ export async function createWebRuntimeAgentSessionTerminalWithLaunchDraft( } ): Promise { const created = await createWebRuntimeSessionTerminalResult(args) - if (created.outcome.status !== 'failed' && created.hostTabId) { + if (created.outcome.status === 'created' && created.hostTabId) { seedNativeChatLaunchDraftForAgentTab({ tabId: toWebTerminalSurfaceTabId(created.hostTabId), agent: args.agent, diff --git a/src/renderer/src/runtime/web-runtime-terminal-legacy-create.ts b/src/renderer/src/runtime/web-runtime-terminal-legacy-create.ts new file mode 100644 index 000000000000..05fc1624d234 --- /dev/null +++ b/src/renderer/src/runtime/web-runtime-terminal-legacy-create.ts @@ -0,0 +1,40 @@ +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import type { RuntimeMobileSessionCreateTerminalResult } from '../../../shared/runtime-types' +import { toHostSessionTabId } from '../../../shared/terminal-surface-id' +import { unwrapRuntimeRpcResult } from './runtime-rpc-result' +import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' +import type { CreateWebRuntimeSessionTerminalArgs } from './web-runtime-session-types' + +export async function createLegacyWebRuntimeTerminal( + args: CreateWebRuntimeSessionTerminalArgs, + callEnvironment: (request: { + method: string + params?: unknown + timeoutMs?: number + }) => Promise> +): Promise { + const response = await callEnvironment({ + method: 'session.tabs.createTerminal', + params: { + worktree: toRuntimeWorktreeSelector(args.worktreeId), + afterTabId: args.afterTabId ? toHostSessionTabId(args.afterTabId) : undefined, + targetGroupId: args.targetGroupId, + command: args.command, + cwd: args.cwd, + ...(args.env ? { env: args.env } : {}), + ...(args.envToDelete ? { envToDelete: args.envToDelete } : {}), + startupCommandDelivery: args.startupCommandDelivery, + ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), + ...(args.launchToken ? { launchToken: args.launchToken } : {}), + ...(args.viewMode ? { viewMode: args.viewMode } : {}), + // Old hosts use activate:false; new hosts use select/navigation for client-local focus. + activate: false, + select: args.activate !== false, + navigation: 'caller' + }, + timeoutMs: 15_000 + }) + return unwrapRuntimeRpcResult( + response as RuntimeRpcResponse + ) +} diff --git a/src/renderer/src/runtime/web-runtime-terminal-lost-destination-retirement.ts b/src/renderer/src/runtime/web-runtime-terminal-lost-destination-retirement.ts new file mode 100644 index 000000000000..801b259a1ca6 --- /dev/null +++ b/src/renderer/src/runtime/web-runtime-terminal-lost-destination-retirement.ts @@ -0,0 +1,62 @@ +import { isAgentLaunchDestinationLive } from '@/lib/agent-launch-destination' +import { translate } from '../i18n/i18n' +import { clearWebSessionFocusIntentIfMatches } from './web-session-focus-intent' +import { forgetWebSessionTerminalPlacement, webTerminalPlacementParentTabId } from './web-session-terminal-placement' +import type { WebSessionIntentOwner } from './web-session-intent-owner' +import type { CreatedWebRuntimeSessionTerminal } from './web-runtime-session-types' +import { + restoreActiveWorkspaceSelection, + type WebRuntimeSessionWorkspaceSelectionRollback +} from './web-runtime-session-workspace-selection' + +export async function retireWebRuntimeTerminalForLostDestination(args: { + strictDestination?: boolean + worktreeId: string + targetGroupId?: string + environmentId: string + intentOwner: WebSessionIntentOwner + createdTabId?: string + workspaceSelectionRollback: WebRuntimeSessionWorkspaceSelectionRollback | null +}): Promise { + if ( + !args.strictDestination || + isAgentLaunchDestinationLive(args.worktreeId, args.targetGroupId) + ) { + return null + } + const cleanupFailureMessage = translate( + 'auto.runtime.webRuntimeSession.abandonedTerminalCleanupFailed', + 'The pane closed, but Orca could not confirm its terminal closed on the host.' + ) + if (args.workspaceSelectionRollback) { + restoreActiveWorkspaceSelection(args.workspaceSelectionRollback) + } + if (!args.createdTabId) { + return { outcome: { status: 'failed', message: cleanupFailureMessage } } + } + const hostTabId = webTerminalPlacementParentTabId(args.createdTabId) + forgetWebSessionTerminalPlacement({ + environmentId: args.environmentId, + worktreeId: args.worktreeId, + hostTabId + }) + clearWebSessionFocusIntentIfMatches(args.intentOwner, args.worktreeId, args.createdTabId) + try { + const { closeWebRuntimeSessionTab } = await import('./web-runtime-session-tab-lifecycle') + const close = await closeWebRuntimeSessionTab({ + worktreeId: args.worktreeId, + tabId: hostTabId, + environmentId: args.environmentId, + intentOwner: args.intentOwner, + requireConfirmedClose: true, + reason: 'user' + }) + if (close !== 'failed') { + return { outcome: { status: 'abandoned' } } + } + } catch (error) { + console.warn('[web-runtime-session] terminal retirement failed:', error) + } + console.warn('[web-runtime-session] could not retire terminal after its pane closed') + return { outcome: { status: 'failed', message: cleanupFailureMessage } } +} diff --git a/src/renderer/src/runtime/web-runtime-terminal-placement-settlement.ts b/src/renderer/src/runtime/web-runtime-terminal-placement-settlement.ts index d82fdcfefc63..167fb9903ce0 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-placement-settlement.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-placement-settlement.ts @@ -1,15 +1,63 @@ import { insertUnifiedTabAfterAnchor } from '../lib/unified-tab-anchor-insertion' +import { isAgentLaunchDestinationLive } from '../lib/agent-launch-destination' import { useAppStore } from '../store' +import { recordWebSessionFocusIntent } from './web-session-focus-intent' import { forgetWebSessionTerminalPlacement, + recordWebSessionTerminalPlacement, webTerminalPlacementParentTabId } from './web-session-terminal-placement' +import { matchesWebSessionIntentOwner } from './web-runtime-session-environment' +import { refreshWebRuntimeSessionTabsSnapshot } from './web-runtime-session-snapshot' +import type { WebSessionIntentOwner } from './web-session-intent-owner' +import type { CreateWebRuntimeSessionTerminalArgs } from './web-runtime-session-types' import { isWebTerminalSurfaceTabId, toHostSessionTabId, toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +export async function publishWebRuntimeTerminalPlacement( + args: CreateWebRuntimeSessionTerminalArgs, + environmentId: string, + intentOwner: WebSessionIntentOwner, + createdTabId?: string, + createdLeafId?: string +): Promise { + if (args.targetGroupId && createdTabId) { + recordWebSessionTerminalPlacement({ + environmentId, + worktreeId: args.worktreeId, + hostTabId: webTerminalPlacementParentTabId(createdTabId), + groupId: args.targetGroupId + }) + } + if (args.activate !== false && createdTabId && matchesWebSessionIntentOwner(intentOwner)) { + recordWebSessionFocusIntent(intentOwner, args.worktreeId, createdTabId, createdLeafId) + } + const placementTabId = + createdTabId && (args.targetGroupId || args.afterTabId) ? createdTabId : undefined + await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId, { + expectedEnvironmentPairingRevision: intentOwner.pairingRevision, + acceptCurrentSnapshot: + Boolean(createdTabId) && (args.activate !== false || Boolean(placementTabId)), + ...(placementTabId ? { afterCurrentInFlight: true } : {}) + }) + if (placementTabId) { + await settleWebRuntimeTerminalPlacement( + environmentId, + args.worktreeId, + webTerminalPlacementParentTabId(placementTabId), + { + groupId: args.targetGroupId, + afterTabId: args.afterTabId, + activate: args.activate !== false, + ...(args.strictDestination ? { strictDestination: true } : {}) + } + ) + } +} + /** Snapshots key mirrored terminals by the parent tab, so an unknown `parent::leaf` anchor resolves to its parent. */ function anchorUnifiedTabId(worktreeId: string, afterTabId: string): string { const known = (useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).some( @@ -25,7 +73,7 @@ export async function settleWebRuntimeTerminalPlacement( environmentId: string, worktreeId: string, hostTabId: string, - placement: { groupId?: string; afterTabId?: string; activate: boolean } + placement: { groupId?: string; afterTabId?: string; activate: boolean; strictDestination?: boolean } ): Promise { const unifiedTabId = toWebTerminalSurfaceTabId(hostTabId) const findTab = () => @@ -35,8 +83,17 @@ export async function settleWebRuntimeTerminalPlacement( try { const deadline = Date.now() + 10_000 while (!findTab() && Date.now() < deadline) { + if ( + placement.strictDestination && + !isAgentLaunchDestinationLive(worktreeId, placement.groupId) + ) { + return + } await new Promise((resolve) => setTimeout(resolve, 250)) } + if (placement.strictDestination && !isAgentLaunchDestinationLive(worktreeId, placement.groupId)) { + return + } const tab = findTab() if (!tab) { return From fe5d91e080999b5fea5eb1995a610041b4e6f3b5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:38:39 -0700 Subject: [PATCH 22/42] test(chat): declare destination fixtures without type assertions --- src/renderer/src/lib/launch-ai-vault-session.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/lib/launch-ai-vault-session.test.ts b/src/renderer/src/lib/launch-ai-vault-session.test.ts index f9c4aefecdd2..9618fc532678 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.test.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.test.ts @@ -15,11 +15,14 @@ const runtimeMocks = vi.hoisted(() => ({ isWebRuntimeSessionActive: vi.fn(() => false) })) +const mockGroupsByWorktree: Record = { + 'wt-1': [{ id: 'group-1' }] +} const mockState = { createTab: mockCreateTab, createEmptySplitGroup: mockCreateEmptySplitGroup, closeEmptyGroup: mockCloseEmptyGroup, - groupsByWorktree: { 'wt-1': [{ id: 'group-1' }] } as Record, + groupsByWorktree: mockGroupsByWorktree, setActiveTabType: mockSetActiveTabType, setTabBarOrder: mockSetTabBarOrder, tabsByWorktree: {} as Record, From d5dfe40e4c9b8d4e2f9c46d9863a32dff188a011 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:54:02 -0700 Subject: [PATCH 23/42] fix(chat): keep Resume visible when its source pane closes --- .../NativeChatResumeInCliButton.test.tsx | 26 ++--- .../NativeChatResumeInCliButton.tsx | 8 +- .../ai-vault-session-launch-actions.test.ts | 1 - .../ai-vault-session-launch-actions.ts | 4 - .../tab-group/AiVaultSessionDropLayer.tsx | 3 - src/renderer/src/i18n/locales/en.json | 3 +- .../src/lib/launch-agent-web-host-tab.ts | 3 - .../src/lib/launch-ai-vault-session.test.ts | 24 ---- .../src/lib/launch-ai-vault-session.ts | 7 -- .../web-runtime-session-tab-lifecycle.ts | 21 +--- ...me-session-terminal-host-authority.test.ts | 57 ---------- .../src/runtime/web-runtime-session-types.ts | 3 - .../web-runtime-terminal-create-operation.ts | 105 +++++++++++------- .../runtime/web-runtime-terminal-creation.ts | 4 +- .../web-runtime-terminal-legacy-create.ts | 40 ------- ...me-terminal-lost-destination-retirement.ts | 62 ----------- ...b-runtime-terminal-placement-settlement.ts | 59 +--------- 17 files changed, 83 insertions(+), 347 deletions(-) delete mode 100644 src/renderer/src/runtime/web-runtime-terminal-legacy-create.ts delete mode 100644 src/renderer/src/runtime/web-runtime-terminal-lost-destination-retirement.ts diff --git a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.test.tsx b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.test.tsx index 39e330514c48..4a6ace2499be 100644 --- a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.test.tsx @@ -32,13 +32,9 @@ beforeEach(() => { mocks.resolveSwitch.mockReturnValue({ action: 'resume-in-new-cli', worktreeId: 'worktree-1' }) }) -it.each([ - { groupSurvives: true, launches: 1 }, - { groupSurvives: false, launches: 0 } -])('uses the clicked pane during history lookup (survives=$groupSurvives)', async ({ - groupSurvives, - launches -}) => { +it.each([true, false])('finishes Resume after source chat closes (pane survives=%s)', async ( + groupSurvives +) => { const state = { groupsByWorktree: { 'worktree-1': [{ id: 'chat-group' }] }, unifiedTabsByWorktree: { 'worktree-1': [{ id: 'chat-tab' }, { id: 'sibling-tab' }] } @@ -51,7 +47,7 @@ it.each([ mocks.lookup.mockImplementation( async (_subject: unknown, _list: unknown, options: { isCancelled: () => boolean }) => { await history - expect(options.isCancelled()).toBe(!groupSurvives) + expect(options.isCancelled()).toBe(false) return { id: 'saved-row' } } ) @@ -75,12 +71,10 @@ it.each([ releaseHistory() await waitFor(() => expect(button).not.toBeDisabled()) - await waitFor(() => expect(mocks.resume).toHaveBeenCalledTimes(launches)) - if (groupSurvives) { - expect(mocks.resume).toHaveBeenCalledWith( - { id: 'saved-row' }, - 'worktree-1', - 'chat-group' - ) - } + await waitFor(() => expect(mocks.resume).toHaveBeenCalledOnce()) + expect(mocks.resume).toHaveBeenCalledWith( + { id: 'saved-row' }, + 'worktree-1', + 'chat-group' + ) }) diff --git a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx index 65f99d382f07..a1e795dcf4bd 100644 --- a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx @@ -5,7 +5,6 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useAppStore } from '@/store' import { createBrowserUuid } from '@/lib/browser-uuid' -import { isAgentLaunchDestinationLive } from '@/lib/agent-launch-destination' import { translate } from '@/i18n/i18n' import { useTabSessionLaunchActions } from '../tab-bar/use-tab-session-launch-actions' import { @@ -41,8 +40,6 @@ export function NativeChatResumeInCliButton({ ) const resume = async (): Promise => { const targetGroupId = groupId - const isDestinationLive = () => - isAgentLaunchDestinationLive(worktreeId, targetGroupId) const subject = resolveTabSessionHistorySubject(useAppStore.getState(), { tab: { id: tabId, worktreeId }, nativeChat @@ -56,13 +53,10 @@ export function NativeChatResumeInCliButton({ (args) => window.api.aiVault.listSessions(args), { requestToken: createBrowserUuid(), - isCancelled: () => !isDestinationLive(), + isCancelled: () => false, userRequested: true } ) - if (!isDestinationLive()) { - return - } const move = row ? resolveTabSessionSwitch(useAppStore.getState(), row, subject) : null if (row && move?.action === 'resume-in-new-cli') { launch.handleResumeInNewCli(row, move.worktreeId, targetGroupId) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts index a6834db09296..63c2aacd0288 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts @@ -173,7 +173,6 @@ describe('Resume in New CLI launch', () => { agent: 'claude', worktreeId: 'worktree-1', targetGroupId: 'chat-group', - strictDestination: true, ...FORK_STARTUP }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index 4e46062b25be..e2443ec3f463 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -123,7 +123,6 @@ export function useAiVaultSessionLaunchActions({ agent: session.agent, worktreeId: targetId.worktreeId, ...(targetGroupId ? { targetGroupId } : {}), - ...(targetGroupId ? { strictDestination: true } : {}), ...startup }) if (launchResult.tabId === null) { @@ -139,9 +138,6 @@ export function useAiVaultSessionLaunchActions({ ) return } - if (outcome.status === 'abandoned') { - return - } if (useAppStore.getState().activeWorktreeId !== targetId.worktreeId) { activateAiVaultResumeWorkspace(targetId.worktreeId) } diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index ae90ddbf5b12..e785bb9de1af 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -292,9 +292,6 @@ export default function AiVaultSessionDropLayer({ ) return } - if (outcome.status === 'abandoned') { - return - } showQueuedToast() }) return diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1f91c3faa7f8..df8f79429a4a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18073,8 +18073,7 @@ "reconnectTimeout": "The server did not reconnect on the updated version." }, "webRuntimeSession": { - "remoteHostDisconnected": "The workspace is not connected to a remote Orca host.", - "abandonedTerminalCleanupFailed": "The pane closed, but Orca could not confirm its terminal closed on the host." + "remoteHostDisconnected": "The workspace is not connected to a remote Orca host." }, "gitlabJobTraceClient": { "loadFailed": "Failed to load the GitLab job log.", diff --git a/src/renderer/src/lib/launch-agent-web-host-tab.ts b/src/renderer/src/lib/launch-agent-web-host-tab.ts index 707dcec7a467..e23851eb3b93 100644 --- a/src/renderer/src/lib/launch-agent-web-host-tab.ts +++ b/src/renderer/src/lib/launch-agent-web-host-tab.ts @@ -104,9 +104,6 @@ export function launchAgentInWebHostTab(args: { // Why: created means the host accepted the launch, not that a local tab // exists; keep pruning stale local rows until the snapshot mirrors. removeStaleLocalAgentTabsForWebHostLaunch(worktreeId) - if (outcome.status === 'abandoned') { - return { delivered: false, failureNotified: false } - } if (outcome.status === 'failed') { toast.error( outcome.message || diff --git a/src/renderer/src/lib/launch-ai-vault-session.test.ts b/src/renderer/src/lib/launch-ai-vault-session.test.ts index 9618fc532678..e61bccaacf87 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.test.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.test.ts @@ -15,14 +15,10 @@ const runtimeMocks = vi.hoisted(() => ({ isWebRuntimeSessionActive: vi.fn(() => false) })) -const mockGroupsByWorktree: Record = { - 'wt-1': [{ id: 'group-1' }] -} const mockState = { createTab: mockCreateTab, createEmptySplitGroup: mockCreateEmptySplitGroup, closeEmptyGroup: mockCloseEmptyGroup, - groupsByWorktree: mockGroupsByWorktree, setActiveTabType: mockSetActiveTabType, setTabBarOrder: mockSetTabBarOrder, tabsByWorktree: {} as Record, @@ -70,7 +66,6 @@ describe('launchAiVaultSessionInNewTab', () => { runtimeMocks.isWebRuntimeSessionActive.mockReturnValue(false) runtimeMocks.createWebRuntimeSessionTerminal.mockResolvedValue({ status: 'created' }) mockState.tabsByWorktree = {} - mockState.groupsByWorktree = { 'wt-1': [{ id: 'group-1' }] } mockState.openFiles = [] mockState.browserTabsByWorktree = {} mockState.tabBarOrderByWorktree = {} @@ -107,23 +102,6 @@ describe('launchAiVaultSessionInNewTab', () => { expect(result).toEqual({ tabId: 'tab-1', groupId: 'group-1' }) }) - it('abandons a named pane that disappeared before the prepared resume commits', async () => { - mockState.groupsByWorktree['wt-1'] = [] - const result = launchAiVaultSessionInNewTab({ - agent: 'claude', - worktreeId: 'wt-1', - targetGroupId: 'group-1', - strictDestination: true, - command: 'claude --resume session-1' - }) - - expect(mockCreateTab).not.toHaveBeenCalled() - expect(runtimeMocks.createWebRuntimeSessionTerminal).not.toHaveBeenCalled() - if (result.tabId === null) { - await expect(result.runtimeLaunch).resolves.toEqual({ status: 'abandoned' }) - } - }) - it('queues configured resume startup details for agent history resumes', () => { launchAiVaultSessionInNewTab({ agent: 'claude', @@ -186,7 +164,6 @@ describe('launchAiVaultSessionInNewTab', () => { agent: 'codex', worktreeId: 'wt-1', targetGroupId: 'group-1', - strictDestination: true, command: "codex resume 'session-1'", env: { CODEX_PROFILE: 'runtime' }, envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], @@ -203,7 +180,6 @@ describe('launchAiVaultSessionInNewTab', () => { worktreeId: 'wt-1', environmentId: 'env-1', targetGroupId: 'group-1', - strictDestination: true, agentSessionKind: 'resume', launchAgent: 'codex', command: "codex resume 'session-1'", diff --git a/src/renderer/src/lib/launch-ai-vault-session.ts b/src/renderer/src/lib/launch-ai-vault-session.ts index 21795fede45f..342c5ccc9b4a 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.ts @@ -4,7 +4,6 @@ import { tuiAgentToAgentKind } from '@/lib/telemetry' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { createWebRuntimeSessionTerminal } from '@/runtime/web-runtime-terminal-creation' import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session-environment' -import { isAgentLaunchDestinationLive } from '@/lib/agent-launch-destination' import type { AiVaultAgent } from '../../../shared/ai-vault-types' import type { AgentProviderSessionMetadata, @@ -27,13 +26,8 @@ export function launchAiVaultSessionInNewTab(args: { launchConfig?: SleepingAgentLaunchConfig providerSession?: AgentProviderSessionMetadata targetGroupId?: string - /** Pane-scoped callers abandon the launch if their named destination disappears. */ - strictDestination?: boolean splitDirection?: TabSplitDirection }): LaunchAiVaultSessionInNewTabResult { - if (args.strictDestination && !isAgentLaunchDestinationLive(args.worktreeId, args.targetGroupId)) { - return { tabId: null, runtimeLaunch: Promise.resolve({ status: 'abandoned' }) } - } const store = useAppStore.getState() let targetGroupId = args.targetGroupId // Why before both paths: the runtime path used to return first and drop the split (#22791). @@ -57,7 +51,6 @@ export function launchAiVaultSessionInNewTab(args: { ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), ...(args.providerSession ? { providerSession: args.providerSession } : {}), ...(args.launchConfig ? { agentArgs: args.launchConfig.agentArgs } : {}), - ...(args.strictDestination ? { strictDestination: true } : {}), activate: true }) const observedRuntimeLaunch = runtimeLaunch.then((outcome) => { diff --git a/src/renderer/src/runtime/web-runtime-session-tab-lifecycle.ts b/src/renderer/src/runtime/web-runtime-session-tab-lifecycle.ts index 55dfa636c5c7..f308587fd543 100644 --- a/src/renderer/src/runtime/web-runtime-session-tab-lifecycle.ts +++ b/src/renderer/src/runtime/web-runtime-session-tab-lifecycle.ts @@ -21,10 +21,8 @@ import { toHostSessionTabId } from '../../../shared/terminal-surface-id' import { captureRuntimeEnvironmentCall, captureWebSessionIntentOwner, - isWebRuntimeSessionActive, - matchesWebSessionIntentOwner + isWebRuntimeSessionActive } from './web-runtime-session-environment' -import type { WebSessionIntentOwner } from './web-session-intent-owner' import { refreshWebRuntimeSessionTabsSnapshot } from './web-runtime-session-snapshot' export async function activateWebRuntimeSessionTab(args: { @@ -73,8 +71,6 @@ export async function closeWebRuntimeSessionTab(args: { worktreeId: string tabId: string environmentId?: string | null - intentOwner?: WebSessionIntentOwner - requireConfirmedClose?: boolean reason: RuntimeSessionTabCloseReason publicationEpoch?: string | null terminalHandle?: string | null @@ -96,8 +92,6 @@ async function callWebRuntimeSessionTabMethod( worktreeId: string tabId: string environmentId?: string | null - intentOwner?: WebSessionIntentOwner - requireConfirmedClose?: boolean reason?: RuntimeSessionTabCloseReason publicationEpoch?: string | null terminalHandle?: string | null @@ -109,15 +103,7 @@ async function callWebRuntimeSessionTabMethod( if (!environmentId || !isWebRuntimeSessionActive(environmentId)) { return 'failed' } - if ( - args.intentOwner && - ((args.requireConfirmedClose && args.intentOwner.pairingRevision === undefined) || - args.intentOwner.environmentId !== environmentId || - !matchesWebSessionIntentOwner(args.intentOwner)) - ) { - return 'failed' - } - const intentOwner = args.intentOwner ?? captureWebSessionIntentOwner(environmentId) + const intentOwner = captureWebSessionIntentOwner(environmentId) const callEnvironment = captureRuntimeEnvironmentCall(environmentId, intentOwner.pairingRevision) const closeIntentTabIds = new Set() let activationHostTabId: string | null = null @@ -214,9 +200,6 @@ async function callWebRuntimeSessionTabMethod( await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId, { expectedEnvironmentPairingRevision: intentOwner.pairingRevision }) - if (args.requireConfirmedClose && result?.refused === true) { - return 'failed' - } } return 'applied' } catch (error) { diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts b/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts index 827695a4bc85..5e148cdeda8b 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts @@ -17,7 +17,6 @@ import { stubTerminalCreateEnvironment } from './web-runtime-session-test-harness' import { stubRuntimeEnvironmentBridge } from './runtime-environment-bridge-fixture' -import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' const mocks = vi.hoisted(() => ({ getState: vi.fn(), @@ -96,65 +95,9 @@ describe('createWebRuntimeSessionTerminal', () => { }) afterEach(() => { - replaceRuntimeEnvironmentRevisions([]) resetTerminalCreateEnvironment() }) - it.each([ - { pairedHostChanged: false, status: 'abandoned' }, - { pairedHostChanged: true, status: 'failed' } - ])('settles a closed destination on its original host ($pairedHostChanged)', async ({ - pairedHostChanged, - status - }) => { - replaceRuntimeEnvironmentRevisions([{ id: ENVIRONMENT_ID, createdAt: 1 }]) - const state = mocks.getState() - state.groupsByWorktree = { [WORKTREE_ID]: [{ id: 'chat-group' }] } - let releaseCreate: () => void = () => undefined - const createReply = new Promise((resolve) => { - releaseCreate = resolve - }) - const runtimeCall = vi.fn(async (request: { method: string }) => { - if (request.method === 'session.tabs.createTerminal') { - await createReply - return { id: 'create', ok: true, result: { tab: { id: 'host-tab-1' } } } - } - if (request.method === 'session.tabs.close') { - return { id: 'close', ok: true, result: { closed: true } } - } - return { id: 'list', ok: true, result: makeSnapshot() } - }) - stubRuntimeEnvironmentBridge({ call: runtimeCall }) - - const launch = createWebRuntimeSessionTerminal({ - worktreeId: WORKTREE_ID, - environmentId: ENVIRONMENT_ID, - targetGroupId: 'chat-group', - strictDestination: true - }) - await vi.waitFor(() => - expect(runtimeCall).toHaveBeenCalledWith( - expect.objectContaining({ method: 'session.tabs.createTerminal' }) - ) - ) - state.groupsByWorktree[WORKTREE_ID] = [] - if (pairedHostChanged) { - replaceRuntimeEnvironmentRevisions([{ id: ENVIRONMENT_ID, createdAt: 2 }]) - } - releaseCreate() - - expect((await launch).status).toBe(status) - const closes = runtimeCall.mock.calls.filter(([request]) => request.method === 'session.tabs.close') - expect(closes).toHaveLength(pairedHostChanged ? 0 : 1) - if (!pairedHostChanged) { - expect(closes[0]?.[0]).toMatchObject({ - selector: ENVIRONMENT_ID, - expectedEnvironmentPairingRevision: 1, - params: { tabId: 'host-tab-1', reason: 'user' } - }) - } - }) - it('keeps same-ID local and runtime worktrees on the selected runtime owner', async () => { const selectedHosts: (string | undefined)[] = [] mocks.setActiveWorktree.mockImplementation((_worktreeId: string, executionHostId?: string) => { diff --git a/src/renderer/src/runtime/web-runtime-session-types.ts b/src/renderer/src/runtime/web-runtime-session-types.ts index 13c9152fdacf..8664c9192ce9 100644 --- a/src/renderer/src/runtime/web-runtime-session-types.ts +++ b/src/renderer/src/runtime/web-runtime-session-types.ts @@ -12,7 +12,6 @@ import type { TuiAgent } from '../../../shared/tui-agent' export type WebRuntimeTerminalCreateOutcome = | { status: 'created' } - | { status: 'abandoned' } | { status: 'failed'; message: string } export type CreateWebRuntimeSessionTerminalArgs = { @@ -20,8 +19,6 @@ export type CreateWebRuntimeSessionTerminalArgs = { environmentId?: string | null afterTabId?: string targetGroupId?: string - /** A pane-scoped launch must retire a host terminal if this target closes in flight. */ - strictDestination?: boolean command?: string cwd?: string env?: Record diff --git a/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts b/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts index 6ec9d6833d99..bd46cc8eff1f 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts @@ -14,16 +14,20 @@ import { runRemoteAgentSessionLaunch } from './remote-agent-session-launch' import { unwrapRuntimeRpcResult } from './runtime-rpc-result' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' import { resolveWebRuntimeSessionEnvironmentId } from './web-runtime-session-workspace-routing' +import { recordWebSessionFocusIntent } from './web-session-focus-intent' import { forgetWebSessionTerminalPlacement, + recordWebSessionTerminalPlacement, webTerminalPlacementParentTabId } from './web-session-terminal-placement' import { toHostSessionTabId } from '../../../shared/terminal-surface-id' import { captureRuntimeEnvironmentCall, captureWebSessionIntentOwner, - isWebRuntimeSessionActive + isWebRuntimeSessionActive, + matchesWebSessionIntentOwner } from './web-runtime-session-environment' +import { refreshWebRuntimeSessionTabsSnapshot } from './web-runtime-session-snapshot' import type { CreatedAgentTerminalIdentity, CreatedWebRuntimeSessionTerminal, @@ -39,10 +43,7 @@ import { createdTerminalLeafId, readCreatedAgentTerminalIdentity } from './web-runtime-terminal-identity' -import { publishWebRuntimeTerminalPlacement } from './web-runtime-terminal-placement-settlement' -import { isAgentLaunchDestinationLive } from '@/lib/agent-launch-destination' -import { retireWebRuntimeTerminalForLostDestination } from './web-runtime-terminal-lost-destination-retirement' -import { createLegacyWebRuntimeTerminal } from './web-runtime-terminal-legacy-create' +import { settleWebRuntimeTerminalPlacement } from './web-runtime-terminal-placement-settlement' export async function createWebRuntimeSessionTerminalResult( args: CreateWebRuntimeSessionTerminalArgs @@ -65,13 +66,6 @@ export async function createWebRuntimeSessionTerminalResult( const intentOwner = captureWebSessionIntentOwner(environmentId) const callEnvironment = captureRuntimeEnvironmentCall(environmentId, intentOwner.pairingRevision) - if ( - args.strictDestination && - !isAgentLaunchDestinationLive(args.worktreeId, args.targetGroupId) - ) { - return { outcome: { status: 'abandoned' } } - } - let workspaceSelectionRollback: WebRuntimeSessionWorkspaceSelectionRollback | null = null if (args.selectWorktree !== false) { const previous = readActiveWorkspaceSelection() @@ -87,14 +81,6 @@ export async function createWebRuntimeSessionTerminalResult( let hostCreated = false let createdTabId: string | undefined let createdLeafId: string | undefined - const retireIfDestinationGone = (): Promise => - retireWebRuntimeTerminalForLostDestination({ - ...args, - environmentId, - intentOwner, - createdTabId, - workspaceSelectionRollback - }) try { const agent = args.launchAgent ?? args.agent const agentArgsOverride = @@ -220,10 +206,6 @@ export async function createWebRuntimeSessionTerminalResult( createdLeafId = legacyAlreadyPlacedInGroup ? created.terminal.leafId : createdTerminalLeafId(created.terminal) - const abandoned = await retireIfDestinationGone() - if (abandoned) { - return abandoned - } if (args.targetGroupId && createdTabId && !legacyAlreadyPlacedInGroup) { await callEnvironment({ method: 'session.tabs.move', @@ -237,31 +219,76 @@ export async function createWebRuntimeSessionTerminalResult( }) } } else { - const created = await createLegacyWebRuntimeTerminal(args, callEnvironment) + const response = await callEnvironment({ + method: 'session.tabs.createTerminal', + params: { + worktree: toRuntimeWorktreeSelector(args.worktreeId), + afterTabId: args.afterTabId ? toHostSessionTabId(args.afterTabId) : undefined, + targetGroupId: args.targetGroupId, + command: args.command, + cwd: args.cwd, + ...(args.env ? { env: args.env } : {}), + ...(args.envToDelete ? { envToDelete: args.envToDelete } : {}), + startupCommandDelivery: args.startupCommandDelivery, + ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), + ...(args.launchToken ? { launchToken: args.launchToken } : {}), + ...(args.viewMode ? { viewMode: args.viewMode } : {}), + // Why: old hosts understand activate:false; new hosts use select/navigation for caller-local focus. + activate: false, + select: args.activate !== false, + navigation: 'caller' + }, + timeoutMs: 15_000 + }) + const created = unwrapRuntimeRpcResult( + response as RuntimeRpcResponse + ) hostCreated = true createdTabId = created.tab.id createdLeafId = created.tab.leafId - const abandoned = await retireIfDestinationGone() - if (abandoned) { - return abandoned - } } - await publishWebRuntimeTerminalPlacement(args, environmentId, intentOwner, createdTabId, createdLeafId) - const abandoned = await retireIfDestinationGone() - if (abandoned) { - return abandoned + if (args.targetGroupId && createdTabId) { + // Why: the host drops client-minted group ids, so this client's own record is what + // lands the mirrored tab in the requested pane under client-owned placement. + recordWebSessionTerminalPlacement({ + environmentId, + worktreeId: args.worktreeId, + hostTabId: webTerminalPlacementParentTabId(createdTabId), + groupId: args.targetGroupId + }) + } + if (args.activate !== false && createdTabId && matchesWebSessionIntentOwner(intentOwner)) { + // Why: record focus intent so the reconcile follows the snapshot's active + // tab to THIS new terminal, instead of sticky-keeping the prior tab. + recordWebSessionFocusIntent(intentOwner, args.worktreeId, createdTabId, createdLeafId) + } + const placementTabId = + createdTabId && (args.targetGroupId || args.afterTabId) ? createdTabId : undefined + await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId, { + expectedEnvironmentPairingRevision: intentOwner.pairingRevision, + // Why: the publication can beat the RPC response; replay it once after caller intent exists. + acceptCurrentSnapshot: + Boolean(createdTabId) && (args.activate !== false || Boolean(placementTabId)), + // Why: a placement record needs a post-create list; a deduped in-flight one can predate it. + ...(placementTabId ? { afterCurrentInFlight: true } : {}) + }) + if (placementTabId) { + await settleWebRuntimeTerminalPlacement( + environmentId, + args.worktreeId, + webTerminalPlacementParentTabId(placementTabId), + { + groupId: args.targetGroupId, + afterTabId: args.afterTabId, + activate: args.activate !== false + } + ) } return { outcome: { status: 'created' }, ...(createdTabId ? { hostTabId: createdTabId } : {}) } } catch (error) { - if (hostCreated) { - const abandoned = await retireIfDestinationGone() - if (abandoned) { - return abandoned - } - } const message = error instanceof Error ? error.message : String(error) console.warn( hostCreated diff --git a/src/renderer/src/runtime/web-runtime-terminal-creation.ts b/src/renderer/src/runtime/web-runtime-terminal-creation.ts index bbe81a65c7a9..42567f76ba52 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-creation.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-creation.ts @@ -28,7 +28,7 @@ export async function createWebRuntimeAgentSessionTerminal( promptDelivered: boolean }> { const created = await createWebRuntimeSessionTerminalResult(args) - if (created.outcome.status !== 'created' || !created.hostTabId) { + if (created.outcome.status === 'failed' || !created.hostTabId) { return { outcome: created.outcome, promptDelivered: false } } @@ -54,7 +54,7 @@ export async function createWebRuntimeAgentSessionTerminalWithLaunchDraft( } ): Promise { const created = await createWebRuntimeSessionTerminalResult(args) - if (created.outcome.status === 'created' && created.hostTabId) { + if (created.outcome.status !== 'failed' && created.hostTabId) { seedNativeChatLaunchDraftForAgentTab({ tabId: toWebTerminalSurfaceTabId(created.hostTabId), agent: args.agent, diff --git a/src/renderer/src/runtime/web-runtime-terminal-legacy-create.ts b/src/renderer/src/runtime/web-runtime-terminal-legacy-create.ts deleted file mode 100644 index 05fc1624d234..000000000000 --- a/src/renderer/src/runtime/web-runtime-terminal-legacy-create.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' -import type { RuntimeMobileSessionCreateTerminalResult } from '../../../shared/runtime-types' -import { toHostSessionTabId } from '../../../shared/terminal-surface-id' -import { unwrapRuntimeRpcResult } from './runtime-rpc-result' -import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' -import type { CreateWebRuntimeSessionTerminalArgs } from './web-runtime-session-types' - -export async function createLegacyWebRuntimeTerminal( - args: CreateWebRuntimeSessionTerminalArgs, - callEnvironment: (request: { - method: string - params?: unknown - timeoutMs?: number - }) => Promise> -): Promise { - const response = await callEnvironment({ - method: 'session.tabs.createTerminal', - params: { - worktree: toRuntimeWorktreeSelector(args.worktreeId), - afterTabId: args.afterTabId ? toHostSessionTabId(args.afterTabId) : undefined, - targetGroupId: args.targetGroupId, - command: args.command, - cwd: args.cwd, - ...(args.env ? { env: args.env } : {}), - ...(args.envToDelete ? { envToDelete: args.envToDelete } : {}), - startupCommandDelivery: args.startupCommandDelivery, - ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), - ...(args.launchToken ? { launchToken: args.launchToken } : {}), - ...(args.viewMode ? { viewMode: args.viewMode } : {}), - // Old hosts use activate:false; new hosts use select/navigation for client-local focus. - activate: false, - select: args.activate !== false, - navigation: 'caller' - }, - timeoutMs: 15_000 - }) - return unwrapRuntimeRpcResult( - response as RuntimeRpcResponse - ) -} diff --git a/src/renderer/src/runtime/web-runtime-terminal-lost-destination-retirement.ts b/src/renderer/src/runtime/web-runtime-terminal-lost-destination-retirement.ts deleted file mode 100644 index 801b259a1ca6..000000000000 --- a/src/renderer/src/runtime/web-runtime-terminal-lost-destination-retirement.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { isAgentLaunchDestinationLive } from '@/lib/agent-launch-destination' -import { translate } from '../i18n/i18n' -import { clearWebSessionFocusIntentIfMatches } from './web-session-focus-intent' -import { forgetWebSessionTerminalPlacement, webTerminalPlacementParentTabId } from './web-session-terminal-placement' -import type { WebSessionIntentOwner } from './web-session-intent-owner' -import type { CreatedWebRuntimeSessionTerminal } from './web-runtime-session-types' -import { - restoreActiveWorkspaceSelection, - type WebRuntimeSessionWorkspaceSelectionRollback -} from './web-runtime-session-workspace-selection' - -export async function retireWebRuntimeTerminalForLostDestination(args: { - strictDestination?: boolean - worktreeId: string - targetGroupId?: string - environmentId: string - intentOwner: WebSessionIntentOwner - createdTabId?: string - workspaceSelectionRollback: WebRuntimeSessionWorkspaceSelectionRollback | null -}): Promise { - if ( - !args.strictDestination || - isAgentLaunchDestinationLive(args.worktreeId, args.targetGroupId) - ) { - return null - } - const cleanupFailureMessage = translate( - 'auto.runtime.webRuntimeSession.abandonedTerminalCleanupFailed', - 'The pane closed, but Orca could not confirm its terminal closed on the host.' - ) - if (args.workspaceSelectionRollback) { - restoreActiveWorkspaceSelection(args.workspaceSelectionRollback) - } - if (!args.createdTabId) { - return { outcome: { status: 'failed', message: cleanupFailureMessage } } - } - const hostTabId = webTerminalPlacementParentTabId(args.createdTabId) - forgetWebSessionTerminalPlacement({ - environmentId: args.environmentId, - worktreeId: args.worktreeId, - hostTabId - }) - clearWebSessionFocusIntentIfMatches(args.intentOwner, args.worktreeId, args.createdTabId) - try { - const { closeWebRuntimeSessionTab } = await import('./web-runtime-session-tab-lifecycle') - const close = await closeWebRuntimeSessionTab({ - worktreeId: args.worktreeId, - tabId: hostTabId, - environmentId: args.environmentId, - intentOwner: args.intentOwner, - requireConfirmedClose: true, - reason: 'user' - }) - if (close !== 'failed') { - return { outcome: { status: 'abandoned' } } - } - } catch (error) { - console.warn('[web-runtime-session] terminal retirement failed:', error) - } - console.warn('[web-runtime-session] could not retire terminal after its pane closed') - return { outcome: { status: 'failed', message: cleanupFailureMessage } } -} diff --git a/src/renderer/src/runtime/web-runtime-terminal-placement-settlement.ts b/src/renderer/src/runtime/web-runtime-terminal-placement-settlement.ts index 167fb9903ce0..d82fdcfefc63 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-placement-settlement.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-placement-settlement.ts @@ -1,63 +1,15 @@ import { insertUnifiedTabAfterAnchor } from '../lib/unified-tab-anchor-insertion' -import { isAgentLaunchDestinationLive } from '../lib/agent-launch-destination' import { useAppStore } from '../store' -import { recordWebSessionFocusIntent } from './web-session-focus-intent' import { forgetWebSessionTerminalPlacement, - recordWebSessionTerminalPlacement, webTerminalPlacementParentTabId } from './web-session-terminal-placement' -import { matchesWebSessionIntentOwner } from './web-runtime-session-environment' -import { refreshWebRuntimeSessionTabsSnapshot } from './web-runtime-session-snapshot' -import type { WebSessionIntentOwner } from './web-session-intent-owner' -import type { CreateWebRuntimeSessionTerminalArgs } from './web-runtime-session-types' import { isWebTerminalSurfaceTabId, toHostSessionTabId, toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' -export async function publishWebRuntimeTerminalPlacement( - args: CreateWebRuntimeSessionTerminalArgs, - environmentId: string, - intentOwner: WebSessionIntentOwner, - createdTabId?: string, - createdLeafId?: string -): Promise { - if (args.targetGroupId && createdTabId) { - recordWebSessionTerminalPlacement({ - environmentId, - worktreeId: args.worktreeId, - hostTabId: webTerminalPlacementParentTabId(createdTabId), - groupId: args.targetGroupId - }) - } - if (args.activate !== false && createdTabId && matchesWebSessionIntentOwner(intentOwner)) { - recordWebSessionFocusIntent(intentOwner, args.worktreeId, createdTabId, createdLeafId) - } - const placementTabId = - createdTabId && (args.targetGroupId || args.afterTabId) ? createdTabId : undefined - await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId, { - expectedEnvironmentPairingRevision: intentOwner.pairingRevision, - acceptCurrentSnapshot: - Boolean(createdTabId) && (args.activate !== false || Boolean(placementTabId)), - ...(placementTabId ? { afterCurrentInFlight: true } : {}) - }) - if (placementTabId) { - await settleWebRuntimeTerminalPlacement( - environmentId, - args.worktreeId, - webTerminalPlacementParentTabId(placementTabId), - { - groupId: args.targetGroupId, - afterTabId: args.afterTabId, - activate: args.activate !== false, - ...(args.strictDestination ? { strictDestination: true } : {}) - } - ) - } -} - /** Snapshots key mirrored terminals by the parent tab, so an unknown `parent::leaf` anchor resolves to its parent. */ function anchorUnifiedTabId(worktreeId: string, afterTabId: string): string { const known = (useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).some( @@ -73,7 +25,7 @@ export async function settleWebRuntimeTerminalPlacement( environmentId: string, worktreeId: string, hostTabId: string, - placement: { groupId?: string; afterTabId?: string; activate: boolean; strictDestination?: boolean } + placement: { groupId?: string; afterTabId?: string; activate: boolean } ): Promise { const unifiedTabId = toWebTerminalSurfaceTabId(hostTabId) const findTab = () => @@ -83,17 +35,8 @@ export async function settleWebRuntimeTerminalPlacement( try { const deadline = Date.now() + 10_000 while (!findTab() && Date.now() < deadline) { - if ( - placement.strictDestination && - !isAgentLaunchDestinationLive(worktreeId, placement.groupId) - ) { - return - } await new Promise((resolve) => setTimeout(resolve, 250)) } - if (placement.strictDestination && !isAgentLaunchDestinationLive(worktreeId, placement.groupId)) { - return - } const tab = findTab() if (!tab) { return From 53c3dbe62bfa5ebd207e85fb2eaca4de08e15bea Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:10:47 -0700 Subject: [PATCH 24/42] fix(chat): reuse tab menu Resume placement unchanged --- .../native-chat/NativeChatPaneHeader.tsx | 1 - .../NativeChatResumeInCliButton.test.tsx | 4 +--- .../NativeChatResumeInCliButton.tsx | 5 +--- .../ai-vault-session-launch-actions.test.ts | 24 ------------------- .../ai-vault-session-launch-actions.ts | 14 +++-------- 5 files changed, 5 insertions(+), 43 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx b/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx index 17a95ebcfca6..5914e0d90c76 100644 --- a/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx @@ -51,7 +51,6 @@ export function NativeChatPaneHeader({ ) : null} diff --git a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.test.tsx b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.test.tsx index 4a6ace2499be..c3801b73c9ec 100644 --- a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.test.tsx @@ -56,7 +56,6 @@ it.each([true, false])('finishes Resume after source chat closes (pane survives= @@ -74,7 +73,6 @@ it.each([true, false])('finishes Resume after source chat closes (pane survives= await waitFor(() => expect(mocks.resume).toHaveBeenCalledOnce()) expect(mocks.resume).toHaveBeenCalledWith( { id: 'saved-row' }, - 'worktree-1', - 'chat-group' + 'worktree-1' ) }) diff --git a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx index a1e795dcf4bd..0866e48a0416 100644 --- a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx @@ -17,12 +17,10 @@ import { export function NativeChatResumeInCliButton({ tabId, worktreeId, - groupId, nativeChat }: { tabId: string worktreeId: string - groupId: string nativeChat: NativeChatTabSession }): React.JSX.Element { const [pending, setPending] = useState(false) @@ -39,7 +37,6 @@ export function NativeChatResumeInCliButton({ ) ) const resume = async (): Promise => { - const targetGroupId = groupId const subject = resolveTabSessionHistorySubject(useAppStore.getState(), { tab: { id: tabId, worktreeId }, nativeChat @@ -59,7 +56,7 @@ export function NativeChatResumeInCliButton({ ) const move = row ? resolveTabSessionSwitch(useAppStore.getState(), row, subject) : null if (row && move?.action === 'resume-in-new-cli') { - launch.handleResumeInNewCli(row, move.worktreeId, targetGroupId) + launch.handleResumeInNewCli(row, move.worktreeId) } else { unavailable() } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts index 63c2aacd0288..36b9ba0dc510 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.test.ts @@ -153,30 +153,6 @@ describe('Resume in New CLI launch', () => { expect(mocks.toastError).not.toHaveBeenCalled() }) - it('keeps the chosen chat group while fork preparation and focus changes are pending', async () => { - let finishFork: ((value: { session: AiVaultSession; copied: boolean }) => void) | undefined - mocks.prepareAiVaultSessionForFork.mockReturnValue( - new Promise((resolve) => { - finishFork = resolve - }) - ) - renderActions().handleResumeInNewCli(owned, 'worktree-1', 'chat-group') - mocks.getState.mockReturnValue({ - activeWorktreeId: 'worktree-1', - activeGroupIdByWorktree: { 'worktree-1': 'other-group' }, - settings: {} - }) - finishFork?.({ session: owned, copied: false }) - - await waitFor(() => expect(mocks.launchAiVaultSessionInNewTab).toHaveBeenCalledOnce()) - expect(mocks.launchAiVaultSessionInNewTab).toHaveBeenCalledWith({ - agent: 'claude', - worktreeId: 'worktree-1', - targetGroupId: 'chat-group', - ...FORK_STARTUP - }) - }) - it.each(['omp', 'opencode2'] as const)( 'opens the %s copy with the actual CLI in the chosen workspace', async (agent) => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index e2443ec3f463..d31800c40fa8 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -104,8 +104,7 @@ export function useAiVaultSessionLaunchActions({ session: AiVaultSession, targetWorktreeId: string | undefined, prepareStartup: (worktreeId: string) => Promise, - describeFailure: (message: string) => string = (message) => message, - targetGroupId?: string + describeFailure: (message: string) => string = (message) => message ): void => { const targetId = resolveAiVaultSessionLaunchTargetOrNotify({ sessionFilePath: session.filePath, @@ -122,7 +121,6 @@ export function useAiVaultSessionLaunchActions({ const launchResult = launchAiVaultSessionInNewTab({ agent: session.agent, worktreeId: targetId.worktreeId, - ...(targetGroupId ? { targetGroupId } : {}), ...startup }) if (launchResult.tabId === null) { @@ -178,7 +176,7 @@ export function useAiVaultSessionLaunchActions({ // Native chat keeps the conversation it owns; the terminal gets a copy (see the fork builder). const handleResumeInNewCli = useCallback( - (session: AiVaultSession, targetWorktreeId: string, targetGroupId?: string): void => { + (session: AiVaultSession, targetWorktreeId: string): void => { const prepareStartup = async (worktreeId: string): Promise => { assertAiVaultCopiedSessionLaunchAllowed({ state: useAppStore.getState(), @@ -215,13 +213,7 @@ export function useAiVaultSessionLaunchActions({ } return startup } - launchInTerminal( - session, - targetWorktreeId, - prepareStartup, - describeAiVaultCliForkFailure, - targetGroupId - ) + launchInTerminal(session, targetWorktreeId, prepareStartup, describeAiVaultCliForkFailure) }, [agentCmdOverrides, launchInTerminal] ) From 81242ac2c0825ce5266bb87358b46dbca2984242 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:43:11 -0700 Subject: [PATCH 25/42] test: fix Resume regression harness setup --- .../native-chat/NativeChatResumeInCliButton.test.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.test.tsx b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.test.tsx index c3801b73c9ec..702c672d9b21 100644 --- a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { beforeEach, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' const mocks = vi.hoisted(() => ({ @@ -26,6 +26,8 @@ vi.mock('../tab-bar/tab-session-history-switch', () => ({ import { NativeChatResumeInCliButton } from './NativeChatResumeInCliButton' +afterEach(cleanup) + beforeEach(() => { vi.clearAllMocks() mocks.resolveSubject.mockReturnValue({ workspaceId: 'worktree-1', kind: 'chat' }) @@ -69,7 +71,7 @@ it.each([true, false])('finishes Resume after source chat closes (pane survives= } releaseHistory() - await waitFor(() => expect(button).not.toBeDisabled()) + await waitFor(() => expect(button.hasAttribute('disabled')).toBe(false)) await waitFor(() => expect(mocks.resume).toHaveBeenCalledOnce()) expect(mocks.resume).toHaveBeenCalledWith( { id: 'saved-row' }, From ca269e183dee91c4652c344480d5fd80df44cde8 Mon Sep 17 00:00:00 2001 From: Orca Worker Date: Thu, 15 Oct 2026 15:08:29 -0700 Subject: [PATCH 26/42] fix(chat): hand input to the split pane during admission --- .../native-chat/native-chat-layout-actions.ts | 11 ++++++- .../native-chat-split-flow.test.ts | 30 ++++++++++++++++++- .../components/tab-group/TabGroupPanel.tsx | 3 +- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-layout-actions.ts b/src/renderer/src/components/native-chat/native-chat-layout-actions.ts index 2a5b8988059a..8cfa13d75c91 100644 --- a/src/renderer/src/components/native-chat/native-chat-layout-actions.ts +++ b/src/renderer/src/components/native-chat/native-chat-layout-actions.ts @@ -1,3 +1,4 @@ +import { flushSync } from 'react-dom' import type { AppState } from '@/store/types' import { useAppStore } from '@/store' import { isTuiAgent } from '../../../../shared/tui-agent-config' @@ -84,11 +85,19 @@ export function runNativeChatSplitTarget( return false } const focusIntent = captureNativeChatComposerFocusIntent() - const groupId = state.createEmptySplitGroup(source.worktreeId, target.groupId, direction) + const groupId = flushSync(() => + state.createEmptySplitGroup(source.worktreeId, target.groupId, direction) + ) if (!groupId) { focusIntent.cancel() return false } + // Host admission can outlive this action; the source draft must stop owning keyboard input. + if (typeof document !== 'undefined') { + document + .querySelector(`[data-tab-group-body-id="${groupId}"]`) + ?.focus({ preventScroll: true }) + } try { const launch = beginStructuredAgentSessionProvisionalLaunch({ plan: adoptAgentSessionLaunchVerdict({ diff --git a/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts b/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts index 1ed9929a6f32..7f554f4dd2c3 100644 --- a/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts @@ -1,4 +1,8 @@ -import { expect, it, vi } from 'vitest' +// @vitest-environment happy-dom +import { expect, it, onTestFinished, vi } from 'vitest' +import { createElement, useSyncExternalStore } from 'react' +import { flushSync } from 'react-dom' +import { createRoot } from 'react-dom/client' import { createTestStore } from '@/store/slices/store-test-helpers' import { createTabsSliceMockApi } from '@/store/slices/tabs-slice-test-harness' import { isEmptyWorkspaceDefaultSurfacePending } from '@/lib/empty-workspace-default-surface-claims' @@ -49,9 +53,33 @@ it.each(['right', 'down'] as const)( unifiedTabId: source.id, groupId: source.groupId } as const + const sourceField = document.body.appendChild(document.createElement('textarea')) + sourceField.value = 'Source draft' + const container = document.body.appendChild(document.createElement('div')) + function SplitBodies() { + const groupId = useSyncExternalStore( + store.subscribe, + () => store.getState().activeGroupIdByWorktree[worktreeId] + ) + return createElement('div', { 'data-tab-group-body-id': groupId, tabIndex: -1 }) + } + const root = createRoot(container) + onTestFinished(() => { + root.unmount() + container.remove() + sourceField.remove() + }) + flushSync(() => root.render(createElement(SplitBodies))) + sourceField.focus() + const initialAdmission = Promise.withResolvers<{ supported: boolean }>() + mocks.createSupport.mockReturnValueOnce(initialAdmission.promise) expect(runNativeChatSplitTarget(target, direction)).toBe(true) const state = store.getState() const groupId = state.activeGroupIdByWorktree[worktreeId] + expect(document.activeElement?.getAttribute('data-tab-group-body-id')).toBe(groupId) + expect(sourceField.value).toBe('Source draft') + expect(mocks.begin).not.toHaveBeenCalled() + initialAdmission.resolve({ supported: true }) expect(state.layoutByWorktree[worktreeId]).toMatchObject({ type: 'split', direction: direction === 'right' ? 'horizontal' : 'vertical', diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index 5835d8e6aa7e..a04ed7ae7593 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -370,7 +370,8 @@ export default function TabGroupPanel({ ref={setBodyRef} data-tab-group-body-id={groupId} data-worktree-id={worktreeId} - className="relative flex-1 min-h-0 overflow-hidden" + tabIndex={-1} + className="relative flex-1 min-h-0 overflow-hidden focus-visible:outline-none" style={bodyAnchorStyle} > {/* Why: empty anchor so the agent-sessions tour reads as a terminal-area tip, not toolbar chrome. */} From 8cb9fd347e436951a665f992ec7eb145c42e729e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:50:50 -0700 Subject: [PATCH 27/42] fix(chat): Split opens a fresh chat beside the current one Split Right/Down on a native chat used to move the chat into a new pane. It now opens an empty pane beside the chat and launches a new chat of the same agent on the same host into it through the New Chat launch, so the new composer takes focus at once. A launch that never opens a tab closes the empty pane. Drops the earlier admission and focus-transfer changes that main now covers. --- .../cmd-j/native-chat-split-quick-actions.ts | 4 +- .../cmd-j/quick-action-context.test.ts | 6 +- .../native-chat-layout-actions.test.ts | 1 - .../native-chat/native-chat-layout-actions.ts | 70 ++++------ .../native-chat-split-flow.test.ts | 127 ++++-------------- .../components/tab-group/TabGroupPanel.tsx | 3 +- src/renderer/src/i18n/locales/en.json | 4 +- src/renderer/src/i18n/locales/es.json | 4 +- src/renderer/src/i18n/locales/fr.json | 4 +- src/renderer/src/i18n/locales/ja.json | 4 +- src/renderer/src/i18n/locales/ko.json | 4 +- src/renderer/src/i18n/locales/zh.json | 4 +- .../src/lib/agent-launch-destination.ts | 11 -- .../lib/agent-launch-funnel-test-harness.ts | 3 - ...red-agent-session-launch-admission.test.ts | 5 +- 15 files changed, 71 insertions(+), 183 deletions(-) delete mode 100644 src/renderer/src/lib/agent-launch-destination.ts diff --git a/src/renderer/src/components/cmd-j/native-chat-split-quick-actions.ts b/src/renderer/src/components/cmd-j/native-chat-split-quick-actions.ts index d2019fb429a9..1868e937f367 100644 --- a/src/renderer/src/components/cmd-j/native-chat-split-quick-actions.ts +++ b/src/renderer/src/components/cmd-j/native-chat-split-quick-actions.ts @@ -34,7 +34,7 @@ export function getNativeChatSplitQuickActions(): CmdJQuickAction[] { title: translate('auto.components.cmd.j.quick.actions.splitChatRight', 'Split Chat Right'), description: translate( 'auto.components.cmd.j.quick.actions.splitChatRightDescription', - 'Open the active chat in a split pane to the right.' + 'Open a new chat in a split pane to the right.' ), icon: PanelRightClose, verbKeywords: [ @@ -48,7 +48,7 @@ export function getNativeChatSplitQuickActions(): CmdJQuickAction[] { title: translate('auto.components.cmd.j.quick.actions.splitChatDown', 'Split Chat Down'), description: translate( 'auto.components.cmd.j.quick.actions.splitChatDownDescription', - 'Open the active chat in a split pane below.' + 'Open a new chat in a split pane below.' ), icon: PanelBottomClose, verbKeywords: [ diff --git a/src/renderer/src/components/cmd-j/quick-action-context.test.ts b/src/renderer/src/components/cmd-j/quick-action-context.test.ts index c92f998380b0..c8cd9e23cb53 100644 --- a/src/renderer/src/components/cmd-j/quick-action-context.test.ts +++ b/src/renderer/src/components/cmd-j/quick-action-context.test.ts @@ -290,16 +290,14 @@ describe('Cmd+J quick action context', () => { entityId: 'session-1', groupId: 'group-1', worktreeId: 'wt-1', - contentType: 'agent-session' + contentType: 'agent-session', + agentSessionAgent: 'codex' } ] }, activeView: 'settings', settings: null } as unknown as AppState - state.unifiedTabsByWorktree['wt-1']?.forEach((tab) => { - tab.agentSessionAgent = 'codex' - }) const buildContext = (activeView: AppState['activeView']) => buildCmdJQuickActionContext({ diff --git a/src/renderer/src/components/native-chat/native-chat-layout-actions.test.ts b/src/renderer/src/components/native-chat/native-chat-layout-actions.test.ts index 53f6c7c6a557..fb860bec07a9 100644 --- a/src/renderer/src/components/native-chat/native-chat-layout-actions.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-layout-actions.test.ts @@ -31,7 +31,6 @@ describe('native chat layout actions', () => { expect(target).toEqual({ kind: 'workspace-tab', unifiedTabId: 'chat', groupId: 'group' }) expect(canRunNativeChatSplitTarget(state, target)).toBe(true) - }) it('resolves terminal-backed chat mode to the existing pane split path', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-layout-actions.ts b/src/renderer/src/components/native-chat/native-chat-layout-actions.ts index 8cfa13d75c91..64d8369c0e41 100644 --- a/src/renderer/src/components/native-chat/native-chat-layout-actions.ts +++ b/src/renderer/src/components/native-chat/native-chat-layout-actions.ts @@ -1,11 +1,8 @@ -import { flushSync } from 'react-dom' import type { AppState } from '@/store/types' import { useAppStore } from '@/store' import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' -import { adoptAgentSessionLaunchVerdict } from '@/lib/agent-session-launch-plan' import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' -import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' +import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import { captureNativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' import { requestActiveTerminalPaneSplit } from '@/components/tab-bar/request-active-terminal-pane-split' import type { NativeChatSplitDirection } from './native-chat-split-shortcut' @@ -75,54 +72,37 @@ export function runNativeChatSplitTarget( }) return true } + // Captured first so focus follows this action into the new chat's composer. + const focusIntent = captureNativeChatComposerFocusIntent() const state = useAppStore.getState() const source = findSplitSource(state, target) - if (!source) { - return false - } - const executionHostId = source.tab.executionHostId - if (!executionHostId || !structuredAgentSessionTargetForHost(executionHostId)) { - return false - } - const focusIntent = captureNativeChatComposerFocusIntent() - const groupId = flushSync(() => - state.createEmptySplitGroup(source.worktreeId, target.groupId, direction) - ) - if (!groupId) { + const groupId = source + ? state.createEmptySplitGroup(source.worktreeId, target.groupId, direction) + : null + if (!source || !groupId) { focusIntent.cancel() return false } - // Host admission can outlive this action; the source draft must stop owning keyboard input. - if (typeof document !== 'undefined') { - document - .querySelector(`[data-tab-group-body-id="${groupId}"]`) - ?.focus({ preventScroll: true }) + const closePane = (): void => { + useAppStore.getState().closeEmptyGroup(source.worktreeId, groupId) } - try { - const launch = beginStructuredAgentSessionProvisionalLaunch({ - plan: adoptAgentSessionLaunchVerdict({ - route: 'structured-native-chat', - requestId: newAgentLaunchRequestId(), - agent: source.agent, - worktreeId: source.worktreeId, - executionHostId - }), - hooks: {}, - focusIntent, - onHostDeclined: null, - targetGroupId: groupId - }) - if (!launch) { - state.closeEmptyGroup(source.worktreeId, groupId) - return false - } - // The normal new-chat launch founds an idle conversation; sending starts the agent. - void launch.settlement.finally(() => state.closeEmptyGroup(source.worktreeId, groupId)) - return true - } catch (error) { - state.closeEmptyGroup(source.worktreeId, groupId) - throw error + // Same launch as New Chat, so the fresh chat opens at once in the new pane. + const result = launchAgentInNewTab({ + requestId: newAgentLaunchRequestId(), + focusIntent, + requestedSurface: 'chat', + selectedExecutionHostId: source.tab.executionHostId, + agent: source.agent, + worktreeId: source.worktreeId, + groupId + }) + if (!result) { + closePane() + return false } + // Closes only a pane no tab ever reached; a chat or declined terminal keeps it. + void result.structuredSettlement?.then(closePane, closePane) + return true } export function runActiveNativeChatSplit( diff --git a/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts b/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts index 7f554f4dd2c3..22d3d8c36a40 100644 --- a/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts @@ -1,124 +1,51 @@ // @vitest-environment happy-dom -import { expect, it, onTestFinished, vi } from 'vitest' -import { createElement, useSyncExternalStore } from 'react' -import { flushSync } from 'react-dom' -import { createRoot } from 'react-dom/client' +import { expect, it, vi } from 'vitest' import { createTestStore } from '@/store/slices/store-test-helpers' import { createTabsSliceMockApi } from '@/store/slices/tabs-slice-test-harness' -import { isEmptyWorkspaceDefaultSurfacePending } from '@/lib/empty-workspace-default-surface-claims' let store: ReturnType -const mocks = vi.hoisted(() => ({ - begin: vi.fn(), - createSupport: vi.fn(), - launchCli: vi.fn(), - error: vi.fn(), - info: vi.fn() -})) +const launch = vi.hoisted(() => vi.fn()) vi.mock('@/store', () => ({ useAppStore: { getState: () => store.getState() } })) -vi.mock('sonner', () => ({ toast: { error: mocks.error, info: mocks.info } })) -vi.mock('@/lib/structured-agent-launch-settlement', () => ({ - beginStructuredAgentLaunchSettlement: mocks.begin -})) -vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: mocks.launchCli })) -vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: mocks.createSupport -})) +vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: launch })) createTabsSliceMockApi() import { runNativeChatSplitTarget } from './native-chat-layout-actions' it.each(['right', 'down'] as const)( - 'splits %s on the owning host, refusing declines and abandoning closed destinations', - async (direction) => { - vi.clearAllMocks() + 'Split %s opens a fresh chat beside the current one', + (direction) => { + launch.mockReset() store = createTestStore() const worktreeId = 'folder-workspace' const executionHostId = 'runtime:remote-host' const source = store.getState().createUnifiedTab(worktreeId, 'agent-session', { - entityId: 'original-session', agentSessionAgent: 'codex', executionHostId }) - mocks.createSupport.mockResolvedValue({ supported: true }) - mocks.begin.mockReturnValue({ - sessionId: 'fresh-session', - executionHostId, - settlement: Promise.resolve({ kind: 'structured', sessionId: 'fresh-session' }), - cancel: vi.fn() - }) - - const target = { - kind: 'workspace-tab', - unifiedTabId: source.id, - groupId: source.groupId - } as const - const sourceField = document.body.appendChild(document.createElement('textarea')) - sourceField.value = 'Source draft' - const container = document.body.appendChild(document.createElement('div')) - function SplitBodies() { - const groupId = useSyncExternalStore( - store.subscribe, - () => store.getState().activeGroupIdByWorktree[worktreeId] - ) - return createElement('div', { 'data-tab-group-body-id': groupId, tabIndex: -1 }) - } - const root = createRoot(container) - onTestFinished(() => { - root.unmount() - container.remove() - sourceField.remove() - }) - flushSync(() => root.render(createElement(SplitBodies))) - sourceField.focus() - const initialAdmission = Promise.withResolvers<{ supported: boolean }>() - mocks.createSupport.mockReturnValueOnce(initialAdmission.promise) + const { id: unifiedTabId, groupId: sourceGroupId } = source + const target = { kind: 'workspace-tab', unifiedTabId, groupId: sourceGroupId } as const + launch.mockReturnValue({ surface: { kind: 'local-chat', tabId: 'fresh' } }) expect(runNativeChatSplitTarget(target, direction)).toBe(true) - const state = store.getState() - const groupId = state.activeGroupIdByWorktree[worktreeId] - expect(document.activeElement?.getAttribute('data-tab-group-body-id')).toBe(groupId) - expect(sourceField.value).toBe('Source draft') - expect(mocks.begin).not.toHaveBeenCalled() - initialAdmission.resolve({ supported: true }) - expect(state.layoutByWorktree[worktreeId]).toMatchObject({ - type: 'split', + const groupId = store.getState().activeGroupIdByWorktree[worktreeId] + expect(store.getState().layoutByWorktree[worktreeId]).toMatchObject({ direction: direction === 'right' ? 'horizontal' : 'vertical', - first: { type: 'leaf', groupId: source.groupId }, - second: { type: 'leaf', groupId } - }) - await vi.waitFor(() => - expect(store.getState().unifiedTabsByWorktree[worktreeId]).toMatchObject([ - source, - { entityId: 'fresh-session', agentSessionAgent: 'codex', executionHostId, groupId } - ]) - ) - expect(mocks.begin.mock.lastCall?.[2].executionHostId).toBe(executionHostId) - expect(mocks.begin.mock.lastCall?.[2].prompt).toBeUndefined() - const splitTabs = store.getState().unifiedTabsByWorktree[worktreeId] - - mocks.createSupport.mockResolvedValue({ supported: false }) - expect(runNativeChatSplitTarget(target, direction)).toBe(true) - await vi.waitFor(() => { - expect(mocks.error).toHaveBeenCalledExactlyOnceWith('Chat could not be started.') - expect(store.getState().layoutByWorktree[worktreeId]).toEqual( - state.layoutByWorktree[worktreeId] - ) + first: { groupId: sourceGroupId }, + second: { groupId } }) - expect(store.getState().unifiedTabsByWorktree[worktreeId]).toEqual(splitTabs) - const admission = Promise.withResolvers<{ supported: boolean }>() - mocks.createSupport.mockReturnValueOnce(admission.promise) - runNativeChatSplitTarget(target, direction) - store - .getState() - .closeEmptyGroup(worktreeId, store.getState().activeGroupIdByWorktree[worktreeId]) - admission.resolve({ supported: true }) - await vi.waitFor(() => expect(isEmptyWorkspaceDefaultSurfacePending(worktreeId)).toBe(false)) - expect(store.getState().unifiedTabsByWorktree[worktreeId]).toEqual(splitTabs) - expect(store.getState().layoutByWorktree[worktreeId]).toEqual( - state.layoutByWorktree[worktreeId] + expect(launch).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + requestedSurface: 'chat', + selectedExecutionHostId: executionHostId, + agent: 'codex', + worktreeId, + groupId, + focusIntent: expect.objectContaining({ cancel: expect.any(Function) }) + }) ) - expect(mocks.begin).toHaveBeenCalledOnce() - expect(mocks.launchCli).not.toHaveBeenCalled() - expect(mocks.info).not.toHaveBeenCalled() + expect(store.getState().unifiedTabsByWorktree[worktreeId]).toEqual([source]) + const layout = store.getState().layoutByWorktree[worktreeId] + launch.mockReturnValue(null) + expect(runNativeChatSplitTarget(target, direction)).toBe(false) + expect(store.getState().layoutByWorktree[worktreeId]).toEqual(layout) } ) diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index e38b552f54af..45c67f946a1a 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -324,8 +324,7 @@ export default function TabGroupPanel({ ref={setBodyRef} data-tab-group-body-id={groupId} data-worktree-id={worktreeId} - tabIndex={-1} - className="relative flex-1 min-h-0 overflow-hidden focus-visible:outline-none" + className="relative flex-1 min-h-0 overflow-hidden" style={bodyAnchorStyle} > {/* Why: empty anchor so the agent-sessions tour reads as a terminal-area tip, not toolbar chrome. */} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9fe49df8d8c1..690686025f41 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -16892,9 +16892,9 @@ "chatPaneBelow": "chat pane below" }, "splitChatRight": "Split Chat Right", - "splitChatRightDescription": "Open the active chat in a split pane to the right.", + "splitChatRightDescription": "Open a new chat in a split pane to the right.", "splitChatDown": "Split Chat Down", - "splitChatDownDescription": "Open the active chat in a split pane below." + "splitChatDownDescription": "Open a new chat in a split pane below." } }, "palette": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b3410096679c..cac7730562e9 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -16597,9 +16597,9 @@ "chatPaneBelow": "panel de chat abajo" }, "splitChatRight": "Dividir chat a la derecha", - "splitChatRightDescription": "Abre el chat activo en un panel dividido a la derecha.", + "splitChatRightDescription": "Abre un chat nuevo en un panel dividido a la derecha.", "splitChatDown": "Dividir chat abajo", - "splitChatDownDescription": "Abre el chat activo en un panel dividido abajo." + "splitChatDownDescription": "Abre un chat nuevo en un panel dividido abajo." } }, "palette": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index df4728c164fe..32263d9c765e 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -16710,9 +16710,9 @@ "chatPaneBelow": "volet de discussion ci-dessous" }, "splitChatRight": "Diviser le chat à droite", - "splitChatRightDescription": "Ouvrez le chat actif dans un volet divisé à droite.", + "splitChatRightDescription": "Ouvrez un nouveau chat dans un volet divisé à droite.", "splitChatDown": "Diviser la discussion", - "splitChatDownDescription": "Ouvrez le chat actif dans un volet divisé ci-dessous." + "splitChatDownDescription": "Ouvrez un nouveau chat dans un volet divisé ci-dessous." } }, "palette": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index af4b42e3f43b..d52ec701876c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -16602,9 +16602,9 @@ "chatPaneBelow": "下のチャットペイン" }, "splitChatRight": "右の分割チャット", - "splitChatRightDescription": "右側の分割ペインでアクティブなチャットを開きます。", + "splitChatRightDescription": "右側の分割ペインで新しいチャットを開きます。", "splitChatDown": "チャットを下に分割", - "splitChatDownDescription": "下の分割ペインでアクティブなチャットを開きます。" + "splitChatDownDescription": "下の分割ペインで新しいチャットを開きます。" } }, "palette": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3bf00086ee1d..a2032b34d913 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -16602,9 +16602,9 @@ "chatPaneBelow": "아래 채팅 창" }, "splitChatRight": "채팅을 오른쪽으로 분할", - "splitChatRightDescription": "오른쪽 분할 창에서 활성 채팅을 엽니다.", + "splitChatRightDescription": "오른쪽 분할 창에서 새 채팅을 엽니다.", "splitChatDown": "채팅 분할", - "splitChatDownDescription": "아래 분할 창에서 활성 채팅을 엽니다." + "splitChatDownDescription": "아래 분할 창에서 새 채팅을 엽니다." } }, "palette": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 663a43db52e1..e2411a80dcee 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -16602,9 +16602,9 @@ "chatPaneBelow": "下面的聊天窗格" }, "splitChatRight": "右侧拆分聊天", - "splitChatRightDescription": "在右侧的拆分窗格中打开活动聊天。", + "splitChatRightDescription": "在右侧的拆分窗格中打开新聊天。", "splitChatDown": "拆分聊天", - "splitChatDownDescription": "在下面的拆分窗格中打开活动聊天。" + "splitChatDownDescription": "在下面的拆分窗格中打开新聊天。" } }, "palette": { diff --git a/src/renderer/src/lib/agent-launch-destination.ts b/src/renderer/src/lib/agent-launch-destination.ts deleted file mode 100644 index 187c7a5aad06..000000000000 --- a/src/renderer/src/lib/agent-launch-destination.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useAppStore } from '@/store' - -/** A named pane is required; only launches without one may use default placement. */ -export function isAgentLaunchDestinationLive(worktreeId: string, groupId?: string): boolean { - return ( - groupId === undefined || - (useAppStore.getState().groupsByWorktree[worktreeId] ?? []).some( - (group) => group.id === groupId - ) - ) -} diff --git a/src/renderer/src/lib/agent-launch-funnel-test-harness.ts b/src/renderer/src/lib/agent-launch-funnel-test-harness.ts index d96f5e364db0..ec26a151a58d 100644 --- a/src/renderer/src/lib/agent-launch-funnel-test-harness.ts +++ b/src/renderer/src/lib/agent-launch-funnel-test-harness.ts @@ -40,7 +40,6 @@ export function createLaunchFunnelStore(): { repos: { id: string; connectionId: string | null; path: string }[] allWorktrees: ReturnType tabsByWorktree: Record - groupsByWorktree: Record openFiles: { id: string; worktreeId: string }[] browserTabsByWorktree: Record tabBarOrderByWorktree: Record @@ -60,7 +59,6 @@ export function createLaunchFunnelStore(): { repos: [], allWorktrees: vi.fn(() => []), tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, - groupsByWorktree: { 'wt-1': [{ id: 'group-1' }] }, openFiles: [], browserTabsByWorktree: {}, tabBarOrderByWorktree: {}, @@ -111,7 +109,6 @@ export function resetLaunchFunnelStore( store.settings = launchFunnelSettings(settingsOverrides) store.repos = [] store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } - store.groupsByWorktree = { 'wt-1': [{ id: 'group-1' }] } store.openFiles = [] store.browserTabsByWorktree = {} store.tabBarOrderByWorktree = {} diff --git a/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts b/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts index e13a00a099e4..be8c2303cc4d 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts @@ -280,12 +280,11 @@ describe('a structured chat launch on this machine', () => { it("opens a new agent tab's terminal with the typed prompt when the caller names none", async () => { mocks.createSupport.mockResolvedValue({ supported: false }) mocks.launchAgentInNewTab.mockReturnValue({ surface: { kind: 'local-terminal', tabId: 't' } }) - const groupId = useAppStore.getState().ensureWorktreeRootGroup(WORKTREE) const launch = beginStructuredAgentSessionProvisionalLaunch({ plan: localPlan(), hooks: {}, - targetGroupId: groupId, + targetGroupId: 'group-1', declinedTerminal: { agentArgs: '--model opus' } }) @@ -294,7 +293,7 @@ describe('a structured chat launch on this machine', () => { expect.objectContaining({ agent: 'claude', worktreeId: WORKTREE, - groupId, + groupId: 'group-1', prompt: 'fix the flaky test', agentArgs: '--model opus', agentSessionLaunchPlan: expect.objectContaining({ route: 'terminal-tui' }) From 46dfe6a55b7ac8f2713644084d56fb11624b0f8e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:53:19 -0700 Subject: [PATCH 28/42] fix(chat): Split palette search words describe a new chat, not a move --- .../src/components/cmd-j/native-chat-split-quick-actions.ts | 4 ++-- .../src/components/tab-bar/tab-move-to-pane-column.ts | 2 +- src/renderer/src/i18n/locales/en.json | 4 ++-- src/renderer/src/i18n/locales/es.json | 4 ++-- src/renderer/src/i18n/locales/fr.json | 4 ++-- src/renderer/src/i18n/locales/ja.json | 4 ++-- src/renderer/src/i18n/locales/ko.json | 4 ++-- src/renderer/src/i18n/locales/zh.json | 4 ++-- 8 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/components/cmd-j/native-chat-split-quick-actions.ts b/src/renderer/src/components/cmd-j/native-chat-split-quick-actions.ts index 1868e937f367..197560e486f8 100644 --- a/src/renderer/src/components/cmd-j/native-chat-split-quick-actions.ts +++ b/src/renderer/src/components/cmd-j/native-chat-split-quick-actions.ts @@ -39,7 +39,7 @@ export function getNativeChatSplitQuickActions(): CmdJQuickAction[] { icon: PanelRightClose, verbKeywords: [ translate('auto.components.cmd.j.quick.actions.verbs.splitChatRight', 'split chat right'), - translate('auto.components.cmd.j.quick.actions.verbs.moveChatRight', 'move chat right'), + translate('auto.components.cmd.j.quick.actions.verbs.newChatRight', 'new chat right'), translate('auto.components.cmd.j.quick.actions.verbs.chatPaneRight', 'chat pane right') ] }), @@ -53,7 +53,7 @@ export function getNativeChatSplitQuickActions(): CmdJQuickAction[] { icon: PanelBottomClose, verbKeywords: [ translate('auto.components.cmd.j.quick.actions.verbs.splitChatDown', 'split chat down'), - translate('auto.components.cmd.j.quick.actions.verbs.moveChatDown', 'move chat down'), + translate('auto.components.cmd.j.quick.actions.verbs.newChatBelow', 'new chat below'), translate('auto.components.cmd.j.quick.actions.verbs.chatPaneBelow', 'chat pane below') ] }) diff --git a/src/renderer/src/components/tab-bar/tab-move-to-pane-column.ts b/src/renderer/src/components/tab-bar/tab-move-to-pane-column.ts index 6dfed428e752..c34b6b5b5507 100644 --- a/src/renderer/src/components/tab-bar/tab-move-to-pane-column.ts +++ b/src/renderer/src/components/tab-bar/tab-move-to-pane-column.ts @@ -9,7 +9,7 @@ type TabMovePaneColumnState = Pick< 'unifiedTabsByWorktree' | 'groupsByWorktree' > -export function canMoveTabToNewPaneColumnFromState( +function canMoveTabToNewPaneColumnFromState( state: TabMovePaneColumnState, unifiedTabId: string, groupId: string diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 690686025f41..06032b47e7eb 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -16885,10 +16885,10 @@ "addQuickCommand": "add quick command", "newQuickCommand": "new quick command", "splitChatRight": "split chat right", - "moveChatRight": "move chat right", + "newChatRight": "new chat right", "chatPaneRight": "chat pane right", "splitChatDown": "split chat down", - "moveChatDown": "move chat down", + "newChatBelow": "new chat below", "chatPaneBelow": "chat pane below" }, "splitChatRight": "Split Chat Right", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index cac7730562e9..e396f082b3d7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -16590,10 +16590,10 @@ "addQuickCommand": "agregar comando rápido", "newQuickCommand": "nuevo comando rápido", "splitChatRight": "dividir chat a la derecha", - "moveChatRight": "mover chat a la derecha", + "newChatRight": "nuevo chat a la derecha", "chatPaneRight": "panel de chat a la derecha", "splitChatDown": "dividir chat abajo", - "moveChatDown": "mover chat abajo", + "newChatBelow": "nuevo chat abajo", "chatPaneBelow": "panel de chat abajo" }, "splitChatRight": "Dividir chat a la derecha", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 32263d9c765e..8a7bd8501fa4 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -16703,10 +16703,10 @@ "addQuickCommand": "ajouter une commande rapide", "newQuickCommand": "nouvelle commande rapide", "splitChatRight": "diviser le chat à droite", - "moveChatRight": "déplacer le chat vers la droite", + "newChatRight": "nouveau chat à droite", "chatPaneRight": "volet de discussion à droite", "splitChatDown": "diviser la discussion", - "moveChatDown": "déplacer le chat vers le bas", + "newChatBelow": "nouveau chat en bas", "chatPaneBelow": "volet de discussion ci-dessous" }, "splitChatRight": "Diviser le chat à droite", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index d52ec701876c..ab01f4ccc875 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -16595,10 +16595,10 @@ "addQuickCommand": "クイックコマンドを追加", "newQuickCommand": "新規クイックコマンド", "splitChatRight": "チャットを右に分割", - "moveChatRight": "チャットを右に移動", + "newChatRight": "右に新しいチャット", "chatPaneRight": "チャットペイン右", "splitChatDown": "チャットを分割する", - "moveChatDown": "チャットを下に移動", + "newChatBelow": "下に新しいチャット", "chatPaneBelow": "下のチャットペイン" }, "splitChatRight": "右の分割チャット", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a2032b34d913..56e2b772bfbe 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -16595,10 +16595,10 @@ "addQuickCommand": "빠른 명령 추가", "newQuickCommand": "새로운 빠른 명령", "splitChatRight": "바로 채팅 분할", - "moveChatRight": "채팅을 오른쪽으로 이동", + "newChatRight": "오른쪽에 새 채팅", "chatPaneRight": "채팅창 오른쪽", "splitChatDown": "채팅을 분할하다", - "moveChatDown": "채팅을 아래로 이동", + "newChatBelow": "아래에 새 채팅", "chatPaneBelow": "아래 채팅 창" }, "splitChatRight": "채팅을 오른쪽으로 분할", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index e2411a80dcee..0c48c9b6cc2b 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -16595,10 +16595,10 @@ "addQuickCommand": "添加快捷命令", "newQuickCommand": "新的快捷命令", "splitChatRight": "拆分聊天权限", - "moveChatRight": "向右移动聊天", + "newChatRight": "在右侧新建聊天", "chatPaneRight": "聊天窗格右侧", "splitChatDown": "拆分聊天", - "moveChatDown": "下移聊天", + "newChatBelow": "在下方新建聊天", "chatPaneBelow": "下面的聊天窗格" }, "splitChatRight": "右侧拆分聊天", From d3b4c75421a166fe538e3cd6f2b3a5cb6aa7f46e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:14:25 -0700 Subject: [PATCH 29/42] fix: remove misleading chat move shortcut hints --- .../cmd-j/quick-action-context.test.ts | 16 ++++++++------- .../native-chat/native-chat-layout-actions.ts | 2 +- ...uctured-native-chat-pane-commands.test.tsx | 20 +++++++++++++++---- ...se-structured-native-chat-pane-commands.ts | 6 +----- 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/src/renderer/src/components/cmd-j/quick-action-context.test.ts b/src/renderer/src/components/cmd-j/quick-action-context.test.ts index c8cd9e23cb53..d82cd01ad516 100644 --- a/src/renderer/src/components/cmd-j/quick-action-context.test.ts +++ b/src/renderer/src/components/cmd-j/quick-action-context.test.ts @@ -9,6 +9,7 @@ import { import { getCmdJQuickActions } from './quick-actions' import type { AppState } from '@/store/types' import type { Worktree } from '../../../../shared/worktree/types' +import { createTestStore, makeUnifiedTab, makeWorktree } from '@/store/slices/store-test-helpers' type GroupState = Pick @@ -259,18 +260,19 @@ describe('Cmd+J quick action context', () => { }) it('exposes chat split actions only on the workspace surface', () => { - const worktree = { + const worktree = makeWorktree({ id: 'wt-1', repoId: 'repo-1', path: '/repo/wt', displayName: 'Workspace', branch: 'main', createdAt: 0 - } as Worktree - const state = { + }) + const state: AppState = { + ...createTestStore().getState(), activeWorktreeId: 'wt-1', worktreesByRepo: { 'repo-1': [worktree] }, - repos: [{ id: 'repo-1', path: '/repo', displayName: 'Repo', addedAt: 0 }], + repos: [{ id: 'repo-1', path: '/repo', displayName: 'Repo', badgeColor: '', addedAt: 0 }], sshConnectionStates: new Map(), activeGroupIdByWorktree: { 'wt-1': 'group-1' }, groupsByWorktree: { @@ -285,19 +287,19 @@ describe('Cmd+J quick action context', () => { }, unifiedTabsByWorktree: { 'wt-1': [ - { + makeUnifiedTab({ id: 'chat-1', entityId: 'session-1', groupId: 'group-1', worktreeId: 'wt-1', contentType: 'agent-session', agentSessionAgent: 'codex' - } + }) ] }, activeView: 'settings', settings: null - } as unknown as AppState + } const buildContext = (activeView: AppState['activeView']) => buildCmdJQuickActionContext({ diff --git a/src/renderer/src/components/native-chat/native-chat-layout-actions.ts b/src/renderer/src/components/native-chat/native-chat-layout-actions.ts index 64d8369c0e41..b490053401da 100644 --- a/src/renderer/src/components/native-chat/native-chat-layout-actions.ts +++ b/src/renderer/src/components/native-chat/native-chat-layout-actions.ts @@ -100,7 +100,7 @@ export function runNativeChatSplitTarget( closePane() return false } - // Closes only a pane no tab ever reached; a chat or declined terminal keeps it. + // Settlement cleanup preserves any pane that currently contains a tab. void result.structuredSettlement?.then(closePane, closePane) return true } diff --git a/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.test.tsx b/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.test.tsx index 0d8a230da294..758ddbb1f290 100644 --- a/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.test.tsx @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as I18n from '@/i18n/i18n' import type { NativeChatComposerHandle } from './NativeChatComposer' import { useStructuredNativeChatPaneCommands } from './use-structured-native-chat-pane-commands' +import { moveTabToNewPaneColumn } from '@/components/tab-bar/tab-move-to-pane-column' type ItemProps = { onSelect?: () => void; children?: ReactNode } @@ -40,11 +41,9 @@ vi.mock('@/components/ui/tooltip', () => { const Pass = ({ children }: { children?: ReactNode }) => children return { Tooltip: Pass, TooltipTrigger: Pass, TooltipContent: () => null } }) -vi.mock('@/components/tab-bar/TabWorkspaceLayoutMenuSection', () => ({ - TabWorkspaceLayoutMenuSection: () => null -})) vi.mock('@/components/tab-bar/tab-move-to-pane-column', () => ({ - canMoveTabToNewPaneColumn: () => false + canMoveTabToNewPaneColumn: () => true, + moveTabToNewPaneColumn: vi.fn() })) vi.mock('@/store', () => ({ useAppStore: (select: (state: { keybindings: object }) => unknown) => select({ keybindings: {} }) @@ -92,6 +91,19 @@ describe('a structured chat tab', () => { }) }) + it('keeps Move Tab to Split without fresh-chat shortcut hints', () => { + renderToStaticMarkup() + const right = items.list.find((item) => childrenText(item.children) === 'Right') + expect(right).toBeDefined() + expect(items.list.some((item) => childrenText(item.children) === 'Down')).toBe(true) + right?.onSelect?.() + expect(moveTabToNewPaneColumn).toHaveBeenCalledWith({ + unifiedTabId: 'chat-tab', + groupId: 'group-1', + direction: 'right' + }) + }) + it("offers Copy Orca Session ID, asking the tab's host for this session's Orca session ID", async () => { renderToStaticMarkup() diff --git a/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.ts b/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.ts index 5323baef623e..c89bb0b3c386 100644 --- a/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.ts +++ b/src/renderer/src/components/native-chat/use-structured-native-chat-pane-commands.ts @@ -71,11 +71,7 @@ export function useStructuredNativeChatPaneCommands({ groupId && terminalPaneActions === undefined ? { unifiedTabId: tabId, - groupId, - shortcutLabels: { - right: formatShortcutLabel('terminal.splitRight', keybindings), - down: formatShortcutLabel('terminal.splitDown', keybindings) - } + groupId } : undefined }) From b904956a321766b80e1a34ccebfc03ad0ec762b5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 04:02:48 -0700 Subject: [PATCH 30/42] fix(chat): a new chat stays in its pane until its host lists it A host tab list built before a chat's create landed can arrive after the create reply. Once the reply settled the launch, nothing protected the new tab, so that older list dropped it: Split's new pane collapsed and focus moved to another chat, and the next list re-added the chat elsewhere. Keep a small in-memory record for a chat this client created that no host list has shown yet. While it exists, a list that omits the chat keeps its tab. The record ends when an accepted list from that host includes the chat, when the tab closes, on workspace teardown, or 30 seconds after the reply, when the worktree is re-read from its host (including an unchanged version) so a chat the host really closed is dropped then. The launch lifecycle, status, persistence and settlement are unchanged. --- ...d-agent-session-launch-outcome-tracking.ts | 5 + ...ctured-agent-session-launch-publication.ts | 3 + ...tructured-agent-session-launch-registry.ts | 2 + ...ent-session-unlisted-created-chats.test.ts | 107 ++++++++++++++++++ ...ed-agent-session-unlisted-created-chats.ts | 64 +++++++++++ ...structured-agent-session-tab-retirement.ts | 21 +++- .../apply-preparation-browser.ts | 4 +- .../purged-structured-launch-retirement.ts | 2 + 8 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/lib/structured-agent-session-unlisted-created-chats.test.ts create mode 100644 src/renderer/src/lib/structured-agent-session-unlisted-created-chats.ts diff --git a/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts b/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts index f12041b2451b..1837648f693d 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts @@ -17,6 +17,7 @@ import { retireStructuredAgentSessionLaunchCancellationTombstone, type StructuredLaunchState } from './structured-agent-session-launch-registry' +import { recordUnlistedCreatedChat } from './structured-agent-session-unlisted-created-chats' // How a launch's create outcome settles its callers: published, refused, failed or unknown. @@ -74,6 +75,10 @@ export function trackLaunchSettlement( return } materializeStructuredLaunchSurface(state.intent) + if (!state.hostListed) { + const { worktreeId, sessionId, executionHostId } = state.intent + recordUnlistedCreatedChat(worktreeId, sessionId, executionHostId) + } settleStructuredLaunchCallers(state.callers, 'published') notifyStructuredLaunchListeners() }, diff --git a/src/renderer/src/lib/structured-agent-session-launch-publication.ts b/src/renderer/src/lib/structured-agent-session-launch-publication.ts index 3a8161a2dfe3..2bbbee1be7ec 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-publication.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-publication.ts @@ -7,6 +7,7 @@ import { hasStructuredAgentSessionLaunchCancellationTombstone, notifyStructuredLaunchListeners } from './structured-agent-session-launch-registry' +import { forgetUnlistedCreatedChat } from './structured-agent-session-unlisted-created-chats' function confirmStructuredLaunchFirstMessagePublication( worktreeId: string, @@ -49,6 +50,7 @@ export function markStructuredAgentSessionLaunchPublished( ) { return false } + state.hostListed = true if (state.callers.outcome === 'published') { return true } @@ -73,6 +75,7 @@ export function markStructuredAgentSessionLaunchesPublished( published: Iterable<{ worktreeId: string; sessionId: string }> ): void { for (const { worktreeId, sessionId } of published) { + forgetUnlistedCreatedChat(worktreeId, sessionId, executionHostId) if (!hasStructuredAgentSessionLaunchCancellationTombstone(worktreeId, sessionId)) { markStructuredAgentSessionLaunchPublished(worktreeId, sessionId, executionHostId) } diff --git a/src/renderer/src/lib/structured-agent-session-launch-registry.ts b/src/renderer/src/lib/structured-agent-session-launch-registry.ts index 8d026f1512ad..2d9cfe7ca944 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-registry.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-registry.ts @@ -39,6 +39,8 @@ export type StructuredLaunchState = StructuredLaunchRecoveryState & { * failure named none. */ failure?: StructuredLaunchFailure selection: StructuredLaunchSelection + /** A host list showed the chat, so its create reply needs no protection from older lists. */ + hostListed?: true } export type StructuredAgentLaunchStatus = 'idle' | 'pending' | 'unknown' diff --git a/src/renderer/src/lib/structured-agent-session-unlisted-created-chats.test.ts b/src/renderer/src/lib/structured-agent-session-unlisted-created-chats.test.ts new file mode 100644 index 000000000000..0cf7ed627dad --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-unlisted-created-chats.test.ts @@ -0,0 +1,107 @@ +// @vitest-environment happy-dom +// Real store, Split action, launch registry and list apply; only host transport is faked. +import { expect, it, vi } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { getDefaultSettings } from '../../../shared/constants' + +const host = vi.hoisted(() => ({ replies: [] as ((value: unknown) => void)[], list: {} })) +vi.mock('@/runtime/structured-agent-session-client', async (importOriginal) => ({ + ...(await importOriginal()), + callStructuredAgentSession: (_target: unknown, method: string) => + method === 'agentSession.create' + ? new Promise((resolve) => host.replies.push(resolve)) + : Promise.resolve({ supported: true }) +})) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: async () => host.list })) +vi.mock('@/lib/structured-agent-session-create-hydration', () => ({ + publishStructuredAgentSessionCreateHydration: vi.fn() +})) + +import { useAppStore } from '@/store' +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY as CHATS } from '../../../shared/agent-session-runtime-capabilities' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY as CREATE } from '../../../shared/agent-session-create-capabilities' +import { runNativeChatSplitTarget } from '@/components/native-chat/native-chat-layout-actions' +import { applyStructuredSessionTabSnapshots } from '@/runtime/local-structured-session-tabs-sync/snapshot-apply' +import { getStructuredAgentSessionLaunchLifecycle } from '@/lib/structured-agent-session-launch' +import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' +import { isUnlistedCreatedChat } from './structured-agent-session-unlisted-created-chats' + +const WT = 'repo1::/tmp/wt1' +const tabId = (sid: string) => `structured-agent-session-${sid}` +let version = 100 +const CHAT_ROW = { type: 'agent-session', agent: 'claude', title: 'Chat', isActive: false } as const + +function hostList(groupId: string, extra?: string): RuntimeMobileSessionTabsResult { + const sids = extra ? ['b', extra] : ['b'] + const ids = sids.map((sid) => `agent-session:${sid}`) + return { + worktree: WT, + publicationEpoch: 'renderer:split-flow', + snapshotVersion: ++version, + activeGroupId: groupId, + activeTabId: null, + activeTabType: null, + tabGroups: [{ id: groupId, activeTabId: ids[0]!, tabOrder: ids }], + tabs: sids.map((sid, i) => ({ ...CHAT_ROW, id: ids[i]!, sessionId: sid })) + } +} + +const state = () => useAppStore.getState() +const groupOf = (id: string) => state().groupsByWorktree[WT]?.find((g) => g.tabOrder.includes(id)) + +/** Split Right from chat `b`; resolves once the new chat's create call is in flight. */ +async function split(): Promise<{ groupB: string; sid: string; groupN: string }> { + host.replies.length = 0 + setLocalRuntimeCapabilitiesForTests([CHATS, CREATE]) + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), experimentalNativeChat: true }, + worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/tmp/wt1' })] }, + activeWorktreeId: WT + }) + const source = { id: tabId('b'), entityId: 'b', agentSessionAgent: 'claude' } as const + const { groupId: groupB } = state().createUnifiedTab(WT, 'agent-session', source) + applyStructuredSessionTabSnapshots([hostList(groupB)], undefined, { authoritative: true }) + const target = { kind: 'workspace-tab', unifiedTabId: tabId('b'), groupId: groupB } as const + expect(runNativeChatSplitTarget(target, 'right')).toBe(true) + await vi.waitFor(() => expect(host.replies).toHaveLength(1)) + const tab = state().unifiedTabsByWorktree[WT]!.find((t) => t.entityId !== 'b')! + return { groupB, sid: tab.entityId, groupN: tab.groupId } +} + +async function reply(sid: string): Promise { + host.replies[0]!({ ok: true, value: { sessionId: sid, fence: 1 } }) + await vi.waitFor(() => expect(getStructuredAgentSessionLaunchLifecycle(WT, sid)).toBeNull()) +} + +it.each(['after', 'before'] as const)( + 'a new chat a host first lists %s its create reply stays active in its own pane', + async (listed) => { + const { groupB, sid, groupN } = await split() + if (listed === 'before') { + applyStructuredSessionTabSnapshots([hostList(groupB, sid)]) + } + await reply(sid) + if (listed === 'after') { + // Built before the create landed, applied after its reply. + applyStructuredSessionTabSnapshots([hostList(groupB)]) + applyStructuredSessionTabSnapshots([hostList(groupB, sid)]) + } + expect(groupOf(tabId(sid))).toMatchObject({ id: groupN, activeTabId: tabId(sid) }) + expect(state().activeGroupIdByWorktree[WT]).toBe(groupN) + expect(isUnlistedCreatedChat(WT, sid)).toBe(false) + } +) + +it('a chat its host closed before ever listing it goes once the protection lapses', async () => { + const { groupB, sid, groupN } = await split() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + await reply(sid) + // The re-read returns the version already applied: nothing republishes an unchanged list. + applyStructuredSessionTabSnapshots([(host.list = hostList(groupB))]) + expect(groupOf(tabId(sid))?.id).toBe(groupN) + await vi.advanceTimersByTimeAsync(30_000) + vi.useRealTimers() + await vi.waitFor(() => expect(groupOf(tabId(sid))).toBeUndefined()) +}) diff --git a/src/renderer/src/lib/structured-agent-session-unlisted-created-chats.ts b/src/renderer/src/lib/structured-agent-session-unlisted-created-chats.ts new file mode 100644 index 000000000000..d79f9a960adb --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-unlisted-created-chats.ts @@ -0,0 +1,64 @@ +import type { ExecutionHostId } from '../../../shared/execution-host' + +// Why: a host list built before a create landed can arrive after its reply; until a list shows +// the chat, omission is no evidence it closed. Bounded so a chat the host never lists still goes. +const UNLISTED_CREATED_CHAT_TTL_MS = 30_000 + +type UnlistedCreatedChat = { + executionHostId: ExecutionHostId + timer: ReturnType +} + +const unlistedCreatedChats = new Map() + +function chatKey(worktreeId: string, sessionId: string): string { + return `${worktreeId}\u0000${sessionId}` +} + +/** This client's create reply landed for a chat no host list has shown yet. */ +export function recordUnlistedCreatedChat( + worktreeId: string, + sessionId: string, + executionHostId: ExecutionHostId +): void { + forgetUnlistedCreatedChat(worktreeId, sessionId) + const key = chatKey(worktreeId, sessionId) + const record: UnlistedCreatedChat = { + executionHostId, + timer: setTimeout(() => { + if (unlistedCreatedChats.get(key) !== record) { + return + } + unlistedCreatedChats.delete(key) + // Unchanged lists are never republished, so re-read the one an omission was held against. + // Lazy: tab retirement reaches this module through launch publication. + void import('@/runtime/structured-agent-session-tab-retirement') + .then(({ reacceptStructuredSessionHostTabs }) => + reacceptStructuredSessionHostTabs(executionHostId, worktreeId) + ) + .catch((error: unknown) => + console.warn('[native-chat] re-reading an unlisted chat failed', error) + ) + }, UNLISTED_CREATED_CHAT_TTL_MS) + } + unlistedCreatedChats.set(key, record) +} + +export function isUnlistedCreatedChat(worktreeId: string, sessionId: string): boolean { + return unlistedCreatedChats.has(chatKey(worktreeId, sessionId)) +} + +/** `listedBy` limits the release to a list from the host the chat was created on. */ +export function forgetUnlistedCreatedChat( + worktreeId: string, + sessionId: string, + listedBy?: ExecutionHostId +): void { + const key = chatKey(worktreeId, sessionId) + const record = unlistedCreatedChats.get(key) + if (!record || (listedBy !== undefined && record.executionHostId !== listedBy)) { + return + } + clearTimeout(record.timer) + unlistedCreatedChats.delete(key) +} diff --git a/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts b/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts index 7a0cd4840f94..26d483f961ea 100644 --- a/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts +++ b/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts @@ -8,7 +8,8 @@ import { hasStructuredAgentSessionLaunchCancellationTombstone, markStructuredAgentSessionLaunchCancelled } from '@/lib/structured-agent-session-launch-registry' -import { toRuntimeExecutionHostId } from '../../../shared/execution-host' +import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import { forgetUnlistedCreatedChat } from '@/lib/structured-agent-session-unlisted-created-chats' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { discardStructuredAgentSessionChatSends } from '@/lib/structured-agent-session-launch-prompt' import { stopStructuredAgentSessionSends } from '@/components/native-chat/structured-agent-session-message-sender' @@ -17,7 +18,8 @@ import { closeStructuredAgentSession } from './structured-agent-session-close' import { withLocalSessionTabCloseOwner } from './local-session-tab-close-owner' import { executionHostIdForStructuredTarget, - structuredAgentSessionFocusOwner + structuredAgentSessionFocusOwner, + structuredAgentSessionTargetForHost } from './structured-agent-session-owner' import { clearWebSessionCloseIntent, recordWebSessionCloseIntent } from './web-session-close-intent' import type { WebSessionIntentOwner } from './web-session-intent-owner' @@ -106,6 +108,20 @@ function reacceptHostSessionTabs( ) } +/** Re-reads a worktree whose lists were held back from dropping a chat created here. */ +export function reacceptStructuredSessionHostTabs( + executionHostId: ExecutionHostId, + worktreeId: string +): void { + const target = structuredAgentSessionTargetForHost(executionHostId) + if (target) { + const intentOwner = captureWebSessionIntentOwner( + structuredAgentSessionFocusOwner(target).environmentId + ) + reacceptHostSessionTabs(target, worktreeId, intentOwner) + } +} + /** Mark cancellation before removing the row so a late host publication cannot resurrect it. */ export function beginStructuredAgentSessionTabClose(args: { target: RuntimeClientTarget @@ -115,6 +131,7 @@ export function beginStructuredAgentSessionTabClose(args: { layoutOwner: StructuredLaunchSlotOwner onError?: (error: unknown) => void }): void { + forgetUnlistedCreatedChat(args.worktreeId, args.sessionId) if (args.provisional) { markStructuredAgentSessionLaunchCancelled( args.worktreeId, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts index f30146b780f1..2d63988f600c 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts @@ -20,6 +20,7 @@ import { } from './state-equality-files' import { shouldRetainStructuredAgentSessionLaunchTab } from '@/lib/structured-agent-session-launch-registry' import { executionHostIdForSessionTabsOwner } from '../local-structured-session-owner' +import { isUnlistedCreatedChat } from '@/lib/structured-agent-session-unlisted-created-chats' export function prepareWebSessionTabsSnapshotBrowser( base: ReturnType @@ -185,7 +186,8 @@ export function prepareWebSessionTabsSnapshotBrowser( return ( !publishedAgentSessionIds.has(tab.entityId) && (!agentSessionsAffirmed || - shouldRetainStructuredAgentSessionLaunchTab(worktreeId, tab.entityId)) + shouldRetainStructuredAgentSessionLaunchTab(worktreeId, tab.entityId) || + isUnlistedCreatedChat(worktreeId, tab.entityId)) ) } if (tab.contentType === 'browser') { diff --git a/src/renderer/src/store/slices/worktrees/teardown/purged-structured-launch-retirement.ts b/src/renderer/src/store/slices/worktrees/teardown/purged-structured-launch-retirement.ts index e7cb187c71ba..c64ebf7b09b4 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/purged-structured-launch-retirement.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/purged-structured-launch-retirement.ts @@ -10,6 +10,7 @@ import { structuredLaunchStates } from '@/lib/structured-agent-session-launch-registry' import { discardStructuredAgentSessionChatSends } from '@/lib/structured-agent-session-launch-prompt' +import { forgetUnlistedCreatedChat } from '@/lib/structured-agent-session-unlisted-created-chats' import { readStructuredAgentLaunchRecords } from '@/lib/structured-agent-session-launch-persistence' import { retireStructuredAgentSessionReadOwner } from '@/components/native-chat/structured-agent-session-read-owner-registry' import { clearWebSessionFocusIntentIfMatches } from '@/runtime/web-session-focus-intent' @@ -35,6 +36,7 @@ export function retirePurgedTabLaunches( } for (const tab of unifiedTabs) { if (tab.contentType === 'agent-session') { + forgetUnlistedCreatedChat(worktreeId, tab.entityId) const target = structuredAgentSessionTargetForTab(s, tab) if (target) { retireStructuredAgentSessionReadOwner(tab.entityId, target) From e64c5c3ea268371101a4b5bf5a1a833560f51ef9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 04:06:03 -0700 Subject: [PATCH 31/42] test(chat): type the faked create replies without an assertion --- .../structured-agent-session-unlisted-created-chats.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/lib/structured-agent-session-unlisted-created-chats.test.ts b/src/renderer/src/lib/structured-agent-session-unlisted-created-chats.test.ts index 0cf7ed627dad..7b1aca35da2e 100644 --- a/src/renderer/src/lib/structured-agent-session-unlisted-created-chats.test.ts +++ b/src/renderer/src/lib/structured-agent-session-unlisted-created-chats.test.ts @@ -4,7 +4,10 @@ import { expect, it, vi } from 'vitest' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import { getDefaultSettings } from '../../../shared/constants' -const host = vi.hoisted(() => ({ replies: [] as ((value: unknown) => void)[], list: {} })) +const host = vi.hoisted(() => { + const replies: ((value: unknown) => void)[] = [] + return { replies, list: {} } +}) vi.mock('@/runtime/structured-agent-session-client', async (importOriginal) => ({ ...(await importOriginal()), callStructuredAgentSession: (_target: unknown, method: string) => From b2c663cb4a1f2a6a9b97dc17845b4b6c24378d1d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:14:32 -0700 Subject: [PATCH 32/42] Keep new chat protection tied to accepted host lists --- .../native-chat/native-chat-layout-actions.ts | 12 +- .../native-chat-split-flow.test.ts | 5 +- ...ured-agent-session-provisional-tab.test.ts | 51 +++++++- ...ent-session-unlisted-created-chats.test.ts | 110 ------------------ ...ed-agent-session-unlisted-created-chats.ts | 11 +- ...host-structured-launch-bookkeeping.test.ts | 22 +++- ...structured-agent-session-tab-retirement.ts | 41 +------ ...uctured-launch-cancel-layout-owner.test.ts | 32 ++++- .../runtime/web-runtime-session-snapshot.ts | 1 + .../active-session-subscription.ts | 4 +- .../global-session-events.ts | 1 + .../global-session-inventory-event.ts | 4 +- .../web-session-tabs-sync/load-initial.ts | 1 + .../web-session-tabs-sync/mirror-settle.ts | 2 + .../web-session-tabs-sync/store-patch.ts | 13 +++ .../visibility-resume-repair.ts | 1 + .../web-session-terminal-orphan-recovery.ts | 9 +- 17 files changed, 143 insertions(+), 177 deletions(-) delete mode 100644 src/renderer/src/lib/structured-agent-session-unlisted-created-chats.test.ts diff --git a/src/renderer/src/components/native-chat/native-chat-layout-actions.ts b/src/renderer/src/components/native-chat/native-chat-layout-actions.ts index b490053401da..59802b4be1e5 100644 --- a/src/renderer/src/components/native-chat/native-chat-layout-actions.ts +++ b/src/renderer/src/components/native-chat/native-chat-layout-actions.ts @@ -1,3 +1,6 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { getAgentLabel } from '@/lib/agent-catalog' import type { AppState } from '@/store/types' import { useAppStore } from '@/store' import { isTuiAgent } from '../../../../shared/tui-agent-config' @@ -98,7 +101,14 @@ export function runNativeChatSplitTarget( }) if (!result) { closePane() - return false + toast.error( + translate( + 'auto.components.tab.bar.QuickLaunchButton.465e432ef1', + 'Could not build launch command for {{value0}}.', + { value0: getAgentLabel(source.agent) } + ) + ) + return true } // Settlement cleanup preserves any pane that currently contains a tab. void result.structuredSettlement?.then(closePane, closePane) diff --git a/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts b/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts index 22d3d8c36a40..b008bccf2fae 100644 --- a/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-split-flow.test.ts @@ -1,11 +1,13 @@ // @vitest-environment happy-dom import { expect, it, vi } from 'vitest' +import { toast } from 'sonner' import { createTestStore } from '@/store/slices/store-test-helpers' import { createTabsSliceMockApi } from '@/store/slices/tabs-slice-test-harness' let store: ReturnType const launch = vi.hoisted(() => vi.fn()) vi.mock('@/store', () => ({ useAppStore: { getState: () => store.getState() } })) +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: launch })) createTabsSliceMockApi() @@ -45,7 +47,8 @@ it.each(['right', 'down'] as const)( expect(store.getState().unifiedTabsByWorktree[worktreeId]).toEqual([source]) const layout = store.getState().layoutByWorktree[worktreeId] launch.mockReturnValue(null) - expect(runNativeChatSplitTarget(target, direction)).toBe(false) + expect(runNativeChatSplitTarget(target, direction)).toBe(true) + expect(toast.error).toHaveBeenLastCalledWith('Could not build launch command for Codex.') expect(store.getState().layoutByWorktree[worktreeId]).toEqual(layout) } ) 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 5fa9802c1c1a..dd54802966af 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 @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import type * as launchModule from './launch-structured-agent-session' import type { AgentSessionLaunchPlan } from './agent-session-launch-plan' @@ -18,6 +18,10 @@ import { addNativeChatPendingAttachment, clearNativeChatPendingAttachmentsForTests } from '@/components/native-chat/native-chat-pending-attachment-cache' +import { applyStructuredSessionTabSnapshots } from '@/runtime/local-structured-session-tabs-sync/snapshot-apply' +import { makeEmptyHostSnapshot } from '@/runtime/host-session-mirror-frame-fixtures' +import * as unlisted from './structured-agent-session-unlisted-created-chats' +import { buildFullWorktreePurgeState } from '@/store/slices/worktrees/teardown/worktree-purge-state' const mocks = vi.hoisted(() => ({ text: '', focus: vi.fn(), toast: vi.fn(), launch: vi.fn() })) vi.mock('./launch-structured-agent-session', async (original) => ({ @@ -268,3 +272,48 @@ describe('a provisional structured chat tab', () => { view.unmount() }) }) + +afterEach(() => vi.useRealTimers()) +it.each(['expiry', 'purge'])('created pane publication: %s', async (order) => { + const store = () => useAppStore.getState() + const source = store().createUnifiedTab('wt-1', 'agent-session', { entityId: 'b' }) + const groupId = store().createEmptySplitGroup('wt-1', source.groupId, 'right')! + const chat = store().createUnifiedTab('wt-1', 'agent-session', { + entityId: 'created', + targetGroupId: groupId + }) + const list = (snapshotVersion: number) => + applyStructuredSessionTabSnapshots([ + { + ...makeEmptyHostSnapshot('wt-1'), + publicationEpoch: chat.entityId, + snapshotVersion, + tabs: ['b'].map((sessionId) => ({ + type: 'agent-session', + id: `agent-session:${sessionId}`, + sessionId, + agent: 'claude', + title: 'Chat', + isActive: false + })) + } + ]) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + unlisted.recordUnlistedCreatedChat('wt-1', chat.entityId, 'local') + list(100) + expect(store().unifiedTabsByWorktree['wt-1']?.find((t) => t.id === chat.id)).toMatchObject({ + groupId + }) + if (order === 'purge') { + useAppStore.setState(buildFullWorktreePurgeState(store(), [{ id: 'wt-1' }])) + } + await vi.advanceTimersByTimeAsync(30_000) + expect(unlisted.isUnlistedCreatedChat('wt-1', chat.entityId)).toBe(false) + if (order === 'purge') { + expect(store().unifiedTabsByWorktree['wt-1']).toBeUndefined() + } else { + expect(store().unifiedTabsByWorktree['wt-1']?.some((t) => t.id === chat.id)).toBe(true) + list(101) + expect(store().unifiedTabsByWorktree['wt-1']?.some((t) => t.id === chat.id)).toBe(false) + } +}) diff --git a/src/renderer/src/lib/structured-agent-session-unlisted-created-chats.test.ts b/src/renderer/src/lib/structured-agent-session-unlisted-created-chats.test.ts deleted file mode 100644 index 7b1aca35da2e..000000000000 --- a/src/renderer/src/lib/structured-agent-session-unlisted-created-chats.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -// @vitest-environment happy-dom -// Real store, Split action, launch registry and list apply; only host transport is faked. -import { expect, it, vi } from 'vitest' -import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' -import { getDefaultSettings } from '../../../shared/constants' - -const host = vi.hoisted(() => { - const replies: ((value: unknown) => void)[] = [] - return { replies, list: {} } -}) -vi.mock('@/runtime/structured-agent-session-client', async (importOriginal) => ({ - ...(await importOriginal()), - callStructuredAgentSession: (_target: unknown, method: string) => - method === 'agentSession.create' - ? new Promise((resolve) => host.replies.push(resolve)) - : Promise.resolve({ supported: true }) -})) -vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: async () => host.list })) -vi.mock('@/lib/structured-agent-session-create-hydration', () => ({ - publishStructuredAgentSessionCreateHydration: vi.fn() -})) - -import { useAppStore } from '@/store' -import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY as CHATS } from '../../../shared/agent-session-runtime-capabilities' -import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY as CREATE } from '../../../shared/agent-session-create-capabilities' -import { runNativeChatSplitTarget } from '@/components/native-chat/native-chat-layout-actions' -import { applyStructuredSessionTabSnapshots } from '@/runtime/local-structured-session-tabs-sync/snapshot-apply' -import { getStructuredAgentSessionLaunchLifecycle } from '@/lib/structured-agent-session-launch' -import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' -import { isUnlistedCreatedChat } from './structured-agent-session-unlisted-created-chats' - -const WT = 'repo1::/tmp/wt1' -const tabId = (sid: string) => `structured-agent-session-${sid}` -let version = 100 -const CHAT_ROW = { type: 'agent-session', agent: 'claude', title: 'Chat', isActive: false } as const - -function hostList(groupId: string, extra?: string): RuntimeMobileSessionTabsResult { - const sids = extra ? ['b', extra] : ['b'] - const ids = sids.map((sid) => `agent-session:${sid}`) - return { - worktree: WT, - publicationEpoch: 'renderer:split-flow', - snapshotVersion: ++version, - activeGroupId: groupId, - activeTabId: null, - activeTabType: null, - tabGroups: [{ id: groupId, activeTabId: ids[0]!, tabOrder: ids }], - tabs: sids.map((sid, i) => ({ ...CHAT_ROW, id: ids[i]!, sessionId: sid })) - } -} - -const state = () => useAppStore.getState() -const groupOf = (id: string) => state().groupsByWorktree[WT]?.find((g) => g.tabOrder.includes(id)) - -/** Split Right from chat `b`; resolves once the new chat's create call is in flight. */ -async function split(): Promise<{ groupB: string; sid: string; groupN: string }> { - host.replies.length = 0 - setLocalRuntimeCapabilitiesForTests([CHATS, CREATE]) - useAppStore.setState(useAppStore.getInitialState(), true) - useAppStore.setState({ - settings: { ...getDefaultSettings('/tmp'), experimentalNativeChat: true }, - worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/tmp/wt1' })] }, - activeWorktreeId: WT - }) - const source = { id: tabId('b'), entityId: 'b', agentSessionAgent: 'claude' } as const - const { groupId: groupB } = state().createUnifiedTab(WT, 'agent-session', source) - applyStructuredSessionTabSnapshots([hostList(groupB)], undefined, { authoritative: true }) - const target = { kind: 'workspace-tab', unifiedTabId: tabId('b'), groupId: groupB } as const - expect(runNativeChatSplitTarget(target, 'right')).toBe(true) - await vi.waitFor(() => expect(host.replies).toHaveLength(1)) - const tab = state().unifiedTabsByWorktree[WT]!.find((t) => t.entityId !== 'b')! - return { groupB, sid: tab.entityId, groupN: tab.groupId } -} - -async function reply(sid: string): Promise { - host.replies[0]!({ ok: true, value: { sessionId: sid, fence: 1 } }) - await vi.waitFor(() => expect(getStructuredAgentSessionLaunchLifecycle(WT, sid)).toBeNull()) -} - -it.each(['after', 'before'] as const)( - 'a new chat a host first lists %s its create reply stays active in its own pane', - async (listed) => { - const { groupB, sid, groupN } = await split() - if (listed === 'before') { - applyStructuredSessionTabSnapshots([hostList(groupB, sid)]) - } - await reply(sid) - if (listed === 'after') { - // Built before the create landed, applied after its reply. - applyStructuredSessionTabSnapshots([hostList(groupB)]) - applyStructuredSessionTabSnapshots([hostList(groupB, sid)]) - } - expect(groupOf(tabId(sid))).toMatchObject({ id: groupN, activeTabId: tabId(sid) }) - expect(state().activeGroupIdByWorktree[WT]).toBe(groupN) - expect(isUnlistedCreatedChat(WT, sid)).toBe(false) - } -) - -it('a chat its host closed before ever listing it goes once the protection lapses', async () => { - const { groupB, sid, groupN } = await split() - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - await reply(sid) - // The re-read returns the version already applied: nothing republishes an unchanged list. - applyStructuredSessionTabSnapshots([(host.list = hostList(groupB))]) - expect(groupOf(tabId(sid))?.id).toBe(groupN) - await vi.advanceTimersByTimeAsync(30_000) - vi.useRealTimers() - await vi.waitFor(() => expect(groupOf(tabId(sid))).toBeUndefined()) -}) diff --git a/src/renderer/src/lib/structured-agent-session-unlisted-created-chats.ts b/src/renderer/src/lib/structured-agent-session-unlisted-created-chats.ts index d79f9a960adb..178c7e628d3f 100644 --- a/src/renderer/src/lib/structured-agent-session-unlisted-created-chats.ts +++ b/src/renderer/src/lib/structured-agent-session-unlisted-created-chats.ts @@ -1,7 +1,7 @@ import type { ExecutionHostId } from '../../../shared/execution-host' // Why: a host list built before a create landed can arrive after its reply; until a list shows -// the chat, omission is no evidence it closed. Bounded so a chat the host never lists still goes. +// the chat, omission is no evidence it closed. Expiry ends protection; the next list decides. const UNLISTED_CREATED_CHAT_TTL_MS = 30_000 type UnlistedCreatedChat = { @@ -30,15 +30,6 @@ export function recordUnlistedCreatedChat( return } unlistedCreatedChats.delete(key) - // Unchanged lists are never republished, so re-read the one an omission was held against. - // Lazy: tab retirement reaches this module through launch publication. - void import('@/runtime/structured-agent-session-tab-retirement') - .then(({ reacceptStructuredSessionHostTabs }) => - reacceptStructuredSessionHostTabs(executionHostId, worktreeId) - ) - .catch((error: unknown) => - console.warn('[native-chat] re-reading an unlisted chat failed', error) - ) }, UNLISTED_CREATED_CHAT_TTL_MS) } unlistedCreatedChats.set(key, record) diff --git a/src/renderer/src/runtime/paired-host-structured-launch-bookkeeping.test.ts b/src/renderer/src/runtime/paired-host-structured-launch-bookkeeping.test.ts index b58999d690d9..6b3daac687dd 100644 --- a/src/renderer/src/runtime/paired-host-structured-launch-bookkeeping.test.ts +++ b/src/renderer/src/runtime/paired-host-structured-launch-bookkeeping.test.ts @@ -43,8 +43,8 @@ import { resetStructuredAgentLaunchPersistenceForTests, writeStructuredAgentLaunchRecord } from '@/lib/structured-agent-session-launch-persistence' -import { acceptPairedHostStructuredSessions } from './structured-agent-session-tab-retirement' - +import { suppressCancelledStructuredSessionTabs } from './structured-agent-session-tab-retirement' +import { applyWebSessionTabsStorePatch } from './web-session-tabs-sync/store-patch' const layoutOwner: StructuredLaunchSlotOwner = { unifiedTabsByWorktree: {}, groupsByWorktree: {}, @@ -76,6 +76,24 @@ function serverFrame( } } +function acceptPairedHostStructuredSessions( + snapshot: RuntimeMobileSessionTabsResult, + environmentId: string +) { + const frame = suppressCancelledStructuredSessionTabs(snapshot, SERVER) + applyWebSessionTabsStorePatch((s) => s, { + frames: [ + { + environmentId, + worktreeId: frame.worktree, + source: frame, + decision: { apply: true, settlesHostMirror: true } + } + ] + }) + return frame +} + /** A renderer reload: in-memory launch state is gone, localStorage survives. */ function reload(): void { resetStructuredAgentLaunchPersistenceForTests() diff --git a/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts b/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts index 26d483f961ea..1b1992a6b0c0 100644 --- a/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts +++ b/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts @@ -1,14 +1,9 @@ import type { StructuredLaunchSlotOwner } from '@/lib/structured-agent-session-launch-slot' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' -import { - markStructuredAgentSessionLaunchesPublished, - publishedStructuredSessions -} from '@/lib/structured-agent-session-launch-publication' import { hasStructuredAgentSessionLaunchCancellationTombstone, markStructuredAgentSessionLaunchCancelled } from '@/lib/structured-agent-session-launch-registry' -import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' import { forgetUnlistedCreatedChat } from '@/lib/structured-agent-session-unlisted-created-chats' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { discardStructuredAgentSessionChatSends } from '@/lib/structured-agent-session-launch-prompt' @@ -18,8 +13,7 @@ import { closeStructuredAgentSession } from './structured-agent-session-close' import { withLocalSessionTabCloseOwner } from './local-session-tab-close-owner' import { executionHostIdForStructuredTarget, - structuredAgentSessionFocusOwner, - structuredAgentSessionTargetForHost + structuredAgentSessionFocusOwner } from './structured-agent-session-owner' import { clearWebSessionCloseIntent, recordWebSessionCloseIntent } from './web-session-close-intent' import type { WebSessionIntentOwner } from './web-session-intent-owner' @@ -108,20 +102,6 @@ function reacceptHostSessionTabs( ) } -/** Re-reads a worktree whose lists were held back from dropping a chat created here. */ -export function reacceptStructuredSessionHostTabs( - executionHostId: ExecutionHostId, - worktreeId: string -): void { - const target = structuredAgentSessionTargetForHost(executionHostId) - if (target) { - const intentOwner = captureWebSessionIntentOwner( - structuredAgentSessionFocusOwner(target).environmentId - ) - reacceptHostSessionTabs(target, worktreeId, intentOwner) - } -} - /** Mark cancellation before removing the row so a late host publication cannot resurrect it. */ export function beginStructuredAgentSessionTabClose(args: { target: RuntimeClientTarget @@ -172,25 +152,6 @@ export function beginStructuredAgentSessionTabClose(args: { }) } -/** - * A paired host's frame, as this client may apply it: chats cancelled before their create landed - * are retired on that host, and the rest settle any launch still waiting to learn they exist. - */ -export function acceptPairedHostStructuredSessions( - frame: RuntimeMobileSessionTabsResult, - environmentId: string -): RuntimeMobileSessionTabsResult { - const snapshot = suppressCancelledStructuredSessionTabs(frame, { - kind: 'environment', - environmentId - }) - markStructuredAgentSessionLaunchesPublished( - toRuntimeExecutionHostId(environmentId), - publishedStructuredSessions([snapshot]) - ) - return snapshot -} - /** A host snapshot containing a cancelled session is suppressed and retired again idempotently. */ export function suppressCancelledStructuredSessionTabs( snapshot: RuntimeMobileSessionTabsResult, diff --git a/src/renderer/src/runtime/structured-launch-cancel-layout-owner.test.ts b/src/renderer/src/runtime/structured-launch-cancel-layout-owner.test.ts index a647d207fddc..d0e75db21489 100644 --- a/src/renderer/src/runtime/structured-launch-cancel-layout-owner.test.ts +++ b/src/renderer/src/runtime/structured-launch-cancel-layout-owner.test.ts @@ -8,6 +8,12 @@ import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import type { ExecutionHostId } from '../../../shared/execution-host' import type { StructuredLaunchSlotOwner } from '@/lib/structured-agent-session-launch-slot' +import { useAppStore } from '@/store' +import { recoverWebSessionTerminalOrphansBeforeApply } from './web-session-terminal-orphan-recovery' +import { acceptSessionTabsRuntimeId } from './web-session-tabs-sync/publisher-identity-fences' +import { decideWebSessionTabsSnapshot } from './web-session-tabs-sync/tracking-decisions' +import * as unlisted from '@/lib/structured-agent-session-unlisted-created-chats' + const mocks = vi.hoisted(() => ({ layoutOwnerIsMain: true, closeSession: @@ -38,6 +44,7 @@ vi.mock('./runtime-worktree-selector', () => ({ import { getStructuredAgentSessionLaunchLifecycle, + getStructuredLaunchStateBySessionId, hasStructuredAgentSessionLaunchCancellationTombstone, markStructuredAgentSessionLaunchCancelled, resetStructuredAgentLaunchRegistryForTests, @@ -51,7 +58,6 @@ import { writeStructuredAgentLaunchRecord } from '@/lib/structured-agent-session-launch-persistence' import { - acceptPairedHostStructuredSessions, beginStructuredAgentSessionTabClose, suppressCancelledStructuredSessionTabs } from './structured-agent-session-tab-retirement' @@ -218,13 +224,13 @@ describe('a paired server, with main owning the layout', () => { expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE, SESSION)).toBe(true) expect(mocks.closeSession).not.toHaveBeenCalled() - expect(acceptPairedHostStructuredSessions(hostFrame(), 'server-1').tabs).toEqual([]) + expect(suppressCancelledStructuredSessionTabs(hostFrame(), SERVER).tabs).toEqual([]) expect(mocks.closeSession).toHaveBeenCalledWith(SERVER, SESSION) }) it('create before the cancel: the published chat closes as any chat does, no tombstone', () => { pendingLaunch(SERVER_HOST, SERVER) - expect(acceptPairedHostStructuredSessions(hostFrame(), 'server-1').tabs).toHaveLength(1) + expect(suppressCancelledStructuredSessionTabs(hostFrame(), SERVER).tabs).toHaveLength(1) // Once the create has answered, the tab is not provisional, so its close cancels nothing. beginStructuredAgentSessionTabClose({ @@ -239,3 +245,23 @@ describe('a paired server, with main owning the layout', () => { expect(mocks.closeSession).toHaveBeenCalledWith(SERVER, SESSION) }) }) + +it.each([false, true])('rejected paired publication; reply=%s', async (replied) => { + if (replied) { + unlisted.recordUnlistedCreatedChat(WORKTREE, SESSION, SERVER_HOST) + } else { + pendingLaunch(SERVER_HOST, SERVER) + } + acceptSessionTabsRuntimeId('server-1', 'old') + acceptSessionTabsRuntimeId('server-1', 'new') + const recovered = await recoverWebSessionTerminalOrphansBeforeApply( + useAppStore.getState(), + hostFrame(), + 'server-1', + { expectedRuntimeId: 'old' } + ) + expect(decideWebSessionTabsSnapshot(recovered!, 'server-1', 'old').apply).toBe(false) + expect(getStructuredLaunchStateBySessionId(SESSION)?.hostListed).not.toBe(true) + expect(unlisted.isUnlistedCreatedChat(WORKTREE, SESSION)).toBe(replied) + unlisted.forgetUnlistedCreatedChat(WORKTREE, SESSION) +}) diff --git a/src/renderer/src/runtime/web-runtime-session-snapshot.ts b/src/renderer/src/runtime/web-runtime-session-snapshot.ts index 1a5d87b5c4d9..f201feb3a85b 100644 --- a/src/renderer/src/runtime/web-runtime-session-snapshot.ts +++ b/src/renderer/src/runtime/web-runtime-session-snapshot.ts @@ -156,6 +156,7 @@ export async function refreshWebRuntimeSessionTabsSnapshot( { environmentId, worktreeId: snapshot.worktree, + source: recovered, decision, expectedEnvironmentConnectionGeneration, expectedEnvironmentPairingRevision, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts b/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts index 40f4fbf229e1..4d2163b9532f 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts @@ -139,7 +139,6 @@ export function installActiveSessionTabsSubscription({ const decision = decideWebSessionTabsSnapshot(recovered, environmentId, runtimeId) const syncState = useAppStore.getState() const localTabs = syncState.tabsByWorktree[activeWorktreeId] ?? [] - const localTerminalCount = localTabs.length const hasLiveLocalPty = localTabs.some( (tab) => (syncState.ptyIdsByTabId[tab.id] ?? []).length > 0 ) @@ -150,7 +149,7 @@ export function installActiveSessionTabsSubscription({ activeWorktreeId, requestedRespawnAfterWake, snapshotIsFresh: decision.apply, - localTerminalCount, + localTerminalCount: localTabs.length, hasLiveLocalPty, skipWakeRespawn: shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId) || @@ -174,6 +173,7 @@ export function installActiveSessionTabsSubscription({ { environmentId, worktreeId: recovered.worktree, + source: recovered, decision, expectedEnvironmentConnectionGeneration: activeWorktreeRuntimeConnectionGeneration, expectedEnvironmentPairingRevision: activeWorktreeRuntimePairingRevision, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts b/src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts index 6ea1a1f7cfee..2c70e0c2d185 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts @@ -148,6 +148,7 @@ export function handleGlobalSessionEvent(args: GlobalSessionEventArgs): void { { environmentId, worktreeId: recovered.worktree, + source: recovered, decision, expectedEnvironmentConnectionGeneration, expectedEnvironmentPairingRevision, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts b/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts index d2e193ef859c..e8e62226e266 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/global-session-inventory-event.ts @@ -137,6 +137,7 @@ export function handleGlobalSessionInventoryEvent({ frames: applicable.map(({ snapshot }, index) => ({ environmentId, worktreeId: snapshot.worktree, + source: snapshot, decision: decisions[index]!, expectedEnvironmentConnectionGeneration, expectedEnvironmentPairingRevision, @@ -176,7 +177,6 @@ export function handleGlobalSessionInventoryEvent({ settleHydration?.() } }) - // Each subscription opens with one census: the host is reachable again. Chats it lists were - // already settled as published above, before any recovery await. + // Each subscription opens with one census: recheck launches now that the host is reachable. recheckUnconfirmedStructuredAgentLaunches(toRuntimeExecutionHostId(environmentId)) } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts b/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts index bacf3a67a4a3..1d7fbf8a7e6b 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts @@ -134,6 +134,7 @@ export function loadInitialWebSessionTabs({ frames: applicable.map((snapshot, index) => ({ environmentId, worktreeId: snapshot.worktree, + source: snapshot, decision: decisions[index]!, expectedEnvironmentConnectionGeneration, expectedEnvironmentPairingRevision, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/mirror-settle.ts b/src/renderer/src/runtime/web-session-tabs-sync/mirror-settle.ts index 8da8b63f2cd2..870e2260ab8f 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/mirror-settle.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/mirror-settle.ts @@ -1,3 +1,4 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' import { probeHostLiveTerminals } from '../host-live-terminal-probe' import { @@ -12,6 +13,7 @@ import type { WebSessionTabsSnapshotDecision } from './tracking-decisions' export type HostSessionMirrorSettle = () => void export type HostSessionMirrorPatchFrame = { + source?: RuntimeMobileSessionTabsResult environmentId: string worktreeId: string decision: WebSessionTabsSnapshotDecision diff --git a/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts index 8cc352bfdab6..a553d9cecd9b 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts @@ -1,3 +1,8 @@ +import { + markStructuredAgentSessionLaunchesPublished, + publishedStructuredSessions +} from '@/lib/structured-agent-session-launch-publication' +import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' import type { AppState } from '../../store' import { useAppStore } from '../../store' import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' @@ -180,6 +185,14 @@ export function applyWebSessionTabsStorePatch( console.warn('[web-session-tabs-sync] a store subscriber failed after the patch landed:', error) } + for (const { environmentId, source, decision } of hostMirrorVerdict.frames) { + if (decision.apply && source) { + markStructuredAgentSessionLaunchesPublished( + toRuntimeExecutionHostId(environmentId), + publishedStructuredSessions([source]) + ) + } + } const settleHostMirror = createHostSessionMirrorSettle(hostMirrorVerdict) try { if (mirroredAgentStatusChanged) { diff --git a/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-repair.ts b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-repair.ts index 367342d6ef8b..cf89b02bd37c 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-repair.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-repair.ts @@ -21,6 +21,7 @@ export function applyVisibilityResumeRepairs( frames: decided.map(({ environmentId, snapshot, decision }) => ({ environmentId, worktreeId: snapshot.worktree, + source: snapshot, decision, expectedEnvironmentConnectionGeneration: batch.environments.get(environmentId)?.expectedEnvironmentConnectionGeneration, diff --git a/src/renderer/src/runtime/web-session-terminal-orphan-recovery.ts b/src/renderer/src/runtime/web-session-terminal-orphan-recovery.ts index 5191c397f3d2..1c7cc382d6cf 100644 --- a/src/renderer/src/runtime/web-session-terminal-orphan-recovery.ts +++ b/src/renderer/src/runtime/web-session-terminal-orphan-recovery.ts @@ -30,7 +30,7 @@ import { terminalRowsBySurface } from './web-session-terminal-orphan-recovery-surface-index' import { resolveTerminalOrphanInventory } from './web-session-terminal-orphan-recovery-inventory' -import { acceptPairedHostStructuredSessions } from './structured-agent-session-tab-retirement' +import { suppressCancelledStructuredSessionTabs } from './structured-agent-session-tab-retirement' import { resolvePersistedTerminalSurfaces } from './web-session-terminal-orphan-recovery-pane' import { clearCachedSurfaceResolutions, @@ -252,11 +252,10 @@ export function recoverWebSessionTerminalOrphansBeforeApply( ): Promise { const options = normalizeOptions(optionsOrCall) // Why: every host frame enters recovery here, so this is where a delta frame regains the proofs - // the host already sent this client (see the ledger for the negotiated contract), and where its - // structured chats settle this client's launch bookkeeping. - const snapshot = acceptPairedHostStructuredSessions( + // the host already sent this client (see the ledger for the negotiated contract). + const snapshot = suppressCancelledStructuredSessionTabs( mergeRetainedTerminalRetirementProofs(environmentId, frame), - environmentId + { kind: 'environment', environmentId } ) const key = recoveryKey( environmentId, From 8abd33ca8e622f705da62440a91b42c4e48839df Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:22:42 -0700 Subject: [PATCH 33/42] Update paired chat close regression to current suppression API --- .../structured-session-tab-close-regressions.unit.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/e2e/structured-session-tab-close-regressions.unit.test.ts b/tests/e2e/structured-session-tab-close-regressions.unit.test.ts index 247b99ce2dc7..3d907517a2c5 100644 --- a/tests/e2e/structured-session-tab-close-regressions.unit.test.ts +++ b/tests/e2e/structured-session-tab-close-regressions.unit.test.ts @@ -14,7 +14,7 @@ import { registerTerminalUiRoutingIpcBridge } from '../../src/renderer/src/hooks import type { RuntimeClientTarget } from '../../src/shared/runtime-client-target' import { applyWebSessionTabsSnapshot } from '../../src/renderer/src/runtime/web-session-tabs-sync/snapshot-api' import { - acceptPairedHostStructuredSessions, + suppressCancelledStructuredSessionTabs, retireStructuredAgentSessionTab } from '../../src/renderer/src/runtime/structured-agent-session-tab-retirement' import { isLocalSessionTabCloseOwned } from '../../src/renderer/src/runtime/local-session-tab-close-owner' @@ -467,7 +467,10 @@ describe('in-flight structured session close', () => { useAppStore.setState((state) => applyWebSessionTabsSnapshot( state, - acceptPairedHostStructuredSessions(snapshot, SERVER), + suppressCancelledStructuredSessionTabs(snapshot, { + kind: 'environment', + environmentId: SERVER + }), SERVER ) ) From e382d82092faa2ca3a411bdbfbc9a6a406c0608c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:33:25 -0700 Subject: [PATCH 34/42] fix(chat): Portuguese Split palette words describe a new chat --- src/renderer/src/i18n/locales/pt.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 7165d202856a..6fb498bf729f 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -17467,16 +17467,16 @@ "addQuickCommand": "adicionar comando rápido", "newQuickCommand": "novo comando rápido", "splitChatRight": "dividir chat à direita", - "moveChatRight": "mover chat para a direita", + "newChatRight": "novo chat à direita", "chatPaneRight": "painel de chat à direita", "splitChatDown": "dividir chat para baixo", - "moveChatDown": "mover chat para baixo", + "newChatBelow": "novo chat abaixo", "chatPaneBelow": "painel de chat abaixo" }, "splitChatRight": "Dividir Chat à Direita", - "splitChatRightDescription": "Abra o chat ativo em um painel dividido à direita.", + "splitChatRightDescription": "Abra um novo chat em um painel dividido à direita.", "splitChatDown": "Dividir Chat para Baixo", - "splitChatDownDescription": "Abra o chat ativo em um painel dividido abaixo." + "splitChatDownDescription": "Abra um novo chat em um painel dividido abaixo." } }, "palette": { From 649d734193800b0670f02a40e498b90cbe659d1a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:11:43 -0700 Subject: [PATCH 35/42] test(chat): use the destination group option in split fixture --- .../src/components/native-chat/NativeChatPaneHeader.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/NativeChatPaneHeader.test.tsx b/src/renderer/src/components/native-chat/NativeChatPaneHeader.test.tsx index 1350df0d4e78..575c5071d0ef 100644 --- a/src/renderer/src/components/native-chat/NativeChatPaneHeader.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPaneHeader.test.tsx @@ -81,7 +81,7 @@ it('Split from the header keeps the source and opens a fresh chat on the same ho const source = renderHeader() mocks.launch.mockImplementation((args: { worktreeId: string; groupId: string }) => { useAppStore.getState().createUnifiedTab(args.worktreeId, 'agent-session', { - groupId: args.groupId, + targetGroupId: args.groupId, agentSessionAgent: 'codex', executionHostId: 'runtime:remote-host' }) From 1d25bcc49d06226b11603e118befccfae3b63789 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:56:42 -0700 Subject: [PATCH 36/42] Avoid History projection in native chat header and currentness checks --- .../native-chat/NativeChatPaneHeader.test.tsx | 27 ++++++++---- .../NativeChatResumeInCliButton.tsx | 21 ++++++--- .../tab-bar/NativeChatCliForkMenuItems.tsx | 4 +- .../tab-bar/native-chat-cli-fork-run.test.ts | 10 +++-- .../tab-bar/native-chat-cli-fork-run.ts | 20 ++++----- .../tab-session-history-switch.test.ts | 20 +++++++++ .../tab-bar/tab-session-history-switch.ts | 44 ++++++++++++++++--- 7 files changed, 112 insertions(+), 34 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatPaneHeader.test.tsx b/src/renderer/src/components/native-chat/NativeChatPaneHeader.test.tsx index 575c5071d0ef..4834ea2b983b 100644 --- a/src/renderer/src/components/native-chat/NativeChatPaneHeader.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPaneHeader.test.tsx @@ -1,9 +1,11 @@ // @vitest-environment happy-dom -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import { useAppStore } from '@/store' import { createTabsSliceMockApi } from '@/store/slices/tabs-slice-test-harness' +import { makeFolderWorkspace } from '@/store/slices/worktrees-slice-test-fixtures' +import * as historyRequest from '../right-sidebar/ai-vault-panel-session-list-request' const mocks = vi.hoisted(() => ({ launch: vi.fn(), resume: vi.fn(), close: vi.fn() })) vi.mock('@/store', async () => { @@ -16,15 +18,10 @@ vi.mock('../tab-bar/native-chat-cli-fork-run', () => ({ runNativeChatCliFork: mocks.resume, useNativeChatCliForkPending: () => false })) -vi.mock('../tab-bar/tab-session-history-switch', () => ({ - resolveTabSessionHistorySubject: ( - _state: unknown, - args: { nativeChat: { sessionId: string } } - ) => ({ kind: 'chat', sessionId: args.nativeChat.sessionId, workspaceId: 'folder-workspace' }) -})) import { NativeChatPaneHeader } from './NativeChatPaneHeader' +const fullRequest = vi.spyOn(historyRequest, 'resolveAiVaultPanelSessionListRequest') const testWindow = window const api = createTabsSliceMockApi() vi.stubGlobal('window', testWindow) @@ -33,10 +30,13 @@ afterEach(cleanup) beforeEach(() => { vi.clearAllMocks() useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + folderWorkspaces: [makeFolderWorkspace({ id: 'workspace', folderPath: '/repo' })] + }) }) function renderHeader() { - const source = useAppStore.getState().createUnifiedTab('folder-workspace', 'agent-session', { + const source = useAppStore.getState().createUnifiedTab('folder:workspace', 'agent-session', { agentSessionAgent: 'codex', executionHostId: 'runtime:remote-host' }) @@ -62,10 +62,19 @@ it('offers the terminal header action order and targets Resume and Close at the 'Split Chat Right', 'Close tab' ]) + const folder = useAppStore.getState().folderWorkspaces[0] + act(() => useAppStore.setState({ folderWorkspaces: [{ ...folder, executionHostId: 'ssh:dev' }] })) + expect(screen.queryByRole('button', { name: 'Continue with Terminal Agent' })).toBeNull() + act(() => useAppStore.setState({ folderWorkspaces: [folder] })) + expect(fullRequest).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: 'Continue with Terminal Agent' })) + expect(fullRequest).toHaveBeenCalledTimes(1) expect(mocks.resume).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ - subject: expect.objectContaining({ sessionId: source.entityId }), + subject: expect.objectContaining({ + sessionId: source.entityId, + request: { scopePaths: ['/repo'], executionHostScope: 'local', sessionLimit: 250 } + }), tab: { id: source.id, worktreeId: source.worktreeId }, agent: 'codex' }) diff --git a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx index e81c6f50dfd2..e58f1fade8a3 100644 --- a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx @@ -10,6 +10,7 @@ import { } from '../tab-bar/native-chat-cli-fork-run' import { resolveTabSessionHistorySubject, + resolveNativeChatCliForkIdentity, type NativeChatTabSession } from '../tab-bar/tab-session-history-switch' @@ -23,16 +24,20 @@ export function NativeChatResumeInCliButton({ nativeChat: NativeChatTabSession }): React.JSX.Element | null { const tab = { id: tabId, worktreeId } - const subject = resolveTabSessionHistorySubject(useAppStore.getState(), { tab, nativeChat }) - const chat = subject?.kind === 'chat' ? subject : null - const pending = useNativeChatCliForkPending(chat) + const executionHostScope = useAppStore( + (state) => + resolveNativeChatCliForkIdentity(state, { tab, nativeChat })?.executionHostScope ?? null + ) + const pending = useNativeChatCliForkPending( + executionHostScope ? { executionHostScope, sessionId: nativeChat.sessionId } : null + ) const commandOverrides = useAppStore((state) => state.settings?.agentCmdOverrides) const agent = AI_VAULT_AGENTS.find((candidate) => candidate === nativeChat.agent) const label = translate( 'components.tab.bar.SortableTabContextMenu.switchToTerminalView', 'Continue with Terminal Agent' ) - if (!chat || !agent) { + if (!executionHostScope || !agent) { return null } return ( @@ -47,7 +52,13 @@ export function NativeChatResumeInCliButton({ aria-busy={pending || undefined} onClick={(event) => { event.stopPropagation() - void runNativeChatCliFork({ subject: chat, tab, agent, commandOverrides }) + const subject = resolveTabSessionHistorySubject(useAppStore.getState(), { + tab, + nativeChat + }) + if (subject?.kind === 'chat') { + void runNativeChatCliFork({ subject, tab, agent, commandOverrides }) + } }} > diff --git a/src/renderer/src/components/tab-bar/NativeChatCliForkMenuItems.tsx b/src/renderer/src/components/tab-bar/NativeChatCliForkMenuItems.tsx index 99a1c1e73e9d..bf17bbc72587 100644 --- a/src/renderer/src/components/tab-bar/NativeChatCliForkMenuItems.tsx +++ b/src/renderer/src/components/tab-bar/NativeChatCliForkMenuItems.tsx @@ -24,7 +24,9 @@ export function NativeChatCliForkMenuItems({ resolveTabSessionHistorySubject(useAppStore.getState(), { tab, nativeChat }) ) const chat = subject?.kind === 'chat' ? subject : null - const pending = useNativeChatCliForkPending(chat) + const pending = useNativeChatCliForkPending( + chat ? { executionHostScope: chat.request.executionHostScope, sessionId: chat.sessionId } : null + ) const commandOverrides = useAppStore((state) => state.settings?.agentCmdOverrides) const agent = AI_VAULT_AGENTS.find((candidate) => candidate === nativeChat.agent) if (!chat || !agent) { diff --git a/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.test.ts b/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.test.ts index 48affbb294b0..631c079ff88d 100644 --- a/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.test.ts +++ b/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.test.ts @@ -34,10 +34,10 @@ vi.mock('@/store', () => ({ } })) vi.mock('./tab-session-history-switch', () => ({ - resolveTabSessionHistorySubject: () => ({ - kind: 'chat', - request: { executionHostScope: mocks.host } - }) + resolveNativeChatCliForkIdentity: () => ({ executionHostScope: mocks.host }), + resolveTabSessionHistorySubject: () => { + throw new Error('History projection in currentness') + } })) vi.mock('@/lib/native-chat-cli-fork-preparation', () => ({ prepareNativeChatCliFork: mocks.prepare @@ -93,6 +93,8 @@ it('shares pending across menu openings and suppresses dispatch after source ret const first = runNativeChatCliFork(args) await runNativeChatCliFork(args) expect(mocks.prepare).toHaveBeenCalledTimes(1) + mocks.listeners.forEach((listener) => listener()) + expect(mocks.error).not.toHaveBeenCalled() closeSource() settle(prepared) await first diff --git a/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.ts b/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.ts index 694f54887159..45ef6fc2de2e 100644 --- a/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.ts +++ b/src/renderer/src/components/tab-bar/native-chat-cli-fork-run.ts @@ -23,19 +23,22 @@ import { onRuntimeEnvironmentRevisionsChanged } from '@/runtime/runtime-environment-revision' import type { AiVaultAgent } from '../../../../shared/ai-vault-types' -import { resolveTabSessionHistorySubject } from './tab-session-history-switch' +import { + resolveNativeChatCliForkIdentity, + type NativeChatCliForkIdentity +} from './tab-session-history-switch' const pending = new Map() const listeners = new Set<() => void>() const changed = () => listeners.forEach((listener) => listener()) -const keyFor = (subject: NativeChatCliForkSubject) => - `${subject.request.executionHostScope}\0${subject.sessionId}` +type PendingIdentity = Pick +const keyFor = (subject: PendingIdentity) => `${subject.executionHostScope}\0${subject.sessionId}` const subscribe = (listener: () => void) => { listeners.add(listener) return () => listeners.delete(listener) } -export function useNativeChatCliForkPending(subject: NativeChatCliForkSubject | null): boolean { +export function useNativeChatCliForkPending(subject: PendingIdentity | null): boolean { return useSyncExternalStore( subscribe, () => !!subject && pending.has(keyFor(subject)), @@ -51,7 +54,7 @@ export async function runNativeChatCliFork(args: { commandOverrides?: Partial> }): Promise { const { subject, tab } = args - const key = keyFor(subject) + const key = keyFor({ ...subject, executionHostScope: subject.request.executionHostScope }) if (pending.has(key)) { return } @@ -66,15 +69,12 @@ export async function runNativeChatCliFork(args: { const source = state.unifiedTabsByWorktree[tab.worktreeId]?.find((item) => item.id === tab.id) const current = source?.contentType === 'agent-session' && source.entityId === subject.sessionId - ? resolveTabSessionHistorySubject(state, { + ? resolveNativeChatCliForkIdentity(state, { tab, nativeChat: { sessionId: subject.sessionId, agent: args.agent } }) : null - return ( - current?.kind === 'chat' && - current.request.executionHostScope === subject.request.executionHostScope - ) + return current?.executionHostScope === subject.request.executionHostScope } if (!isCurrent()) { return diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts index 1b39afd447a8..2ca6065c8eb6 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts @@ -24,6 +24,7 @@ import { findTabSessionHistoryRow, lookupTabSessionHistoryRow, resolveTabSessionHistorySubject, + resolveNativeChatCliForkIdentity, resolveTabSessionSwitch, type TabSessionHistorySubject } from './tab-session-history-switch' @@ -201,6 +202,25 @@ describe('resolveTabSessionHistorySubject', () => { executionHostId: 'local' }) }) + it.each(['local', 'runtime:server-1', 'ssh:dev-box', null] as const)( + 'keeps lightweight native eligibility and the public request on workspace host %s', + (executionHostId) => { + const state = makeState({}, executionHostId ? { executionHostId } : {}) + if (!executionHostId) { + state.repos = [] + } + const args = { + tab: { id: 'chat-tab', worktreeId: WORKTREE_ID }, + nativeChat: { sessionId: 'chat-1', agent: 'claude' as const } + } + const expected = executionHostId === 'ssh:dev-box' ? null : executionHostId + const identity = resolveNativeChatCliForkIdentity(state, args) + const full = resolveTabSessionHistorySubject(state, args) + expect(identity?.executionHostScope ?? null).toBe(expected) + expect(full?.request.executionHostScope ?? null).toBe(expected) + } + ) + it('finds a native chat tab by the chat it shows', () => { expect( resolveTabSessionHistorySubject(makeState(), { diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.ts index 90656c586335..d620c9924d06 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.ts @@ -113,6 +113,33 @@ export type NativeChatTabSession = { agent: AgentType | undefined } +export type NativeChatCliForkIdentity = { + workspaceId: string + sessionId: string + executionHostScope: ExecutionHostScope +} + +export function resolveNativeChatCliForkIdentity( + state: AppState, + args: { tab: Pick; nativeChat: NativeChatTabSession } +): NativeChatCliForkIdentity | null { + const { nativeChat, tab } = args + if ( + !nativeChat.agent || + !agentForksConversations(nativeChat.agent) || + !resolveAiVaultTargetWorkspacePath(state, tab.worktreeId) + ) { + return null + } + const executionHostScope = resolveAiVaultHostScopeDefaults( + getAiVaultResumeWorkspaceExecutionHostId(state, tab.worktreeId), + tab.worktreeId + ).defaultExecutionHostScope + return canHostOfferTabSessionMove('chat', executionHostScope) + ? { workspaceId: tab.worktreeId, sessionId: nativeChat.sessionId, executionHostScope } + : null +} + export function resolveTabSessionHistorySubject( state: AppState, args: { @@ -134,11 +161,18 @@ export function resolveTabSessionHistorySubject( } if (args.nativeChat) { // A chat tab's only move is opening a copy in its CLI. - const target = - args.nativeChat.agent && agentForksConversations(args.nativeChat.agent) - ? workspace(args.tab.worktreeId, 'chat') - : null - return target ? { ...target, kind: 'chat', sessionId: args.nativeChat.sessionId } : null + const identity = resolveNativeChatCliForkIdentity(state, { + ...args, + nativeChat: args.nativeChat + }) + return identity + ? { + kind: 'chat', + workspaceId: identity.workspaceId, + sessionId: identity.sessionId, + request: resolveAiVaultPanelSessionListRequest(state, identity.workspaceId) + } + : null } // The pane-to-conversation mapping tab titles use, for the agents a chat can take over, limited to // agents still running: once the agent exits, the tab title keeps its conversation but the move is From f0e90c86ace0b9d446715b22f8c47cc0fb2e2783 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:18:01 -0700 Subject: [PATCH 37/42] fix(native-chat): keep Find below pane controls --- .../src/components/native-chat/NativeChatFindBar.tsx | 2 +- .../src/components/native-chat/native-chat-find.css | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/NativeChatFindBar.tsx b/src/renderer/src/components/native-chat/NativeChatFindBar.tsx index 9bce722ca2f6..68edd3f9524a 100644 --- a/src/renderer/src/components/native-chat/NativeChatFindBar.tsx +++ b/src/renderer/src/components/native-chat/NativeChatFindBar.tsx @@ -44,7 +44,7 @@ export function NativeChatFindBar({ // The chat sends clicks on its plain surface to the composer; the bar's are its own. data-native-chat-typing-redirect-ignore="true" data-native-chat-find-bar="true" - className="absolute top-2 right-4 z-20 flex w-85 max-w-[calc(100%-2rem)] items-center gap-1 rounded-lg border border-border bg-popover/95 px-2 py-1 text-popover-foreground shadow-floating backdrop-blur-sm" + className="absolute top-[calc(var(--native-chat-find-top-inset,0px)+var(--spacing)*2)] right-4 z-20 flex w-85 max-w-[calc(100%-2rem)] items-center gap-1 rounded-lg border border-border bg-popover/95 px-2 py-1 text-popover-foreground shadow-floating backdrop-blur-sm" onMouseDown={(event) => { // A press on the count or padding keeps typing in the find input. if (!(event.target instanceof Element) || !event.target.closest('button, input')) { diff --git a/src/renderer/src/components/native-chat/native-chat-find.css b/src/renderer/src/components/native-chat/native-chat-find.css index 181808317187..3e828708e4e2 100644 --- a/src/renderer/src/components/native-chat/native-chat-find.css +++ b/src/renderer/src/components/native-chat/native-chat-find.css @@ -1,3 +1,11 @@ +.native-chat-appearance { + --native-chat-find-top-inset: 0px; +} + +.native-chat-appearance:has(> .pane-title-bar) { + --native-chat-find-top-inset: var(--orca-pane-title-height); +} + /* Why: find matches are painted as CSS highlights (Ranges, no DOM mutation), in the markdown preview's colors; see use-native-chat-find-matches.ts. ::highlight() supports color/background only. */ From 8391b9f78d0a18beae8f14e1c473cd6ad1d03840 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:01:46 -0700 Subject: [PATCH 38/42] fix(ui): keep noninteractive tooltip layers pointer-transparent --- src/renderer/src/assets/main.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 09f930c216c0..7d33051babca 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -960,6 +960,12 @@ html.native-shell .app-layout { -webkit-app-region: no-drag; } +/* Modal inline pointer styles must not turn noninteractive labels into hit targets. */ +[data-radix-popper-content-wrapper]:has(> [data-slot='tooltip-content'].pointer-events-none), +[data-slot='tooltip-content'].pointer-events-none { + pointer-events: none !important; +} + .titlebar { height: 36px; min-height: 36px; From c088804a16670151ac62126c597fb45665b7939d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 05:22:20 -0700 Subject: [PATCH 39/42] Mount native chat actions only for shown panes --- .../native-chat/NativeChatPaneHeader.tsx | 10 +- .../NativeChatResumeInCliButton.tsx | 43 +- .../native-chat-header-ownership.test.tsx | 378 ++++++++++++++++++ 3 files changed, 426 insertions(+), 5 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-header-ownership.test.tsx diff --git a/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx b/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx index 80b6856995a2..ab301fb84a5e 100644 --- a/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx @@ -100,12 +100,18 @@ export function NativeChatPaneHeader({ export function nativeChatPaneHeader( view: Pick< NativeChatStructuredViewProps, - 'tabId' | 'groupId' | 'sessionId' | 'agent' | 'isFocusedGroup' | 'contextMenuActions' + | 'tabId' + | 'groupId' + | 'sessionId' + | 'agent' + | 'isVisible' + | 'isFocusedGroup' + | 'contextMenuActions' >, worktreeId: string | null, colorScheme: React.CSSProperties['colorScheme'] ): React.JSX.Element | null { - return worktreeId && view.groupId && !view.contextMenuActions ? ( + return view.isVisible && worktreeId && view.groupId && !view.contextMenuActions ? ( { + if ( + worktrees !== state.worktreesByRepo || + folders !== state.folderWorkspaces || + groups !== state.projectGroups || + repos !== state.repos + ) { + scope = + resolveNativeChatCliForkIdentity(state, { + tab: { id: tabId, worktreeId }, + nativeChat: { sessionId, agent } + })?.executionHostScope ?? null + worktrees = state.worktreesByRepo + folders = state.folderWorkspaces + groups = state.projectGroups + repos = state.repos + } + return scope + } +} + export function NativeChatResumeInCliButton({ tabId, worktreeId, @@ -24,10 +59,12 @@ export function NativeChatResumeInCliButton({ nativeChat: NativeChatTabSession }): React.JSX.Element | null { const tab = { id: tabId, worktreeId } - const executionHostScope = useAppStore( - (state) => - resolveNativeChatCliForkIdentity(state, { tab, nativeChat })?.executionHostScope ?? null + const { sessionId, agent: nativeAgent } = nativeChat + const selectExecutionHost = useMemo( + () => createExecutionHostSelector(tabId, worktreeId, sessionId, nativeAgent), + [tabId, worktreeId, sessionId, nativeAgent] ) + const executionHostScope = useAppStore(selectExecutionHost) const pending = useNativeChatCliForkPending( executionHostScope ? { executionHostScope, sessionId: nativeChat.sessionId } : null ) diff --git a/src/renderer/src/components/native-chat/native-chat-header-ownership.test.tsx b/src/renderer/src/components/native-chat/native-chat-header-ownership.test.tsx new file mode 100644 index 000000000000..d51a26f37ae0 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-header-ownership.test.tsx @@ -0,0 +1,378 @@ +// @vitest-environment happy-dom +import { Profiler } from 'react' +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { useAppStore } from '@/store' +import type { Tab } from '../../../../shared/tab-types' +import { createTabsSliceMockApi } from '@/store/slices/tabs-slice-test-harness' +import { makeTabGroup } from '@/store/slices/store-test-helpers' +import { + makeFolderWorkspace, + makeRepo, + makeUnifiedTab, + makeWorktree +} from '../worktree-jump-palette-test-fixtures' +import * as subjects from '../tab-bar/tab-session-history-switch' +import * as history from '../right-sidebar/ai-vault-panel-session-list-request' +import type { NativeChatCliForkPreparation } from '@/lib/native-chat-cli-fork-preparation' +import type { NativeChatStructuredViewProps } from './native-chat-view-types' +import type * as paneActions from './use-structured-chat-pane-actions' + +const work = vi.hoisted(() => ({ + header: false, + headerVisits: 0, + otherVisits: 0, + lookups: 0, + commits: 0 +})) +const mocks = vi.hoisted(() => ({ prepare: vi.fn(), launch: vi.fn(), close: vi.fn() })) +vi.mock('@/store', async () => { + const { createTestStore } = await import('@/store/slices/store-test-helpers') + return { useAppStore: createTestStore() } +}) +vi.mock('./use-structured-chat-pane-actions', async (original) => { + const { useStructuredChatPaneActions } = await original() + return { + useStructuredChatPaneActions(...args: Parameters) { + work.header = true + const actions = useStructuredChatPaneActions(...args) + work.header = false + return actions + } + } +}) +vi.mock('./NativeChatView', async () => { + const { nativeChatPaneHeader } = await import('./NativeChatPaneHeader') + return { + default: (props: NativeChatStructuredViewProps) => ( +
+ {nativeChatPaneHeader(props, 'folder:workspace', 'dark')} + {props.sessionId} +
+ ) + } +}) +vi.mock('@/lib/workspace-tab-commands', () => ({ dispatchWorkspaceTabCommand: mocks.close })) +vi.mock('@/lib/native-chat-cli-fork-preparation', () => ({ + prepareNativeChatCliFork: mocks.prepare +})) +vi.mock('@/lib/launch-ai-vault-session-and-reveal', () => ({ + launchAiVaultSessionAndReveal: mocks.launch +})) +vi.mock('@/lib/ai-vault-session-fork-startup', () => ({ + assertAiVaultCopiedSessionLaunchAllowed: () => {}, + buildAiVaultForkStartupForWorktree: () => ({ command: 'fixture-fork' }) +})) + +import StructuredAgentSessionPaneOverlayLayer from './StructuredAgentSessionPaneOverlayLayer' +import { NativeChatResumeInCliButton } from './NativeChatResumeInCliButton' + +const workspace = 'folder:workspace' +const initial = useAppStore.getInitialState() +const fullRequest = vi.spyOn(history, 'resolveAiVaultPanelSessionListRequest') +const identity = vi.spyOn(subjects, 'resolveNativeChatCliForkIdentity') +const testWindow = window +const originalApi = window.api +const api = createTabsSliceMockApi() +vi.stubGlobal('window', testWindow) +beforeEach(() => { + vi.clearAllMocks() + Object.assign(window, { api }) + useAppStore.setState(initial, true) + useAppStore.setState({ + folderWorkspaces: [makeFolderWorkspace({ id: 'workspace', folderPath: '/repo' })] + }) +}) +afterEach(() => { + cleanup() + useAppStore.setState(initial, true) + Object.assign(window, { api: originalApi }) +}) +function countedTab(tab: Tab): Tab { + return new Proxy(tab, { + get(target, key, receiver) { + if (key === 'groupId' || key === 'id') { + work[work.header ? 'headerVisits' : 'otherVisits']++ + } + return Reflect.get(target, key, receiver) + } + }) +} +function seedChats(size: number) { + const tabs = Array.from({ length: size }, (_, index) => + countedTab({ + ...makeUnifiedTab(`chat-${index}`, workspace, `session-${index}`, `Chat ${index}`), + contentType: 'agent-session', + agentSessionAgent: 'codex', + executionHostId: 'local', + groupId: index === size - 1 ? 'second' : 'first' + }) + ) + const groups = ['first', 'second'].map((id) => + makeTabGroup({ + id, + worktreeId: workspace, + tabOrder: tabs.filter((tab) => tab.groupId === id).map((tab) => tab.id), + activeTabId: id === 'first' ? 'chat-0' : `chat-${size - 1}` + }) + ) + useAppStore.setState({ + unifiedTabsByWorktree: { [workspace]: tabs }, + groupsByWorktree: { [workspace]: groups }, + activeGroupIdByWorktree: { [workspace]: 'first' } + }) +} +function overlay(active: boolean) { + return ( + + + + ) +} +function chat(container: HTMLElement, id: string) { + return within(container).getByText(`session-${id.slice(5)}`).parentElement! +} + +it.each([10, 100])( + 'bounds shown header tab work at %i retained chats and keeps hidden transcripts', + async (size) => { + seedChats(size) + const view = render(overlay(true)) + const retained = chat(view.container, 'chat-0').querySelector('output') + const unfocused = chat(view.container, `chat-${size - 1}`) + expect(within(unfocused).getByRole('button', { name: 'Close Pane' })).toBeTruthy() + for (const operation of [ + () => useAppStore.getState().setTabLabel('chat-0', 'Changed title'), + () => useAppStore.getState().activateTab('chat-1', { worktreeId: workspace }) + ]) { + work.headerVisits = work.otherVisits = 0 + await act(async () => operation()) + expect(work.headerVisits).toBeGreaterThan(0) + // Filter plus pin lookup: at most four real tab visits per shown header per tab. + expect(work.headerVisits).toBeLessThanOrEqual(4 * 2 * size) + expect(work.otherVisits).toBeGreaterThan(0) + } + expect(useAppStore.getState().getTab('chat-0')?.label).toBe('Changed title') + expect(chat(view.container, 'chat-0').querySelector('output')).toBe(retained) + view.rerender(overlay(false)) + work.headerVisits = 0 + await act(async () => { + useAppStore.getState().setTabLabel('chat-1', 'Hidden title') + useAppStore.getState().pinTab('chat-1', { recordInteraction: false }) + useAppStore.setState({ + groupsByWorktree: { [workspace]: [useAppStore.getState().groupsByWorktree[workspace][0]] } + }) + }) + expect(work.headerVisits).toBe(0) + expect(view.container.querySelector('.pane-title-bar')).toBeNull() + view.rerender(overlay(true)) + const shown = chat(view.container, 'chat-1') + expect(within(shown).queryByRole('button', { name: 'Close tab' })).toBeNull() + act(() => useAppStore.getState().unpinTab('chat-1', { recordInteraction: false })) + fireEvent.click(within(shown).getByRole('button', { name: 'Close tab' })) + expect(mocks.close).toHaveBeenCalledExactlyOnceWith({ + type: 'close', + target: { kind: 'tab', worktreeId: workspace, tabId: 'chat-1' } + }) + expect(chat(view.container, 'chat-0').querySelector('output')).toBe(retained) + expect(fullRequest).not.toHaveBeenCalled() + } +) + +function countedRecords(records: T[]): T[] { + return new Proxy(records, { + get(target, key, receiver) { + if (typeof key === 'string' && /^\d+$/.test(key)) { + work.lookups++ + } + return Reflect.get(target, key, receiver) + } + }) +} +function eligibility( + folder: string, + git: string, + sessionId = 'session', + agent: subjects.NativeChatTabSession['agent'] = 'codex', + tabId = 'tab' +) { + return ( + + work.commits++}> + {[ + { key: 'folder', id: folder }, + { key: 'git', id: git } + ].map(({ key, id }) => ( + + ))} + + + ) +} + +it.each([10, 100])( + 'does no idle eligibility lookup across %i workspace records and tracks all dependencies', + async (size) => { + const folders = countedRecords( + Array.from({ length: size }, (_, i) => + makeFolderWorkspace({ + id: `folder-${i}`, + folderPath: `/folder/${i}`, + projectGroupId: `project-${size - 1}` + }) + ) + ) + const groups = countedRecords( + Array.from({ length: size }, (_, i) => ({ + id: `project-${i}`, + name: `Project ${i}`, + parentPath: null, + parentGroupId: null, + createdFrom: 'manual' as const, + tabOrder: i, + isCollapsed: false, + color: null, + createdAt: 0, + updatedAt: 0 + })) + ) + const repos = countedRecords( + Array.from({ length: size }, (_, i) => ({ + ...makeRepo(), + id: `repo-${i}`, + projectGroupId: `project-${size - 1}` + })) + ) + const worktrees = Object.fromEntries( + repos.map((repo, i) => [ + repo.id, + countedRecords([ + makeWorktree(`${repo.id}::/git/${i}`, `Tree ${i}`, { repoId: repo.id, path: `/git/${i}` }) + ]) + ]) + ) + const folder = `folder:folder-${size - 1}` + const git = `repo-${size - 1}::/git/${size - 1}` + useAppStore.setState({ + folderWorkspaces: folders, + projectGroups: groups, + repos, + worktreesByRepo: worktrees + }) + const view = render(eligibility(folder, git)) + work.lookups = work.commits = 0 + identity.mockClear() + act(() => { + for (let tick = 0; tick < 50; tick++) { + useAppStore.setState({ lastTerminalInputAtByPaneKey: { 'other:pane': tick } }) + } + }) + expect(work.lookups).toBe(0) + expect(identity).not.toHaveBeenCalled() + expect(work.commits).toBe(0) + const restore = { + folderWorkspaces: folders, + projectGroups: groups, + repos, + worktreesByRepo: worktrees + } + const replacements = [ + { + worktreesByRepo: { + ...worktrees, + [`repo-${size - 1}`]: countedRecords([ + makeWorktree(git, 'missing path', { repoId: `repo-${size - 1}`, path: '' }) + ]) + } + }, + { + folderWorkspaces: countedRecords( + folders.map((row, i) => (i === size - 1 ? { ...row, folderPath: '' } : row)) + ) + }, + { + projectGroups: countedRecords( + groups.map((row, i) => (i === size - 1 ? { ...row, executionHostId: 'ssh:dev' } : row)) + ) + }, + { repos: countedRecords(repos.map((row) => ({ ...row, executionHostId: 'ssh:dev' }))) } + ] + for (const replacement of replacements) { + work.lookups = 0 + identity.mockClear() + await act(async () => useAppStore.setState(replacement)) + expect(identity).toHaveBeenCalledTimes(2) + expect(work.lookups).toBeGreaterThan(0) + // Two shown buttons; existing folder candidates/groups and git lookups are linear in records. + expect(work.lookups).toBeLessThanOrEqual(20 * 2 * size) + const expected = [folder, git].filter((worktreeId) => + subjects.resolveNativeChatCliForkIdentity(useAppStore.getState(), { + tab: { id: 'tab', worktreeId }, + nativeChat: { sessionId: 'session', agent: 'codex' } + }) + ) + expect(screen.queryAllByRole('button')).toHaveLength(expected.length) + await act(async () => useAppStore.setState(restore)) + expect(screen.getAllByRole('button')).toHaveLength(2) + } + identity.mockClear() + view.rerender(eligibility(folder, git)) + expect(identity).not.toHaveBeenCalled() + view.rerender(eligibility(folder, git, 'new-session')) + expect(identity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ nativeChat: { sessionId: 'new-session', agent: 'codex' } }) + ) + identity.mockClear() + view.rerender(eligibility(folder, git, 'new-session', 'codex', 'new-tab')) + expect(identity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ tab: { id: 'new-tab', worktreeId: folder } }) + ) + view.rerender(eligibility(folder, git, 'new-session', 'gemini', 'new-tab')) + expect(screen.queryAllByRole('button')).toHaveLength(0) + view.rerender(eligibility(folder, git, 'new-session', 'codex', 'new-tab')) + expect(screen.getAllByRole('button')).toHaveLength(2) + view.rerender(eligibility('folder:missing', git, 'new-session', 'codex', 'new-tab')) + expect(screen.getAllByRole('button')).toHaveLength(1) + expect(fullRequest).not.toHaveBeenCalled() + } +) + +it('keeps the real Continue pending operation disabled across header hide/reveal and launches once', async () => { + seedChats(10) + const preparation = Promise.withResolvers() + mocks.prepare.mockReturnValue(preparation.promise) + mocks.launch.mockResolvedValue(undefined) + const view = render(overlay(true)) + const source = chat(view.container, 'chat-0') + const button = () => within(source).getByRole('button', { name: 'Continue with Terminal Agent' }) + expect(fullRequest).not.toHaveBeenCalled() + fireEvent.click(button()) + expect(button().hasAttribute('disabled')).toBe(true) + expect(button().getAttribute('aria-busy')).toBe('true') + view.rerender(overlay(false)) + expect(within(source).queryByRole('button')).toBeNull() + expect(useAppStore.getState().getTab('chat-0')).not.toBeNull() + view.rerender(overlay(true)) + expect(button().hasAttribute('disabled')).toBe(true) + expect(button().getAttribute('aria-busy')).toBe('true') + fireEvent.click(button()) + expect(mocks.prepare).toHaveBeenCalledTimes(1) + await act(async () => + preparation.resolve({ + copied: false, + session: { agent: 'codex', sessionId: 'fork', cwd: '/repo', codexHome: null } + }) + ) + expect(mocks.launch).toHaveBeenCalledTimes(1) + expect(button().hasAttribute('disabled')).toBe(false) + expect(button().hasAttribute('aria-busy')).toBe(false) + expect(fullRequest).toHaveBeenCalledTimes(1) +}) From dde77e3584ae37ab545c04f9f62e3d043a1560f3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 05:47:56 -0700 Subject: [PATCH 40/42] Use checked property access in header instrumentation --- .../native-chat/native-chat-header-ownership.test.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-header-ownership.test.tsx b/src/renderer/src/components/native-chat/native-chat-header-ownership.test.tsx index d51a26f37ae0..335b0bad0db8 100644 --- a/src/renderer/src/components/native-chat/native-chat-header-ownership.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-header-ownership.test.tsx @@ -89,13 +89,16 @@ afterEach(() => { useAppStore.setState(initial, true) Object.assign(window, { api: originalApi }) }) +function hasPropertyKey(target: T, key: PropertyKey): key is keyof T { + return key in target +} function countedTab(tab: Tab): Tab { return new Proxy(tab, { - get(target, key, receiver) { + get(target, key) { if (key === 'groupId' || key === 'id') { work[work.header ? 'headerVisits' : 'otherVisits']++ } - return Reflect.get(target, key, receiver) + return hasPropertyKey(target, key) ? target[key] : undefined } }) } @@ -182,11 +185,11 @@ it.each([10, 100])( function countedRecords(records: T[]): T[] { return new Proxy(records, { - get(target, key, receiver) { + get(target, key) { if (typeof key === 'string' && /^\d+$/.test(key)) { work.lookups++ } - return Reflect.get(target, key, receiver) + return hasPropertyKey(target, key) ? target[key] : undefined } }) } From cee879784a50c8bf92716d8583cd06b7cfbe3789 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 06:17:40 -0700 Subject: [PATCH 41/42] Read Continue eligibility records through workspace selectors --- .../native-chat/NativeChatResumeInCliButton.tsx | 11 +++++++---- .../native-chat/native-chat-header-ownership.test.tsx | 3 ++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx index 4bd0522eaf7b..cd9b9f70d43a 100644 --- a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' import { translate } from '@/i18n/i18n' import { AI_VAULT_AGENTS } from '../../../../shared/ai-vault-types' import { @@ -29,9 +30,11 @@ function createExecutionHostSelector( let repos: AppState['repos'] | undefined let scope: NativeChatCliForkIdentity['executionHostScope'] | null = null return (state: AppState) => { + const nextWorktrees = selectWorktreesByRepo(state) + const nextFolders = selectFolderWorkspaces(state) if ( - worktrees !== state.worktreesByRepo || - folders !== state.folderWorkspaces || + worktrees !== nextWorktrees || + folders !== nextFolders || groups !== state.projectGroups || repos !== state.repos ) { @@ -40,8 +43,8 @@ function createExecutionHostSelector( tab: { id: tabId, worktreeId }, nativeChat: { sessionId, agent } })?.executionHostScope ?? null - worktrees = state.worktreesByRepo - folders = state.folderWorkspaces + worktrees = nextWorktrees + folders = nextFolders groups = state.projectGroups repos = state.repos } diff --git a/src/renderer/src/components/native-chat/native-chat-header-ownership.test.tsx b/src/renderer/src/components/native-chat/native-chat-header-ownership.test.tsx index 335b0bad0db8..a22c5f8d57f6 100644 --- a/src/renderer/src/components/native-chat/native-chat-header-ownership.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-header-ownership.test.tsx @@ -4,6 +4,7 @@ import { act, cleanup, fireEvent, render, screen, within } from '@testing-librar import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' import type { Tab } from '../../../../shared/tab-types' import { createTabsSliceMockApi } from '@/store/slices/tabs-slice-test-harness' import { makeTabGroup } from '@/store/slices/store-test-helpers' @@ -285,7 +286,7 @@ it.each([10, 100])( repos, worktreesByRepo: worktrees } - const replacements = [ + const replacements: Partial[] = [ { worktreesByRepo: { ...worktrees, From 2c8e562260832c9a97925b2b78e34fd83b490014 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:35:59 -0700 Subject: [PATCH 42/42] Cover native header Find clearance and tooltip click passage --- .../native-chat-controls-rendered/fixture.css | 6 + .../native-chat-controls-rendered/fixture.tsx | 44 +++++++ .../native-chat-controls-rendered/index.html | 10 ++ .../native-chat-controls-rendered/run.mjs | 120 ++++++++++++++++++ 4 files changed, 180 insertions(+) create mode 100644 tests/tools/native-chat-controls-rendered/fixture.css create mode 100644 tests/tools/native-chat-controls-rendered/fixture.tsx create mode 100644 tests/tools/native-chat-controls-rendered/index.html create mode 100644 tests/tools/native-chat-controls-rendered/run.mjs diff --git a/tests/tools/native-chat-controls-rendered/fixture.css b/tests/tools/native-chat-controls-rendered/fixture.css new file mode 100644 index 000000000000..f32634122d4a --- /dev/null +++ b/tests/tools/native-chat-controls-rendered/fixture.css @@ -0,0 +1,6 @@ +@import '../../../src/renderer/src/assets/main.css'; +@source './fixture.tsx'; +@source '../../../src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx'; +@source '../../../src/renderer/src/components/native-chat/NativeChatFindBar.tsx'; +@source '../../../src/renderer/src/components/native-chat/NativeChatContextClearFade.tsx'; +@source '../../../src/renderer/src/components/ui'; diff --git a/tests/tools/native-chat-controls-rendered/fixture.tsx b/tests/tools/native-chat-controls-rendered/fixture.tsx new file mode 100644 index 000000000000..2f85667efe44 --- /dev/null +++ b/tests/tools/native-chat-controls-rendered/fixture.tsx @@ -0,0 +1,44 @@ +import React, { useRef } from 'react' +import { createRoot } from 'react-dom/client' +import { NativeChatPaneHeader } from '@/components/native-chat/NativeChatPaneHeader' +import { NativeChatFindBar } from '@/components/native-chat/NativeChatFindBar' +import { NativeChatContextClearFade } from '@/components/native-chat/NativeChatContextClearFade' +import { nativeChatAppearanceStyle } from '@/components/native-chat/native-chat-appearance-style' +import { useNativeChatFind } from '@/components/native-chat/use-native-chat-find' +import { TooltipProvider } from '@/components/ui/tooltip' +import './fixture.css' + +function Fixture() { + const root = useRef(null) + const find = useNativeChatFind(false, root, useRef(null), useRef(null)) + return ( + + +
+ + + {find.isOpen && } +

+ alpha alpha +

+