fix(native-chat): retain panes across tab activation

This commit is contained in:
Brennan Benson
2026-08-27 18:52:27 -07:00
parent 23a8696b75
commit 761ac89aa0
21 changed files with 1118 additions and 149 deletions
@@ -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'")
+6 -7
View File
@@ -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 ? (
<EmulatorPaneOverlayLayer worktreeId={worktreeId} isWorktreeActive={isVisible} />
) : null}
<StructuredAgentSessionPaneOverlayLayer
worktreeId={worktreeId}
isWorktreeActive={isVisible}
/>
<AiVaultSessionDropLayer worktreeId={worktreeId} enabled={isVisible} />
</div>
)
@@ -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<string, readonly Tab[]>
groupsByWorktree: Record<string, readonly TabGroup[]>
runtimeEnvironmentId: string | null
executionHostId: string
focusGroup: (worktreeId: string, groupId: string) => void
}
const mocks = vi.hoisted(() => ({
store: null as null | { setState: (state: Partial<MockAppState>) => void },
focusGroup: vi.fn(),
mountsByTabId: new Map<string, number>(),
unmountsByTabId: new Map<string, number>()
}))
vi.mock('@/store', async () => {
const { create } = await import('zustand')
const useAppStore = create<MockAppState>(() => ({
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 (
<span
data-chat-tab-id={tabId}
data-chat-visible={String(isVisible)}
data-native-chat-working="true"
/>
)
}
}
})
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(
<StructuredAgentSessionPaneOverlayLayer worktreeId={WORKTREE_ID} isWorktreeActive />
)
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(
<StructuredAgentSessionPaneOverlayLayer worktreeId={WORKTREE_ID} isWorktreeActive />
)
const slot = view.container.querySelector<HTMLElement>(
`[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(
<StructuredAgentSessionPaneOverlayLayer worktreeId={WORKTREE_ID} isWorktreeActive />
)
const slot = view.container.querySelector<HTMLElement>(
`[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<HTMLElement>(`[data-chat-tab-id="${tabId}"]`)
if (!surface) {
throw new Error(`missing structured chat surface ${tabId}`)
}
return surface
}
@@ -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<Tab['agentSessionAgent']>
}
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<React.CSSProperties>(
() =>
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 (
<div
style={style}
className="native-chat-pane-shell z-10 min-h-0 min-w-0"
data-structured-agent-session-overlay-tab-id={tab.id}
aria-hidden={!isActive}
onPointerDown={focusOwningGroup}
onFocusCapture={focusOwningGroup}
>
<NativeChatView
mode="structured"
tabId={tab.id}
sessionId={tab.entityId}
agent={tab.agentSessionAgent}
isVisible={isActive}
target={target}
allowFileUriLinks={allowFileUriLinks}
/>
</div>
)
})
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) => (
<StructuredAgentSessionOverlaySlot
key={tab.id}
tab={tab}
groupId={tab.groupId}
isActive={Boolean(isWorktreeActive && groupActiveTabById.get(tab.groupId) === tab.id)}
target={target}
allowFileUriLinks={allowFileUriLinks}
onFocusOwningGroup={focusOwningGroup}
/>
))}
</>
)
}
)
export default StructuredAgentSessionPaneOverlayLayer
@@ -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('<StructuredAgentSessionPaneOverlayLayer')
expect(tabGroup).not.toContain('<NativeChatView')
})
})
@@ -222,7 +222,7 @@ describe('ExperimentalPane', () => {
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<HTMLButtonElement>('[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<HTMLButtonElement>(
'#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(
<ExperimentalPane settings={disabledSettings} updateSettings={vi.fn()} />
)
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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>(
'#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<HTMLButtonElement>('[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()
})
@@ -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<GlobalSettings>) => 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 (
<SearchableSetting
@@ -54,41 +59,95 @@ export function NativeChatExperimentalSetting({
/>
</div>
{nativeChatEnabled ? (
<div className="ml-4 border-l border-border pl-4">
<div className="mb-4 flex items-start justify-between gap-4">
<div className="ml-4 space-y-4 border-l border-border pl-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 shrink space-y-0.5">
<Label>
{translate(
'auto.components.settings.ExperimentalPane.nativeChat.structuredTitle',
'Use updated structured native chat'
'auto.components.settings.ExperimentalPane.nativeChat.defaultTitle',
'Default view'
)}
</Label>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.ExperimentalPane.nativeChat.structuredCopy',
'Opt in to the host-owned structured Codex runtime. Off keeps the existing terminal-backed chat path.'
)}
</p>
<p className="text-xs text-muted-foreground">
{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.'
)}
</p>
</div>
<SettingsSwitch
checked={structuredNativeChatEnabled}
ariaLabel={translate(
'auto.components.settings.ExperimentalPane.nativeChat.structuredToggleLabel',
'Toggle updated structured native chat'
)}
onChange={() =>
<Select
value={defaultView}
onValueChange={(value: NativeChatDefaultView) => {
updateSettings({
experimentalStructuredNativeChat: !structuredNativeChatEnabled
openAgentTabsInChatByDefault: value === 'native-chat'
})
}
/>
}}
>
<SelectTrigger
aria-label={translate(
'auto.components.settings.ExperimentalPane.nativeChat.defaultViewLabel',
'Default Chat UI view'
)}
className="w-36"
size="sm"
>
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4} avoidCollisions={false}>
<SelectItem value="terminal-chat">
{translate(
'auto.components.settings.ExperimentalPane.nativeChat.defaultViewTerminal',
'Terminal chat'
)}
</SelectItem>
<SelectItem value="native-chat">
{translate(
'auto.components.settings.ExperimentalPane.nativeChat.defaultViewNative',
'Chat UI'
)}
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 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' ? (
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 shrink space-y-0.5">
<Label>
{translate(
'auto.components.settings.ExperimentalPane.nativeChat.structuredTitle',
'Use updated structured native chat'
)}
</Label>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.ExperimentalPane.nativeChat.structuredCopy',
'Opt in to the host-owned structured Codex runtime. Off keeps the existing terminal-backed chat path.'
)}
</p>
<p className="text-xs text-muted-foreground">
{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.'
)}
</p>
</div>
<SettingsSwitch
checked={structuredNativeChatEnabled}
ariaLabel={translate(
'auto.components.settings.ExperimentalPane.nativeChat.structuredToggleLabel',
'Toggle updated structured native chat'
)}
onChange={() =>
updateSettings({
experimentalStructuredNativeChat: !structuredNativeChatEnabled
})
}
/>
</div>
) : null}
</div>
) : null}
</SearchableSetting>
@@ -200,23 +200,22 @@ function QuickLaunchAgentMenuItemsInner({
const showsDefaultAgentShortcut =
newAgentShortcut !== null && defaultAgent !== 'blank' && agent === defaultAgent
return (
<React.Fragment key={agent}>
<DropdownMenuItem
onSelect={() => 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 }
)}
>
<AgentIcon agent={agent} size={14} />
<span className="flex-1">{label}</span>
{showsDefaultAgentShortcut ? (
<DropdownMenuShortcut>{newAgentShortcut}</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
</React.Fragment>
<DropdownMenuItem
key={agent}
onSelect={() => 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 }
)}
>
<AgentIcon agent={agent} size={14} />
<span className="flex-1">{label}</span>
{showsDefaultAgentShortcut ? (
<DropdownMenuShortcut>{newAgentShortcut}</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
)
})}
<DropdownMenuItem
@@ -23,13 +23,6 @@ import type { TabGroup } from '../../../../shared/tab-types'
import type { ClientHostedBrowserRow } from '../../../../shared/client-hosted-browser-rows'
import { useClientHostedBrowserRows } from '@/lib/pane-manager/client-hosted-browser-row-state'
import { resolveClientHostedBrowserRowStripGroupId } from '../tab-bar/client-hosted-browser-row-strip-placement'
import NativeChatView from '../native-chat/NativeChatView'
import {
getExecutionHostIdForWorktree,
getRuntimeEnvironmentIdForWorktree
} from '@/lib/worktree-runtime-owner'
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle'
const EditorPanel = lazy(() => 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({
</div>
)}
{activeTab?.contentType === 'agent-session' &&
isAgentSessionHandleProvider(activeTab.agentSessionAgent) ? (
<div className="native-chat-pane-shell absolute inset-0 z-10 flex min-h-0 min-w-0">
<NativeChatView
key={activeTab.entityId}
mode="structured"
tabId={activeTab.id}
sessionId={activeTab.entityId}
agent={activeTab.agentSessionAgent}
isVisible={isVisible}
target={structuredRuntimeTarget}
allowFileUriLinks={structuredFileLinksEnabled}
/>
</div>
) : 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. */}
</div>
</div>
)
+3 -8
View File
@@ -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": {
@@ -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')
@@ -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,
@@ -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<void> {
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<string>((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)
})
})
@@ -87,15 +87,6 @@ function launchStructuredCodexSessionOnce(worktreeId: string): Promise<string> {
}
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(
@@ -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)
@@ -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
}
@@ -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')
@@ -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<typeof applyWebSessionTabsSnapshot>[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<WebSessionTabsSyncState>)
const next = applyLocalStructuredSessionTabSnapshots(
state,
[structuredSnapshot()],
undefined,
NOW
)
expect(next).toBe(state)
})
})
@@ -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<void> | 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<void> {
localStructuredSessionTabsRestorePromise ??= refreshLocalStructuredSessionTabs()
.then(() => undefined)
@@ -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]
)
})
})
@@ -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 &&