diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index a121f12e28b..5572c2dbdbf 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -373,6 +373,7 @@ describe('renderer startup runtime routing', () => { expect(reconnectIndex).toBeLessThan(projectIndex) expect(projectIndex).toBeLessThan(readyIndex) expect(gateBlock).toContain('terminalStartupRestorationReady') + expect(gateBlock).not.toContain('hydrationSucceeded') expect(gateIndex).toBeGreaterThanOrEqual(0) expect(gateIndex).toBeLessThan(createIndex) expect(gateBlock.slice(gateIndex, createIndex)).toContain("outcome !== 'empty'") diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 73cf936f220..de5c158cb30 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -44,6 +44,7 @@ import { hasFeatureInteraction } from '../../../shared/feature-interactions' import BrowserPane from './browser-pane/BrowserPane' import { RetainedBrowserPaneOverlayLayer } from './browser-pane/assemble-chrome/BrowserPaneOverlayLayer' import EmulatorPaneOverlayLayer from './emulator-pane/EmulatorPaneOverlayLayer' +import StructuredAgentSessionPaneOverlayLayer from './native-chat/StructuredAgentSessionPaneOverlayLayer' import { useClientHostedBrowserRows } from '@/lib/pane-manager/client-hosted-browser-row-state' import { onBrowserGuestPaintRetentionChange, @@ -1518,12 +1519,7 @@ function Terminal(): React.JSX.Element | null { ? Object.hasOwn(tabsByWorktree, activeWorktreeId) : false useEffect(() => { - if ( - !workspaceSessionReady || - !terminalStartupRestorationReady || - !hydrationSucceeded || - !activeWorktreeId - ) { + if (!workspaceSessionReady || !terminalStartupRestorationReady || !activeWorktreeId) { return } // Why: host session-tabs are authoritative in the paired web client; a local fallback races the host's initial terminal and duplicates tabs. @@ -1557,7 +1553,6 @@ function Terminal(): React.JSX.Element | null { activeWorktreeId, activeWorktreeHasTerminalState, createTab, - hydrationSucceeded, reconcileWorktreeTabModel, terminalStartupRestorationReady, workspaceSessionReady @@ -2918,6 +2913,10 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ {isVisible || backgroundMountTabIds === null ? ( ) : null} + ) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx new file mode 100644 index 00000000000..25991eac3ff --- /dev/null +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx @@ -0,0 +1,205 @@ +// @vitest-environment happy-dom + +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Tab, TabGroup } from '../../../../shared/tab-types' + +type MockAppState = { + unifiedTabsByWorktree: Record + groupsByWorktree: Record + runtimeEnvironmentId: string | null + executionHostId: string + focusGroup: (worktreeId: string, groupId: string) => void +} + +const mocks = vi.hoisted(() => ({ + store: null as null | { setState: (state: Partial) => void }, + focusGroup: vi.fn(), + mountsByTabId: new Map(), + unmountsByTabId: new Map() +})) + +vi.mock('@/store', async () => { + const { create } = await import('zustand') + const useAppStore = create(() => ({ + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + runtimeEnvironmentId: null, + executionHostId: 'local', + focusGroup: mocks.focusGroup + })) + mocks.store = useAppStore + return { useAppStore } +}) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: (state: MockAppState) => state.runtimeEnvironmentId, + getExecutionHostIdForWorktree: (state: MockAppState) => state.executionHostId +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + getActiveRuntimeTarget: ({ + activeRuntimeEnvironmentId + }: { + activeRuntimeEnvironmentId: string | null + }) => + activeRuntimeEnvironmentId + ? { kind: 'environment', environmentId: activeRuntimeEnvironmentId } + : { kind: 'local' } +})) + +vi.mock('./NativeChatView', async () => { + const { useEffect } = await import('react') + return { + default: function MockNativeChatView({ + tabId, + isVisible + }: { + tabId: string + isVisible: boolean + }) { + useEffect(() => { + mocks.mountsByTabId.set(tabId, (mocks.mountsByTabId.get(tabId) ?? 0) + 1) + return () => { + mocks.unmountsByTabId.set(tabId, (mocks.unmountsByTabId.get(tabId) ?? 0) + 1) + } + }, [tabId]) + return ( + + ) + } + } +}) + +import StructuredAgentSessionPaneOverlayLayer from './StructuredAgentSessionPaneOverlayLayer' + +const WORKTREE_ID = 'wt-1' +const GROUP_ID = 'group-1' +const FIRST_TAB_ID = 'structured-agent-session-session-1' +const SECOND_TAB_ID = 'structured-agent-session-session-2' + +describe('StructuredAgentSessionPaneOverlayLayer', () => { + beforeEach(() => { + mocks.focusGroup.mockClear() + mocks.mountsByTabId.clear() + mocks.unmountsByTabId.clear() + mocks.store?.setState(createState(FIRST_TAB_ID)) + }) + + afterEach(cleanup) + + it('keeps materialized chat surfaces mounted while activation only swaps visibility', () => { + const view = render( + + ) + const firstBefore = chatSurface(view.container, FIRST_TAB_ID) + const secondBefore = chatSurface(view.container, SECOND_TAB_ID) + + expect(firstBefore.dataset.chatVisible).toBe('true') + expect(secondBefore.dataset.chatVisible).toBe('false') + expect(mocks.mountsByTabId).toEqual( + new Map([ + [FIRST_TAB_ID, 1], + [SECOND_TAB_ID, 1] + ]) + ) + + act(() => { + mocks.store?.setState({ + groupsByWorktree: { + [WORKTREE_ID]: [createGroup(SECOND_TAB_ID)] + } + }) + }) + + const firstAfter = chatSurface(view.container, FIRST_TAB_ID) + const secondAfter = chatSurface(view.container, SECOND_TAB_ID) + expect(firstAfter).toBe(firstBefore) + expect(secondAfter).toBe(secondBefore) + expect(firstAfter.dataset.chatVisible).toBe('false') + expect(secondAfter.dataset.chatVisible).toBe('true') + expect(mocks.mountsByTabId.get(FIRST_TAB_ID)).toBe(1) + expect(mocks.mountsByTabId.get(SECOND_TAB_ID)).toBe(1) + expect(mocks.unmountsByTabId.size).toBe(0) + }) + + it('routes overlay interaction back to the owning split group', () => { + const view = render( + + ) + const slot = view.container.querySelector( + `[data-structured-agent-session-overlay-tab-id="${FIRST_TAB_ID}"]` + ) + + expect(slot).not.toBeNull() + fireEvent.pointerDown(slot!) + expect(mocks.focusGroup).toHaveBeenCalledWith(WORKTREE_ID, GROUP_ID) + }) + + it('keeps the base z-layer overridable by the working-chat stylesheet rule', () => { + const view = render( + + ) + const slot = view.container.querySelector( + `[data-structured-agent-session-overlay-tab-id="${FIRST_TAB_ID}"]` + ) + + expect(slot).not.toBeNull() + expect(slot?.classList.contains('native-chat-pane-shell')).toBe(true) + expect(slot?.classList.contains('z-10')).toBe(true) + expect(slot?.style.zIndex).toBe('') + expect(slot?.querySelector('[data-native-chat-working="true"]')).not.toBeNull() + }) +}) + +function createState(activeTabId: string): MockAppState { + return { + unifiedTabsByWorktree: { + [WORKTREE_ID]: [ + structuredTab(FIRST_TAB_ID, 'session-1', 0), + structuredTab(SECOND_TAB_ID, 'session-2', 1) + ] + }, + groupsByWorktree: { [WORKTREE_ID]: [createGroup(activeTabId)] }, + runtimeEnvironmentId: null, + executionHostId: 'local', + focusGroup: mocks.focusGroup + } +} + +function createGroup(activeTabId: string): TabGroup { + return { + id: GROUP_ID, + worktreeId: WORKTREE_ID, + activeTabId, + tabOrder: [FIRST_TAB_ID, SECOND_TAB_ID] + } +} + +function structuredTab(id: string, sessionId: string, sortOrder: number): Tab { + return { + id, + entityId: sessionId, + groupId: GROUP_ID, + worktreeId: WORKTREE_ID, + contentType: 'agent-session', + agentSessionAgent: 'codex', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder, + createdAt: sortOrder + 1 + } +} + +function chatSurface(container: HTMLElement, tabId: string): HTMLElement { + const surface = container.querySelector(`[data-chat-tab-id="${tabId}"]`) + if (!surface) { + throw new Error(`missing structured chat surface ${tabId}`) + } + return surface +} diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx new file mode 100644 index 00000000000..555d23b9ec4 --- /dev/null +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx @@ -0,0 +1,139 @@ +import { memo, useCallback, useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' +import type { Tab, TabGroup } from '../../../../shared/tab-types' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { useAppStore } from '@/store' +import { + getExecutionHostIdForWorktree, + getRuntimeEnvironmentIdForWorktree +} from '@/lib/worktree-runtime-owner' +import { getActiveRuntimeTarget, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { tabGroupBodyAnchorName } from '../tab-group/tab-group-body-anchor' +import NativeChatView from './NativeChatView' + +type StructuredAgentSessionTab = Tab & { + contentType: 'agent-session' + agentSessionAgent: NonNullable +} + +const EMPTY_UNIFIED_TABS: readonly Tab[] = [] +const EMPTY_GROUPS: readonly TabGroup[] = [] + +const StructuredAgentSessionOverlaySlot = memo(function StructuredAgentSessionOverlaySlot({ + tab, + groupId, + isActive, + target, + allowFileUriLinks, + onFocusOwningGroup +}: { + tab: StructuredAgentSessionTab + groupId: string | undefined + isActive: boolean + target: RuntimeClientTarget + allowFileUriLinks: boolean + onFocusOwningGroup: ((groupId: string) => void) | undefined +}): React.JSX.Element { + const anchorName = groupId !== undefined ? tabGroupBodyAnchorName(groupId) : undefined + const style = useMemo( + () => + anchorName + ? { + position: 'absolute', + positionAnchor: anchorName, + top: `anchor(${anchorName} top)`, + left: `anchor(${anchorName} left)`, + width: `anchor-size(${anchorName} width)`, + height: `anchor-size(${anchorName} height)`, + display: isActive ? 'flex' : 'none', + pointerEvents: isActive ? 'auto' : 'none' + } + : { display: 'none' }, + [anchorName, isActive] + ) + const focusOwningGroup = useCallback(() => { + if (groupId !== undefined && onFocusOwningGroup) { + onFocusOwningGroup(groupId) + } + }, [groupId, onFocusOwningGroup]) + + return ( +
+ +
+ ) +}) + +const StructuredAgentSessionPaneOverlayLayer = memo( + function StructuredAgentSessionPaneOverlayLayer({ + worktreeId, + isWorktreeActive + }: { + worktreeId: string + isWorktreeActive: boolean + }): React.JSX.Element { + const { unifiedTabs, groups, runtimeEnvironmentId, allowFileUriLinks } = useAppStore( + useShallow((state) => ({ + unifiedTabs: state.unifiedTabsByWorktree[worktreeId] ?? EMPTY_UNIFIED_TABS, + groups: state.groupsByWorktree[worktreeId] ?? EMPTY_GROUPS, + runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(state, worktreeId), + allowFileUriLinks: getExecutionHostIdForWorktree(state, worktreeId) === 'local' + })) + ) + const focusGroup = useAppStore((state) => state.focusGroup) + const target = useMemo( + () => getActiveRuntimeTarget({ activeRuntimeEnvironmentId: runtimeEnvironmentId }), + [runtimeEnvironmentId] + ) + const focusOwningGroup = useCallback( + (groupId: string) => focusGroup(worktreeId, groupId), + [focusGroup, worktreeId] + ) + const groupActiveTabById = useMemo( + () => new Map(groups.map((group) => [group.id, group.activeTabId] as const)), + [groups] + ) + const structuredTabs = useMemo( + () => + unifiedTabs.filter( + (tab): tab is StructuredAgentSessionTab => + tab.contentType === 'agent-session' && + isAgentSessionHandleProvider(tab.agentSessionAgent) + ), + [unifiedTabs] + ) + + return ( + <> + {structuredTabs.map((tab) => ( + + ))} + + ) + } +) + +export default StructuredAgentSessionPaneOverlayLayer diff --git a/src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts b/src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts index cf688bbed89..4b18921f221 100644 --- a/src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts @@ -10,10 +10,8 @@ describe('native chat Stop layering', () => { it('keeps a working chat pane above bottom-right product chrome', () => { const css = source('src/renderer/src/assets/main.css') const terminalPane = source('src/renderer/src/components/terminal-pane/TerminalPane.tsx') - const tabGroupPane = source('src/renderer/src/components/tab-group/TabGroupPanel.tsx') expect(terminalPane).toContain('native-chat-pane-shell absolute inset-0 z-10') - expect(tabGroupPane).toContain('native-chat-pane-shell absolute inset-0 z-10') expect(css).toMatch(/\[data-sonner-toaster\][^{]*\{[^}]*z-index:\s*40\s*!important;/s) expect(css).toMatch( /\.native-chat-pane-shell:has\(\[data-native-chat-working='true'\]\)[^{]*\{[^}]*z-index:\s*50;/s @@ -28,4 +26,12 @@ describe('native chat Stop layering', () => { expect(source(path)).toContain('data-native-chat-working=') } }) + + it('owns structured session panes at the retained worktree overlay layer', () => { + const terminal = source('src/renderer/src/components/Terminal.tsx') + const tabGroup = source('src/renderer/src/components/tab-group/TabGroupPanel.tsx') + + expect(terminal).toContain(' { expect(markup).toContain('aria-checked="true"') }) - it('shows the structured-native-chat child setting only when Chat UI is enabled', async () => { + it('shows the structured-native-chat child setting only when Chat UI is the default view', async () => { const updateSettings = vi.fn() const disabledSettings = getDefaultSettings('/tmp') const disabledMarkup = renderToStaticMarkup( @@ -230,22 +230,194 @@ describe('ExperimentalPane', () => { ) expect(disabledMarkup).toContain('Chat UI') expect(disabledMarkup).not.toContain('Use updated structured native chat') + expect(disabledMarkup).not.toContain('Default view') - const settings = { + const terminalDefault = { ...getDefaultSettings('/tmp'), experimentalNativeChat: true, experimentalStructuredNativeChat: false, openAgentTabsInChatByDefault: false } - const { root, container } = await renderExperimentalPane({ updateSettings, settings }) + const terminalRender = await renderExperimentalPane({ + updateSettings, + settings: terminalDefault + }) + + // The default-view control is a sibling of the Chat UI toggle, never replaced by the opt-in. + expect(terminalRender.container.textContent).toContain('Default view') + expect( + terminalRender.container.querySelector('[data-slot="native-chat-default-view-select"]') + ).not.toBeNull() + // Structured chat has no entry path under Terminal chat, so its opt-in is not offered. + expect(terminalRender.container.textContent).not.toContain('Use updated structured native chat') + terminalRender.root.unmount() + + const { root, container } = await renderExperimentalPane({ + updateSettings, + settings: { ...terminalDefault, openAgentTabsInChatByDefault: true } + }) expect(container.textContent).toContain('Use updated structured native chat') expect(container.textContent).toContain( - 'Local macOS and Linux sessions only for now. Windows, WSL, remote, and SSH sessions continue to use terminal chat.' + 'Local macOS and Linux sessions only for now. Windows, WSL, and remote execution hosts (including SSH) continue to use terminal chat.' ) + expect(container.textContent).toContain('Default view') + root.unmount() + }) + + it('hides a stale structured opt-in under Terminal chat without clearing it', async () => { + const updateSettings = vi.fn() + const settings = { + ...getDefaultSettings('/tmp'), + experimentalNativeChat: true, + experimentalStructuredNativeChat: true, + openAgentTabsInChatByDefault: true + } + const { root, container } = await renderExperimentalPane({ updateSettings, settings }) + + expect(container.textContent).toContain('Use updated structured native chat') + + const terminalChatOption = Array.from( + container.querySelectorAll('[data-slot="select-item"]') + ).find((button) => button.getAttribute('data-value') === 'terminal-chat') + if (!terminalChatOption) { + throw new Error('Terminal chat default-view option was not rendered') + } + + await act(async () => { + terminalChatOption.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + // Switching the default view must not clobber the persisted opt-in — only hide its control. + expect(updateSettings).toHaveBeenCalledWith({ openAgentTabsInChatByDefault: false }) + expect(updateSettings).toHaveBeenCalledTimes(1) + root.unmount() + + const hidden = await renderExperimentalPane({ + updateSettings, + settings: { ...settings, openAgentTabsInChatByDefault: false } + }) + + expect(hidden.container.textContent).not.toContain('Use updated structured native chat') + hidden.root.unmount() + + // Returning to Chat UI restores the control still switched on. + const restored = await renderExperimentalPane({ updateSettings, settings }) + const structuredSwitch = restored.container.querySelector( + '#experimental-native-chat button[role="switch"][aria-label="Toggle updated structured native chat"]' + ) + expect(structuredSwitch?.getAttribute('aria-checked')).toBe('true') + restored.root.unmount() + }) + + it('shows Chat UI default-mode as a child setting only when Chat UI is enabled', async () => { + const updateSettings = vi.fn() + const disabledSettings = getDefaultSettings('/tmp') + const disabledMarkup = renderToStaticMarkup( + + ) + expect(disabledMarkup).toContain('Chat UI') + expect(disabledMarkup).not.toContain('Default view') + + const settings = { + ...getDefaultSettings('/tmp'), + experimentalNativeChat: true, + openAgentTabsInChatByDefault: false + } + const { root, container } = await renderExperimentalPane({ updateSettings, settings }) + + expect(container.textContent).toContain('Default view') + expect(container.textContent).toContain('Terminal chat') expect(container.textContent).toContain('Chat UI') - expect(container.textContent).not.toContain('Default view') - expect(container.querySelector('[data-slot="native-chat-default-view-select"]')).toBeNull() + expect( + container + .querySelector('[data-slot="native-chat-default-view-select"]') + ?.getAttribute('data-value') + ).toBe('terminal-chat') + + const nativeChatOption = Array.from( + container.querySelectorAll('[data-slot="select-item"]') + ).find((button) => button.getAttribute('data-value') === 'native-chat') + if (!nativeChatOption) { + throw new Error('Chat UI default-view option was not rendered') + } + + await act(async () => { + nativeChatOption.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(updateSettings).toHaveBeenCalledWith({ openAgentTabsInChatByDefault: true }) + + root.unmount() + + const nativeSettings = { + ...settings, + openAgentTabsInChatByDefault: true + } + const secondRender = await renderExperimentalPane({ + updateSettings, + settings: nativeSettings + }) + + expect( + secondRender.container + .querySelector('[data-slot="native-chat-default-view-select"]') + ?.getAttribute('data-value') + ).toBe('native-chat') + + const terminalChatOption = Array.from( + secondRender.container.querySelectorAll('[data-slot="select-item"]') + ).find((button) => button.getAttribute('data-value') === 'terminal-chat') + if (!terminalChatOption) { + throw new Error('Terminal chat default-view option was not rendered') + } + + await act(async () => { + terminalChatOption.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(updateSettings).toHaveBeenCalledWith({ openAgentTabsInChatByDefault: false }) + + secondRender.root.unmount() + }) + + // The two controls are nested, but each still writes only its own key. + it('never writes one Chat UI child setting while changing the other', async () => { + const updateSettings = vi.fn() + const settings = { + ...getDefaultSettings('/tmp'), + experimentalNativeChat: true, + experimentalStructuredNativeChat: false, + openAgentTabsInChatByDefault: true + } + const { root, container } = await renderExperimentalPane({ updateSettings, settings }) + + const structuredSwitch = container.querySelector( + '#experimental-native-chat button[role="switch"][aria-label="Toggle updated structured native chat"]' + ) + if (!structuredSwitch) { + throw new Error('Structured native chat switch was not rendered') + } + + await act(async () => { + structuredSwitch.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(updateSettings).toHaveBeenCalledWith({ experimentalStructuredNativeChat: true }) + + const terminalChatOption = Array.from( + container.querySelectorAll('[data-slot="select-item"]') + ).find((button) => button.getAttribute('data-value') === 'terminal-chat') + if (!terminalChatOption) { + throw new Error('Terminal chat default-view option was not rendered') + } + + await act(async () => { + terminalChatOption.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(updateSettings).toHaveBeenCalledWith({ openAgentTabsInChatByDefault: false }) + expect(updateSettings).toHaveBeenCalledTimes(2) root.unmount() }) diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx index 8ebf18cf08e..666641f61d8 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx @@ -1,10 +1,13 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' import { translate } from '@/i18n/i18n' import { Label } from '../ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { SearchableSetting } from './SearchableSetting' import { SettingsSwitch } from './SettingsFormControls' import { getExperimentalSearchEntry } from './experimental-search' +type NativeChatDefaultView = 'terminal-chat' | 'native-chat' + type NativeChatExperimentalSettingProps = { settings: GlobalSettings updateSettings: (updates: Partial) => void @@ -16,6 +19,8 @@ export function NativeChatExperimentalSetting({ }: NativeChatExperimentalSettingProps): React.JSX.Element { const nativeChatEnabled = settings.experimentalNativeChat === true const structuredNativeChatEnabled = settings.experimentalStructuredNativeChat === true + const defaultView: NativeChatDefaultView = + settings.openAgentTabsInChatByDefault === true ? 'native-chat' : 'terminal-chat' return ( {nativeChatEnabled ? ( -
-
+
+

{translate( - 'auto.components.settings.ExperimentalPane.nativeChat.structuredCopy', - 'Opt in to the host-owned structured Codex runtime. Off keeps the existing terminal-backed chat path.' - )} -

-

- {translate( - 'auto.components.settings.ExperimentalPane.nativeChat.structuredScope', - 'Local macOS and Linux sessions only for now. Windows, WSL, remote, and SSH sessions continue to use terminal chat.' + 'auto.components.settings.ExperimentalPane.nativeChat.defaultCopy', + 'Choose how new supported agent terminal tabs open.' )}

- +
+ + {/* Structured chat rides the Chat UI default view; it has no entry path under Terminal + chat. Hidden only — the opt-in keeps its persisted value for when Chat UI returns. */} + {defaultView === 'native-chat' ? ( +
+
+ +

+ {translate( + 'auto.components.settings.ExperimentalPane.nativeChat.structuredCopy', + 'Opt in to the host-owned structured Codex runtime. Off keeps the existing terminal-backed chat path.' + )} +

+

+ {translate( + 'auto.components.settings.ExperimentalPane.nativeChat.structuredScope', + 'Local macOS and Linux sessions only for now. Windows, WSL, and remote execution hosts (including SSH) continue to use terminal chat.' + )} +

+
+ + updateSettings({ + experimentalStructuredNativeChat: !structuredNativeChatEnabled + }) + } + /> +
+ ) : null}
) : null} diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index c13031303dd..6af2508048c 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -200,23 +200,22 @@ function QuickLaunchAgentMenuItemsInner({ const showsDefaultAgentShortcut = newAgentShortcut !== null && defaultAgent !== 'blank' && agent === defaultAgent return ( - - runLaunch(agent)} - className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium" - title={translate( - 'auto.components.tab.bar.QuickLaunchButton.ec2adf093e', - 'Launch {{value0}} in a new terminal', - { value0: label } - )} - > - - {label} - {showsDefaultAgentShortcut ? ( - {newAgentShortcut} - ) : null} - - + runLaunch(agent)} + className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium" + title={translate( + 'auto.components.tab.bar.QuickLaunchButton.ec2adf093e', + 'Launch {{value0}} in a new terminal', + { value0: label } + )} + > + + {label} + {showsDefaultAgentShortcut ? ( + {newAgentShortcut} + ) : null} + ) })} import('../editor/EditorPanel')) const EMPTY_GROUPS: readonly TabGroup[] = [] @@ -70,16 +63,6 @@ export default function TabGroupPanel({ }): React.JSX.Element { const rightSidebarOpen = useAppStore((state) => state.rightSidebarOpen) const sidebarOpen = useAppStore((state) => state.sidebarOpen) - const structuredRuntimeEnvironmentId = useAppStore((state) => - getRuntimeEnvironmentIdForWorktree(state, worktreeId) - ) - const structuredFileLinksEnabled = useAppStore( - (state) => getExecutionHostIdForWorktree(state, worktreeId) === 'local' - ) - const structuredRuntimeTarget = useMemo( - () => getActiveRuntimeTarget({ activeRuntimeEnvironmentId: structuredRuntimeEnvironmentId }), - [structuredRuntimeEnvironmentId] - ) const model = useTabGroupWorkspaceModel({ groupId, worktreeId }) const { activeTab, @@ -391,23 +374,7 @@ export default function TabGroupPanel({
)} - {activeTab?.contentType === 'agent-session' && - isAgentSessionHandleProvider(activeTab.agentSessionAgent) ? ( -
- -
- ) : null} - - {/* Why: terminal/browser/simulator panes render at the worktree level (overlay layers); per-group rendering remounted xterm/webview/simulator on split moves. */} + {/* Why: terminal/browser/simulator/structured-chat panes render at the worktree level; tab activation only changes overlay visibility and never remounts a live surface. */}
) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2f7a3293a3b..6e7d9893336 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3276,10 +3276,7 @@ "ec2adf093e": "Launch {{value0}} in a new terminal", "465e432ef1": "Could not build launch command for {{value0}}.", "e518f544b1": "No agents detected", - "8dea9b5cdf": "No enabled agents", - "f378201fbd": "Could not create chat session", - "a847779775": "Start Codex without a terminal", - "0ea6a78efb": "Chat session" + "8dea9b5cdf": "No enabled agents" }, "RecentTabSwitcher": { "329638ff6f": "Switch Tab", @@ -6876,7 +6873,7 @@ "defaultViewNative": "Chat UI", "structuredTitle": "Use updated structured native chat", "structuredCopy": "Opt in to the host-owned structured Codex runtime. Off keeps the existing terminal-backed chat path.", - "structuredScope": "Local macOS and Linux sessions only for now. Windows, WSL, remote, and SSH sessions continue to use terminal chat.", + "structuredScope": "Local macOS and Linux sessions only for now. Windows, WSL, and remote execution hosts (including SSH) continue to use terminal chat.", "structuredToggleLabel": "Toggle updated structured native chat" }, "agentDashboard": { @@ -16063,9 +16060,7 @@ "nativeChat": { "contextMenu": { "copy": "Copy" - }, - "structuredSessionLaunchStarting": "Opening Codex chat…", - "structuredSessionLaunchInProgress": "Codex chat is still opening" + } }, "orca": { "profiles": { diff --git a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts index 5838fada19b..dedcf257054 100644 --- a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -95,8 +95,7 @@ describe('structured chat adoption guard on the launch path', () => { store.settings.openAgentTabsInChatByDefault = true }) - it('uses the updated structured-chat toggle without the legacy chat-default setting', async () => { - store.settings.openAgentTabsInChatByDefault = false + it('takes the structured path when the chat-default view is selected', async () => { const { launchAgentInNewTab, shouldQueueTerminalFocusAfterMenuClose } = await import('./launch-agent-in-new-tab') @@ -113,6 +112,24 @@ describe('structured chat adoption guard on the launch path', () => { expect(mockWaitForAgentReady).not.toHaveBeenCalled() }) + /** The toggle is hidden under Terminal chat but its persisted value survives, so the launch + * path must re-check the default view rather than trust a stale opt-in. */ + it('ignores a stale structured opt-in while the default view is Terminal chat', async () => { + store.settings.openAgentTabsInChatByDefault = false + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(result?.tabId).toBe('tab-1') + expect(mockLaunchStructuredCodexSession).not.toHaveBeenCalled() + expect(mockCreateTab).toHaveBeenCalledWith( + 'wt-1', + undefined, + undefined, + expect.objectContaining({ launchAgent: 'codex' }) + ) + }) + it('surfaces a direct structured launch failure instead of silently doing nothing', async () => { mockLaunchStructuredCodexSession.mockRejectedValueOnce(new Error('provider unavailable')) const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') diff --git a/src/renderer/src/lib/resume-legacy-structured-agent-session.test.ts b/src/renderer/src/lib/resume-stale-structured-agent-session.test.ts similarity index 79% rename from src/renderer/src/lib/resume-legacy-structured-agent-session.test.ts rename to src/renderer/src/lib/resume-stale-structured-agent-session.test.ts index 5604ba56885..db16786d27a 100644 --- a/src/renderer/src/lib/resume-legacy-structured-agent-session.test.ts +++ b/src/renderer/src/lib/resume-stale-structured-agent-session.test.ts @@ -1,29 +1,29 @@ import { afterEach, describe, expect, it } from 'vitest' import { useAppStore } from '@/store' -import { LEGACY_AGENT_SESSION_ID } from '../../../shared/agent-session-record-legacy.test-fixture' import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' import { makePaneKey } from '../../../shared/stable-pane-id' import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' const initialAppStoreState = useAppStore.getState() -const WORKTREE_ID = 'legacy-worktree' +const WORKTREE_ID = 'stale-structured-worktree' +const SESSION_ID = 'structured-session-stale' const LEAF_ID = '11111111-1111-4111-8111-111111111111' afterEach(() => { useAppStore.setState(initialAppStoreState, true) }) -describe('legacy structured sleeping session', () => { +describe('stale structured sleeping session', () => { it('clears the synthetic terminal projection without spawning', () => { - const tabId = structuredAgentSessionTabId(LEGACY_AGENT_SESSION_ID) + const tabId = structuredAgentSessionTabId(SESSION_ID) const paneKey = makePaneKey(tabId, LEAF_ID) const record: SleepingAgentSessionRecord = { paneKey, tabId, worktreeId: WORKTREE_ID, agent: 'codex', - providerSession: { key: 'session_id', id: LEGACY_AGENT_SESSION_ID }, + providerSession: { key: 'session_id', id: SESSION_ID }, prompt: 'continue', state: 'working', capturedAt: 1, diff --git a/src/renderer/src/lib/structured-agent-session-launch.test.ts b/src/renderer/src/lib/structured-agent-session-launch.test.ts new file mode 100644 index 00000000000..e83119ca3b4 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts' +import { launchStructuredCodexSession } from '@/lib/launch-structured-codex-session' +import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync' +import { startStructuredCodexLaunch } from './structured-agent-session-launch' + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + message: vi.fn() + } +})) + +vi.mock('@/lib/launch-structured-codex-session', () => ({ + launchStructuredCodexSession: vi.fn() +})) + +vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({ + refreshLocalStructuredSessionTabs: vi.fn() +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +function publishedSnapshot(worktreeId: string, sessionId: string): RuntimeMobileSessionTabsResult { + return { + worktree: worktreeId, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [ + { + type: 'agent-session', + id: 'tab-1', + title: 'Codex', + sessionId, + agent: 'codex', + isActive: true + } + ] + } +} + +async function flushLaunchSettlement(): Promise { + for (let i = 0; i < 8; i += 1) { + await Promise.resolve() + } +} + +describe('startStructuredCodexLaunch', () => { + beforeEach(() => { + vi.mocked(toast.message).mockReset() + vi.mocked(toast.error).mockReset() + vi.mocked(launchStructuredCodexSession).mockReset() + vi.mocked(refreshLocalStructuredSessionTabs).mockReset() + }) + + it('opens the chat without an informational progress toast', async () => { + const worktreeId = 'wt-open-quiet' + vi.mocked(launchStructuredCodexSession).mockResolvedValue('session-1') + vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ + publishedSnapshot(worktreeId, 'session-1') + ]) + + startStructuredCodexLaunch(worktreeId) + await flushLaunchSettlement() + + expect(launchStructuredCodexSession).toHaveBeenCalledTimes(1) + expect(launchStructuredCodexSession).toHaveBeenCalledWith(worktreeId) + expect(toast.message).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + }) + + it('coalesces a duplicate click silently while the launch is in flight', async () => { + const worktreeId = 'wt-duplicate-click' + let resolveLaunch: (sessionId: string) => void = () => {} + vi.mocked(launchStructuredCodexSession).mockImplementation( + () => new Promise((resolve) => (resolveLaunch = resolve)) + ) + vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ + publishedSnapshot(worktreeId, 'session-2') + ]) + + startStructuredCodexLaunch(worktreeId) + startStructuredCodexLaunch(worktreeId) + + expect(launchStructuredCodexSession).toHaveBeenCalledTimes(1) + expect(toast.message).not.toHaveBeenCalled() + + resolveLaunch('session-2') + await flushLaunchSettlement() + expect(toast.error).not.toHaveBeenCalled() + }) + + it('keeps the actionable error toast when the launch fails', async () => { + const worktreeId = 'wt-launch-fails' + vi.mocked(launchStructuredCodexSession).mockRejectedValue(new Error('codex binary missing')) + + startStructuredCodexLaunch(worktreeId) + await flushLaunchSettlement() + + expect(toast.error).toHaveBeenCalledTimes(1) + expect(toast.error).toHaveBeenCalledWith('Could not open Codex chat', { + description: 'codex binary missing' + }) + expect(toast.message).not.toHaveBeenCalled() + }) + + it('releases the single-flight reservation after a failed launch so a retry relaunches', async () => { + const worktreeId = 'wt-retry-after-failure' + vi.mocked(launchStructuredCodexSession).mockRejectedValueOnce(new Error('transient')) + vi.mocked(launchStructuredCodexSession).mockResolvedValueOnce('session-3') + vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ + publishedSnapshot(worktreeId, 'session-3') + ]) + + startStructuredCodexLaunch(worktreeId) + await flushLaunchSettlement() + expect(toast.error).toHaveBeenCalledTimes(1) + + startStructuredCodexLaunch(worktreeId) + await flushLaunchSettlement() + + expect(launchStructuredCodexSession).toHaveBeenCalledTimes(2) + expect(toast.message).not.toHaveBeenCalled() + expect(toast.error).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index ef08ee781b5..a9864c6b2e9 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -87,15 +87,6 @@ function launchStructuredCodexSessionOnce(worktreeId: string): Promise { } export function startStructuredCodexLaunch(worktreeId: string): void { - const alreadyOpening = pendingStructuredLaunchesByWorktree.has(worktreeId) - toast.message( - translate( - alreadyOpening - ? 'auto.components.nativeChat.structuredSessionLaunchInProgress' - : 'auto.components.nativeChat.structuredSessionLaunchStarting', - alreadyOpening ? 'Codex chat is still opening' : 'Opening Codex chat…' - ) - ) void launchStructuredCodexSessionOnce(worktreeId).catch((error) => { toast.error( translate( diff --git a/src/renderer/src/lib/structured-native-chat-availability.test.ts b/src/renderer/src/lib/structured-native-chat-availability.test.ts index 00abdbd1b86..770413208cc 100644 --- a/src/renderer/src/lib/structured-native-chat-availability.test.ts +++ b/src/renderer/src/lib/structured-native-chat-availability.test.ts @@ -67,7 +67,7 @@ function stateFor(input: { } ], repos: [{ id: 'repo-1', connectionId: input.connectionId ?? null, path: 'C:\\repo' }], - settings: { experimentalStructuredNativeChat: true }, + settings: { experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: true }, worktreesByRepo: { 'repo-1': [ { @@ -101,7 +101,37 @@ describe('canUseStructuredNativeChat', () => { it('keeps the legacy bridge when the updated runtime is opted out', () => { expect( canUseStructuredNativeChat( - { ...stateFor({}), settings: { experimentalStructuredNativeChat: false } } as AppState, + { + ...stateFor({}), + settings: { + experimentalStructuredNativeChat: false, + openAgentTabsInChatByDefault: true + } + } as AppState, + 'wt-1' + ) + ).toBe(false) + }) + + it('refuses a stale structured opt-in while the default view is Terminal chat', () => { + expect( + canUseStructuredNativeChat( + { + ...stateFor({}), + settings: { + experimentalStructuredNativeChat: true, + openAgentTabsInChatByDefault: false + } + } as AppState, + 'wt-1' + ) + ).toBe(false) + }) + + it('refuses a structured opt-in when the default view was never chosen', () => { + expect( + canUseStructuredNativeChat( + { ...stateFor({}), settings: { experimentalStructuredNativeChat: true } } as AppState, 'wt-1' ) ).toBe(false) diff --git a/src/renderer/src/lib/structured-native-chat-availability.ts b/src/renderer/src/lib/structured-native-chat-availability.ts index 9a22831bc11..bc14ccfd4f0 100644 --- a/src/renderer/src/lib/structured-native-chat-availability.ts +++ b/src/renderer/src/lib/structured-native-chat-availability.ts @@ -7,6 +7,12 @@ export function canUseStructuredNativeChat(state: AppState, worktreeId: string): if (state.settings?.experimentalStructuredNativeChat !== true) { return false } + // Structured chat has no entry path of its own — it reuses the Chat UI default view. With + // Terminal chat selected the toggle is hidden but its persisted value survives, so gate on the + // default view too or a stale `true` would silently route new tabs into the structured runtime. + if (state.settings?.openAgentTabsInChatByDefault !== true) { + return false + } if (getExecutionHostIdForWorktree(state, worktreeId) !== 'local') { return false } diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts index be5620da0f2..3b884affca1 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest' import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' -import { LEGACY_AGENT_SESSION_ID } from '../../../shared/agent-session-record-legacy.test-fixture' import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' import type { PtyListedSession } from '../../../shared/pty-listed-session' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' @@ -9,6 +8,7 @@ import type { TerminalSlice } from '@/store/slices/terminals' import { runWorktreeAgentActivationGate } from './worktree-agent-activation-gate' const WORKTREE_ID = 'repo::/worktree' +const STALE_STRUCTURED_SESSION_ID = 'structured-session-stale' const LIVE_LEAF_ID = '11111111-1111-4111-8111-111111111111' const DEAD_LEAF_ID = '22222222-2222-4222-8222-222222222222' @@ -252,10 +252,10 @@ describe('worktree agent activation gate', () => { expect(resume).not.toHaveBeenCalled() }) - it('does not activate a terminal for a legacy structured sleeping projection', async () => { - const tabId = structuredAgentSessionTabId(LEGACY_AGENT_SESSION_ID) - const legacy = sleepingRecord(tabId, LIVE_LEAF_ID, LEGACY_AGENT_SESSION_ID) - const { deps, createTab, resume } = testDeps({ structured: true, sleeping: [legacy] }) + it('does not activate a terminal for a stale structured sleeping projection', async () => { + const tabId = structuredAgentSessionTabId(STALE_STRUCTURED_SESSION_ID) + const stale = sleepingRecord(tabId, LIVE_LEAF_ID, STALE_STRUCTURED_SESSION_ID) + const { deps, createTab, resume } = testDeps({ structured: true, sleeping: [stale] }) await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('blocked') diff --git a/src/renderer/src/runtime/local-structured-session-tabs-host-isolation.test.ts b/src/renderer/src/runtime/local-structured-session-tabs-host-isolation.test.ts new file mode 100644 index 00000000000..614eae533af --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-host-isolation.test.ts @@ -0,0 +1,219 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../shared/execution-host' +import { toWebTerminalSurfaceTabId } from './web-runtime-session' +import { + applyLocalStructuredSessionTabSnapshots, + projectLocalStructuredSessionTabs +} from './local-structured-session-tabs-sync' +import { applyWebSessionTabsSnapshot, type WebSessionTabsSyncState } from './web-session-tabs-sync' +import { + ENV, + HOST_SURFACE_ID, + LEAF_ID, + NOW, + WT, + makeSnapshot, + makeState, + resetWebSessionTabsSyncTestState +} from './web-session-tabs-sync-test-harness' + +vi.mock('../store', () => ({ + useAppStore: { + setState: vi.fn() + } +})) + +const REMOTE_GROUP = 'remote-group' +const STRUCTURED_GROUP = 'structured-group' +const HOST_TAB_ID = 'host-tab-1' +const MIRRORED_TAB_ID = toWebTerminalSurfaceTabId(HOST_TAB_ID) + +function terminalSnapshot(version = 1): RuntimeMobileSessionTabsResult { + return makeSnapshot( + [ + { + type: 'terminal', + id: HOST_SURFACE_ID, + parentTabId: HOST_TAB_ID, + leafId: LEAF_ID, + title: 'Terminal', + status: 'ready', + terminal: 'term-host', + isActive: true + } + ], + { + snapshotVersion: version, + activeGroupId: REMOTE_GROUP, + tabGroups: [{ id: REMOTE_GROUP, activeTabId: HOST_TAB_ID, tabOrder: [HOST_TAB_ID] }], + tabGroupLayout: { type: 'leaf', groupId: REMOTE_GROUP } + } + ) +} + +function pendingTerminalSnapshot(): RuntimeMobileSessionTabsResult { + return makeSnapshot( + [ + { + type: 'terminal', + id: HOST_SURFACE_ID, + parentTabId: HOST_TAB_ID, + leafId: LEAF_ID, + title: 'Starting terminal', + status: 'pending-handle', + terminal: null, + isActive: true + } + ], + { + activeGroupId: REMOTE_GROUP, + tabGroups: [{ id: REMOTE_GROUP, activeTabId: HOST_TAB_ID, tabOrder: [HOST_TAB_ID] }], + tabGroupLayout: { type: 'leaf', groupId: REMOTE_GROUP } + } + ) +} + +function structuredSnapshot(): RuntimeMobileSessionTabsResult { + return makeSnapshot( + [ + { + type: 'agent-session', + id: 'agent-session:codex-1', + title: 'Codex Chat', + sessionId: 'codex-1', + agent: 'codex', + isActive: true + } + ], + { + publicationEpoch: 'structured:epoch-1', + activeGroupId: STRUCTURED_GROUP, + activeTabId: 'agent-session:codex-1', + activeTabType: 'agent-session', + tabGroups: [ + { + id: STRUCTURED_GROUP, + activeTabId: 'agent-session:codex-1', + tabOrder: ['agent-session:codex-1'] + } + ], + tabGroupLayout: { type: 'leaf', groupId: STRUCTURED_GROUP } + } + ) +} + +function applySnapshot( + state: WebSessionTabsSyncState, + snapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + options?: Parameters[4] +): WebSessionTabsSyncState { + return { + ...state, + ...applyWebSessionTabsSnapshot(state, snapshot, environmentId, NOW, options) + } as WebSessionTabsSyncState +} + +function expectRemoteTerminalTopology(state: WebSessionTabsSyncState): void { + expect(state.tabsByWorktree[WT]).toEqual([ + expect.objectContaining({ id: MIRRORED_TAB_ID, ptyId: `remote:${ENV}@@term-host` }) + ]) + expect(state.unifiedTabsByWorktree[WT]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: MIRRORED_TAB_ID, contentType: 'terminal' }) + ]) + ) + expect(state.groupsByWorktree[WT]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: REMOTE_GROUP, + tabOrder: expect.arrayContaining([MIRRORED_TAB_ID]) + }) + ]) + ) + expect(state.activeGroupIdByWorktree[WT]).toBe(REMOTE_GROUP) + expect(state.layoutByWorktree[WT]).toEqual({ type: 'leaf', groupId: REMOTE_GROUP }) + expect(state.activeTabIdByWorktree[WT]).toBe(MIRRORED_TAB_ID) + expect(state.activeTabTypeByWorktree[WT]).toBe('terminal') +} + +describe('local structured session tab host isolation', () => { + beforeEach(resetWebSessionTabsSyncTestState) + + it('materializes a complete terminal group and layout into an otherwise empty paired state', () => { + const state = applySnapshot(makeState(), terminalSnapshot(), ENV) + + expectRemoteTerminalTopology(state) + }) + + it('materializes the active paired worktree without disturbing another worktree layout', () => { + const otherWorktree = 'repo::/other-worktree' + const otherLayout = { type: 'leaf' as const, groupId: 'other-group' } + const state = applySnapshot( + makeState({ layoutByWorktree: { [otherWorktree]: otherLayout } }), + terminalSnapshot(), + ENV + ) + + expectRemoteTerminalTopology(state) + expect(state.layoutByWorktree[otherWorktree]).toBe(otherLayout) + }) + + it('keeps ordinary remote topology stable across agent-only inventory frames', () => { + let state = applySnapshot(makeState(), terminalSnapshot(), ENV) + state = applySnapshot( + state, + projectLocalStructuredSessionTabs(structuredSnapshot()), + 'local-structured-session', + { + contentScope: 'agent-session', + preserveLocalLayout: true, + terminalPtyMode: 'local' + } + ) + expectRemoteTerminalTopology(state) + + state = applySnapshot(state, terminalSnapshot(2), ENV) + expectRemoteTerminalTopology(state) + }) + + it('keeps startup terminal topology through pending, ready, and repeated ready frames', () => { + let state = applySnapshot(makeState(), pendingTerminalSnapshot(), ENV) + expect(state.tabsByWorktree[WT]).toEqual([ + expect.objectContaining({ id: MIRRORED_TAB_ID, ptyId: null }) + ]) + expect(state.groupsByWorktree[WT]).toEqual([ + expect.objectContaining({ id: REMOTE_GROUP, tabOrder: [MIRRORED_TAB_ID] }) + ]) + expect(state.layoutByWorktree[WT]).toEqual({ type: 'leaf', groupId: REMOTE_GROUP }) + + state = applySnapshot(state, terminalSnapshot(2), ENV) + expectRemoteTerminalTopology(state) + const stableGroups = state.groupsByWorktree + const stableLayouts = state.layoutByWorktree + + state = applySnapshot(state, terminalSnapshot(3), ENV) + expectRemoteTerminalTopology(state) + expect(state.groupsByWorktree).toBe(stableGroups) + expect(state.layoutByWorktree).toBe(stableLayouts) + }) + + it.each([ + ['paired', toRuntimeExecutionHostId(ENV)], + ['SSH', toSshExecutionHostId('ssh-target-1')] + ])('does not project local structured inventory into a %s-owned workspace', (_name, hostId) => { + const state = makeState({ + activeWorkspaceExecutionHostId: hostId + } as Partial) + + const next = applyLocalStructuredSessionTabSnapshots( + state, + [structuredSnapshot()], + undefined, + NOW + ) + + expect(next).toBe(state) + }) +}) diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts index a336b14923d..2eb3e8eab01 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts @@ -2,7 +2,10 @@ import { useEffect } from 'react' import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import { useAppStore } from '../store' +import type { WorktreeRuntimeOwnerState } from '../lib/worktree-runtime-owner' +import { getExecutionHostIdForWorktree } from '../lib/worktree-runtime-owner' import { applyWebSessionTabsSnapshot, applyWebSessionTabsStorePatch } from './web-session-tabs-sync' +import type { WebSessionTabsSyncState } from './web-session-tabs-sync' export const LOCAL_STRUCTURED_SESSION_OWNER = 'local-structured-session' let localStructuredSessionTabsRestorePromise: Promise | null = null @@ -52,25 +55,42 @@ export function applyStructuredSessionTabSnapshots( owner = LOCAL_STRUCTURED_SESSION_OWNER ): void { const settleStructuredSessionMirror = applyWebSessionTabsStorePatch( - (state) => { - let next = state - for (const snapshot of snapshots) { - const patch = applyWebSessionTabsSnapshot( - next, - projectLocalStructuredSessionTabs(snapshot), - owner, - Date.now(), - { preserveLocalLayout: true, terminalPtyMode: 'local' } - ) - next = patch === next ? next : ({ ...next, ...patch } as typeof state) - } - return next - }, + (state) => applyLocalStructuredSessionTabSnapshots(state, snapshots, owner), { frames: [] } ) settleStructuredSessionMirror() } +export function applyLocalStructuredSessionTabSnapshots< + State extends WebSessionTabsSyncState & WorktreeRuntimeOwnerState +>( + state: State, + snapshots: readonly RuntimeMobileSessionTabsResult[], + owner = LOCAL_STRUCTURED_SESSION_OWNER, + now = Date.now() +): State { + let next = state + for (const snapshot of snapshots) { + // Why: the execution host owns its tabs; local inventory must not rewrite paired or SSH panes. + if (getExecutionHostIdForWorktree(next, snapshot.worktree) !== 'local') { + continue + } + const patch = applyWebSessionTabsSnapshot( + next, + projectLocalStructuredSessionTabs(snapshot), + owner, + now, + { + contentScope: 'agent-session', + preserveLocalLayout: true, + terminalPtyMode: 'local' + } + ) + next = patch === next ? next : ({ ...next, ...patch } as State) + } + return next +} + export function restoreLocalStructuredSessionTabsOnce(): Promise { localStructuredSessionTabsRestorePromise ??= refreshLocalStructuredSessionTabs() .then(() => undefined) diff --git a/src/renderer/src/runtime/web-session-structured-tab-focus.test.ts b/src/renderer/src/runtime/web-session-structured-tab-focus.test.ts index 8ae3930e607..cde8bb66991 100644 --- a/src/renderer/src/runtime/web-session-structured-tab-focus.test.ts +++ b/src/renderer/src/runtime/web-session-structured-tab-focus.test.ts @@ -18,7 +18,8 @@ function structuredTab(sessionId: string, sortOrder: number): Tab { customLabel: null, color: null, sortOrder, - createdAt: sortOrder + 1 + createdAt: sortOrder + 1, + isPinned: false } } @@ -99,6 +100,11 @@ describe('web session structured tab focus', () => { const patch = applyWebSessionTabsSnapshot(state, snapshot, 'environment-1', 10) - expect(patch.groupsByWorktree?.[WORKTREE_ID]?.[0]?.activeTabId).toBe(second.id) + const applied = patch === state ? state : ({ ...state, ...patch } as WebSessionTabsSyncState) + + expect(applied.groupsByWorktree[WORKTREE_ID]?.[0]?.activeTabId).toBe(second.id) + expect(applied.unifiedTabsByWorktree[WORKTREE_ID]).toBe( + state.unifiedTabsByWorktree[WORKTREE_ID] + ) }) }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index e6f8443a0e7..a213b2dbfe3 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -169,6 +169,7 @@ type SessionTabsRemovalFence = { } export type WebSessionTabsSnapshotApplyOptions = { + contentScope?: 'all' | 'agent-session' preserveLocalLayout?: boolean terminalPtyMode?: 'local' | 'remote' } @@ -2801,7 +2802,10 @@ function applyWebSessionTabsSnapshotWithContext( } const currentTerminalTabs = state.tabsByWorktree[worktreeId] ?? [] const existingTerminalById = new Map(currentTerminalTabs.map((tab) => [tab.id, tab])) - const terminalSurfaceTabs = snapshot.tabs.filter(isTerminalSurfaceTab) + const reconcilesNonAgentTabs = options?.contentScope !== 'agent-session' + const terminalSurfaceTabs = reconcilesNonAgentTabs + ? snapshot.tabs.filter(isTerminalSurfaceTab) + : [] const readyTerminalTabs = terminalSurfaceTabs.filter(isReadyTerminalTab) const nextRemotePtyIds = new Set( readyTerminalTabs.map((tab) => toRemoteRuntimePtyId(tab.terminal, environmentId)) @@ -2834,16 +2838,18 @@ function applyWebSessionTabsSnapshotWithContext( } } const exactProvisionalHandoffs = new Set(provisionalHandoffHostTabIds.keys()) - const retainedTerminalTabs = currentTerminalTabs.filter( - (tab) => - !shouldReplaceTerminalTab( - tab, - environmentId, - nextRemotePtyIds, - nextMirroredTerminalIds, - exactProvisionalHandoffs + const retainedTerminalTabs = reconcilesNonAgentTabs + ? currentTerminalTabs.filter( + (tab) => + !shouldReplaceTerminalTab( + tab, + environmentId, + nextRemotePtyIds, + nextMirroredTerminalIds, + exactProvisionalHandoffs + ) ) - ) + : currentTerminalTabs const mirroredTerminalTabs = buildMirroredTerminalTabs( snapshot, environmentId, @@ -2880,7 +2886,7 @@ function applyWebSessionTabsSnapshotWithContext( const hostGroupIdByTabId = buildHostGroupIdByTabId(snapshot.tabGroups) const currentUnifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] const existingTabIndex = buildWebSessionExistingTabIndex({ unifiedTabs: currentUnifiedTabs }) - const readyBrowserTabs = snapshot.tabs.filter(isReadyBrowserTab) + const readyBrowserTabs = reconcilesNonAgentTabs ? snapshot.tabs.filter(isReadyBrowserTab) : [] const nextRemoteBrowserPageIds = new Set(readyBrowserTabs.map((tab) => tab.browserPageId)) const mirroredBrowserTabs = buildMirroredBrowserTabs( snapshot, @@ -2896,7 +2902,7 @@ function applyWebSessionTabsSnapshotWithContext( ) const currentBrowserTabs = state.browserTabsByWorktree[worktreeId] ?? [] const removedBrowserWorkspaceIds = new Set( - currentBrowserTabs + (reconcilesNonAgentTabs ? currentBrowserTabs : []) .filter((tab) => { if (mirroredBrowserWorkspaceIds.has(tab.id)) { return true @@ -2947,7 +2953,7 @@ function applyWebSessionTabsSnapshotWithContext( retainedBrowserTabs.length + mirroredBrowserTabs.length > 0 ? [...retainedBrowserTabs, ...mirroredBrowserTabs.map((entry) => entry.workspace)] : null - const readyEditorTabs = snapshot.tabs.filter(isReadyEditorTab) + const readyEditorTabs = reconcilesNonAgentTabs ? snapshot.tabs.filter(isReadyEditorTab) : [] const worktreeOpenFiles = webSessionOpenFilesForWorktree(state, worktreeId, batchContext) const mirroredEditorTabs = buildMirroredEditorTabs( snapshot, @@ -2970,7 +2976,7 @@ function applyWebSessionTabsSnapshotWithContext( const mirroredEditorFileIds = new Set(mirroredEditorTabs.map((entry) => entry.file.id)) const mirroredEditorHostTabIds = new Set(mirroredEditorTabs.map((entry) => entry.hostTabId)) const removedEditorFileIds = new Set( - worktreeOpenFiles + (reconcilesNonAgentTabs ? worktreeOpenFiles : []) .filter( (file) => file.runtimeEnvironmentId === environmentId &&