mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
fix(native-chat): retain panes across tab activation
This commit is contained in:
@@ -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'")
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
+205
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
|
||||
+5
-5
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user