mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
fix: reconcile packaged terminal restoration
This commit is contained in:
@@ -861,6 +861,11 @@ ipcMain.handle('app:awaitFirstWindowStartupServices', async () => {
|
||||
await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady])
|
||||
})
|
||||
|
||||
ipcMain.handle('app:prepareTerminalStartupRestoration', async () => {
|
||||
await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady])
|
||||
await runtime?.prepareStructuredAgentSessionStartupRestoration()
|
||||
})
|
||||
|
||||
ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup', () =>
|
||||
recoverLegacyWorkerTerminalsForRendererStartup({
|
||||
firstWindowStartupServicesReady,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { OrcaRuntimeService } from './orca-runtime'
|
||||
afterEach(() => setStructuredAgentSessionHost(null))
|
||||
|
||||
describe('structured session cold restoration', () => {
|
||||
it('hydrates PTY inventory before recovering durable TUI owners and only runs once', async () => {
|
||||
it('loads records, inventories PTYs, restores ownership, then projects tabs exactly once', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const hydrate = vi.fn()
|
||||
const refresh = vi.fn(async () => new Set<string>())
|
||||
@@ -29,8 +29,10 @@ describe('structured session cold restoration', () => {
|
||||
listSessionTabs: () => []
|
||||
} as never)
|
||||
|
||||
await runtime.restoreStructuredAgentSessionTabs()
|
||||
await runtime.restoreStructuredAgentSessionTabs()
|
||||
const first = runtime.restoreStructuredAgentSessionTabs()
|
||||
const second = runtime.restoreStructuredAgentSessionTabs()
|
||||
expect(second).toBe(first)
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(hydrate).toHaveBeenCalledWith('workspace-1', {
|
||||
allowAttachedWindow: true,
|
||||
@@ -39,8 +41,15 @@ describe('structured session cold restoration', () => {
|
||||
expect(hydrate).toHaveBeenCalledWith()
|
||||
expect(refresh).toHaveBeenCalledOnce()
|
||||
expect(restoreReadableSessions).toHaveBeenCalledOnce()
|
||||
expect(ensureHost).toHaveBeenCalledOnce()
|
||||
expect(ensureHost.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
refresh.mock.invocationCallOrder[0] ?? Infinity
|
||||
)
|
||||
expect(refresh.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
restoreReadableSessions.mock.invocationCallOrder[0] ?? Infinity
|
||||
)
|
||||
expect(restoreReadableSessions.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
hydrate.mock.invocationCallOrder[0] ?? Infinity
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2828,6 +2828,7 @@ export class OrcaRuntimeService {
|
||||
new TerminalFocusNavigationCoalescer<RuntimeTerminalFocus>()
|
||||
private pendingMobileSessionPtyInventoryRefresh: Promise<Set<string> | null> | null = null
|
||||
private structuredAgentSessionTabRestorePromise: Promise<void> | null = null
|
||||
private structuredAgentSessionStartupRestorePromise: Promise<void> | null = null
|
||||
private leaves = new Map<string, RuntimeLeafRecord>()
|
||||
// Why: PTY output is a per-keystroke hot path. Looking up affected leaves by
|
||||
// ptyId keeps active TUI redraws independent of the total open terminal count.
|
||||
@@ -9804,7 +9805,24 @@ export class OrcaRuntimeService {
|
||||
return this.structuredAgentSessionTabRestorePromise
|
||||
}
|
||||
|
||||
prepareStructuredAgentSessionStartupRestoration(): Promise<void> {
|
||||
this.structuredAgentSessionStartupRestorePromise ??=
|
||||
this.prepareStructuredAgentSessionStartupRestorationOnce().catch((error) => {
|
||||
this.structuredAgentSessionStartupRestorePromise = null
|
||||
throw error
|
||||
})
|
||||
return this.structuredAgentSessionStartupRestorePromise
|
||||
}
|
||||
|
||||
private async prepareStructuredAgentSessionStartupRestorationOnce(): Promise<void> {
|
||||
// Durable agent records must exist before daemon inventory can be reconciled against them.
|
||||
await this.ensureStructuredAgentSessionHost()
|
||||
await this.refreshMobileSessionPtyRecords()
|
||||
await getStructuredAgentSessionHost()?.restoreReadableSessions()
|
||||
}
|
||||
|
||||
private async restoreStructuredAgentSessionTabsOnce(): Promise<void> {
|
||||
await this.prepareStructuredAgentSessionStartupRestoration()
|
||||
for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) {
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, {
|
||||
allowAttachedWindow: true,
|
||||
@@ -9812,10 +9830,7 @@ export class OrcaRuntimeService {
|
||||
})
|
||||
}
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession()
|
||||
await this.refreshMobileSessionPtyRecords()
|
||||
await this.ensureStructuredAgentSessionHost()
|
||||
const host = getStructuredAgentSessionHost()
|
||||
await host?.restoreReadableSessions()
|
||||
for (const session of host?.listSessionTabs() ?? []) {
|
||||
this.publishStructuredAgentSessionTab({ ...session, activate: false, notify: false })
|
||||
}
|
||||
|
||||
@@ -951,6 +951,8 @@ export type AppApi = {
|
||||
/** Resolves when the daemon PTY provider and hook receiver have either
|
||||
* started or failed open for the first BrowserWindow. */
|
||||
awaitFirstWindowStartupServices: () => Promise<void>
|
||||
/** Inventories retained PTYs and restores durable structured ownership before renderer adoption. */
|
||||
prepareTerminalStartupRestoration: () => Promise<void>
|
||||
/** Reconciles legacy worker authority around persisted terminal reconnect. */
|
||||
recoverLegacyWorkerTerminalsForRendererStartup: () => Promise<void>
|
||||
/** Emits a startup benchmark marker when ORCA_STARTUP_DIAGNOSTICS is enabled. */
|
||||
|
||||
@@ -525,6 +525,8 @@ const api = {
|
||||
},
|
||||
awaitFirstWindowStartupServices: (): Promise<void> =>
|
||||
ipcRenderer.invoke('app:awaitFirstWindowStartupServices'),
|
||||
prepareTerminalStartupRestoration: (): Promise<void> =>
|
||||
ipcRenderer.invoke('app:prepareTerminalStartupRestoration'),
|
||||
recoverLegacyWorkerTerminalsForRendererStartup: (): Promise<void> =>
|
||||
ipcRenderer.invoke('app:recoverLegacyWorkerTerminalsForRendererStartup'),
|
||||
startupDiagnostic: (event: string, details?: Record<string, unknown>): Promise<void> =>
|
||||
|
||||
@@ -113,7 +113,10 @@ import {
|
||||
setRuntimeGraphSyncEnabled
|
||||
} from './runtime/sync-runtime-graph'
|
||||
import { useWebSessionTabsSync } from './runtime/web-session-tabs-sync'
|
||||
import { useLocalStructuredSessionTabsSync } from './runtime/local-structured-session-tabs-sync'
|
||||
import {
|
||||
restoreLocalStructuredSessionTabsOnce,
|
||||
useLocalStructuredSessionTabsSync
|
||||
} from './runtime/local-structured-session-tabs-sync'
|
||||
import { StructuredAgentSessionStatusBridge } from './components/native-chat/StructuredAgentSessionStatusBridge'
|
||||
import { useGlobalFileDrop } from './hooks/useGlobalFileDrop'
|
||||
import { MacosTccPromptNoticeHost } from './hooks/MacosTccPromptNoticeHost'
|
||||
@@ -493,6 +496,7 @@ function App(): React.JSX.Element {
|
||||
setSshConnectionState: s.setSshConnectionState,
|
||||
hydratePersistedUI: s.hydratePersistedUI,
|
||||
setHydrationSucceeded: s.setHydrationSucceeded,
|
||||
setTerminalStartupRestorationReady: s.setTerminalStartupRestorationReady,
|
||||
openModal: s.openModal,
|
||||
closeModal: s.closeModal,
|
||||
markFeatureTipsSeen: s.markFeatureTipsSeen,
|
||||
@@ -1006,6 +1010,12 @@ function App(): React.JSX.Element {
|
||||
actions.hydrateEditorSession(sessionRead.session, sessionHydrationOptions)
|
||||
actions.hydrateBrowserSession(sessionRead.session, sessionHydrationOptions)
|
||||
})
|
||||
await timeRendererStartupStep('prepare-terminal-startup-restoration', () =>
|
||||
window.api.app.prepareTerminalStartupRestoration()
|
||||
)
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
// Why: prune visit timestamps AFTER hydration (earlier, worktreesByRepo may be empty and prune would drop entries for worktrees about to appear); seed the active worktree if missing.
|
||||
// See docs/cmd-j-empty-query-ordering.md.
|
||||
timeRendererStartupSyncStep('visit-timestamp-prune', () => {
|
||||
@@ -1115,15 +1125,25 @@ function App(): React.JSX.Element {
|
||||
await timeRendererStartupStep('reconnect-terminals', () =>
|
||||
actions.reconnectPersistedTerminals(abortController.signal)
|
||||
)
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
await timeRendererStartupStep('recover-legacy-worker-terminals-post-reconnect', () =>
|
||||
window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()
|
||||
)
|
||||
await timeRendererStartupStep('project-structured-session-tabs', () =>
|
||||
restoreLocalStructuredSessionTabsOnce()
|
||||
)
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
// Why here: reconnect just published restored PTY ids; sweeping them now
|
||||
// re-offers stale Codex panes whose tabs never mount this session.
|
||||
sweepRestoredCodexPanesForStaleAccounts(useAppStore.getState())
|
||||
syncZoomCSSVar()
|
||||
// Why (issue #1158): unlock the session writer only after hydration and all dependent steps succeeded, so a mid-startup throw can't serialize partially-mutated state to disk.
|
||||
actions.setHydrationSucceeded(true)
|
||||
actions.setTerminalStartupRestorationReady(true)
|
||||
logRendererStartupDiagnostic('startup-hydration-done', {
|
||||
durationMs: Math.round(performance.now() - startupStartedAt)
|
||||
})
|
||||
@@ -1226,6 +1246,7 @@ function App(): React.JSX.Element {
|
||||
pendingReconnectPtyIdByTabId: {}
|
||||
})
|
||||
}
|
||||
actions.setTerminalStartupRestorationReady(true)
|
||||
}
|
||||
}
|
||||
void actions.initGitHubCache()
|
||||
|
||||
@@ -321,6 +321,40 @@ describe('renderer startup runtime routing', () => {
|
||||
expect(reconnectIndex).toBeGreaterThan(capabilityIndex)
|
||||
})
|
||||
|
||||
it('orders packaged restoration before adoption, projection, and default creation', () => {
|
||||
const appSource = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
|
||||
const terminalSource = readFileSync(
|
||||
join(process.cwd(), 'src/renderer/src/components/Terminal.tsx'),
|
||||
'utf8'
|
||||
)
|
||||
const hydrateIndex = appSource.indexOf("timeRendererStartupSyncStep('hydrate-session-stores'")
|
||||
const prepareIndex = appSource.indexOf(
|
||||
"timeRendererStartupStep('prepare-terminal-startup-restoration'"
|
||||
)
|
||||
const reconnectIndex = appSource.indexOf("timeRendererStartupStep('reconnect-terminals'")
|
||||
const projectIndex = appSource.indexOf(
|
||||
"timeRendererStartupStep('project-structured-session-tabs'"
|
||||
)
|
||||
const readyIndex = appSource.indexOf('actions.setTerminalStartupRestorationReady(true)')
|
||||
const gateStart = terminalSource.indexOf('const startupActivationGateWorktreeIdsRef')
|
||||
const gateEnd = terminalSource.indexOf('const handleNewTab', gateStart)
|
||||
const gateBlock = terminalSource.slice(gateStart, gateEnd)
|
||||
const gateIndex = gateBlock.indexOf('gateWorktreeAgentActivation(activeWorktreeId)')
|
||||
const createIndex = gateBlock.indexOf(
|
||||
'createTab(activeWorktreeId, undefined, undefined, { pendingActivationSpawn: true })'
|
||||
)
|
||||
|
||||
expect(hydrateIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(hydrateIndex).toBeLessThan(prepareIndex)
|
||||
expect(prepareIndex).toBeLessThan(reconnectIndex)
|
||||
expect(reconnectIndex).toBeLessThan(projectIndex)
|
||||
expect(projectIndex).toBeLessThan(readyIndex)
|
||||
expect(gateBlock).toContain('terminalStartupRestorationReady')
|
||||
expect(gateIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(gateIndex).toBeLessThan(createIndex)
|
||||
expect(gateBlock.slice(gateIndex, createIndex)).toContain("outcome === 'blocked'")
|
||||
})
|
||||
|
||||
it('does not load the terminal workbench on the no-workspace landing path', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
|
||||
|
||||
|
||||
@@ -342,6 +342,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
const consumeSuppressedPtyExit = useAppStore((s) => s.consumeSuppressedPtyExit)
|
||||
const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId)
|
||||
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
|
||||
const terminalStartupRestorationReady = useAppStore((s) => s.terminalStartupRestorationReady)
|
||||
const hydrationSucceeded = useAppStore((s) => s.hydrationSucceeded)
|
||||
const startupWorktreeRefreshCompleted = useAppStore((s) => s.startupWorktreeRefreshCompleted)
|
||||
const openFiles = useAppStore((s) => s.openFiles)
|
||||
@@ -1465,40 +1466,50 @@ function Terminal(): React.JSX.Element | null {
|
||||
])
|
||||
// Why: on host unmount no reconciliation effect runs again, so dispose every remaining parked watcher.
|
||||
useEffect(() => () => disposeAllParkedTerminalWatchers(), [])
|
||||
// Auto-create first tab when worktree activates
|
||||
const startupActivationGateWorktreeIdsRef = useRef(new Set<string>())
|
||||
useEffect(() => {
|
||||
if (!workspaceSessionReady) {
|
||||
return
|
||||
}
|
||||
if (!activeWorktreeId) {
|
||||
if (
|
||||
!workspaceSessionReady ||
|
||||
!terminalStartupRestorationReady ||
|
||||
!hydrationSucceeded ||
|
||||
!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.
|
||||
if (isWebRuntimeSessionActive(getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId))) {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: give a newly activated worktree a focusable surface when nothing renders, without recreating one after the user closes the last visible tab.
|
||||
const { renderableTabCount } = reconcileWorktreeTabModel(activeWorktreeId)
|
||||
if (!shouldAutoCreateInitialTerminal(renderableTabCount)) {
|
||||
return
|
||||
}
|
||||
// Why: tag this never-visited-worktree tab so its PTY spawn doesn't count as activity and reshuffle the sidebar (explicit New Tab still bumps).
|
||||
createTab(activeWorktreeId, undefined, undefined, { pendingActivationSpawn: true })
|
||||
}, [workspaceSessionReady, activeWorktreeId, createTab, reconcileWorktreeTabModel])
|
||||
|
||||
const startupActivationGateWorktreeIdsRef = useRef(new Set<string>())
|
||||
useEffect(() => {
|
||||
if (!workspaceSessionReady || !hydrationSucceeded || !activeWorktreeId) {
|
||||
return
|
||||
}
|
||||
if (startupActivationGateWorktreeIdsRef.current.has(activeWorktreeId)) {
|
||||
return
|
||||
}
|
||||
startupActivationGateWorktreeIdsRef.current.add(activeWorktreeId)
|
||||
// Why: startup bypasses activateAndRevealWorktree, but must still adopt daemon PTYs before resuming persisted agents.
|
||||
void gateWorktreeAgentActivation(activeWorktreeId)
|
||||
}, [activeWorktreeId, hydrationSucceeded, workspaceSessionReady])
|
||||
let cancelled = false
|
||||
void gateWorktreeAgentActivation(activeWorktreeId).then((outcome) => {
|
||||
if (
|
||||
cancelled ||
|
||||
outcome === 'blocked' ||
|
||||
useAppStore.getState().activeWorktreeId !== activeWorktreeId
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Why: the activation gate reconciles durable/live agent state first; only an actually empty, never-visited workspace receives a default shell.
|
||||
const { renderableTabCount } = reconcileWorktreeTabModel(activeWorktreeId)
|
||||
if (shouldAutoCreateInitialTerminal(renderableTabCount)) {
|
||||
createTab(activeWorktreeId, undefined, undefined, { pendingActivationSpawn: true })
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [
|
||||
activeWorktreeId,
|
||||
createTab,
|
||||
hydrationSucceeded,
|
||||
reconcileWorktreeTabModel,
|
||||
terminalStartupRestorationReady,
|
||||
workspaceSessionReady
|
||||
])
|
||||
|
||||
const handleNewTab = useCallback(
|
||||
(shellOverride?: string) => {
|
||||
|
||||
@@ -763,11 +763,11 @@ function TerminalPane(
|
||||
() => useAppStore.getState().pendingIssueCommandSplitByTabId[tabId]
|
||||
)
|
||||
const consumeTabIssueCommandSplit = useAppStore((store) => store.consumeTabIssueCommandSplit)
|
||||
useEffect(() => {
|
||||
const settleTabStartupCommand = useCallback(() => {
|
||||
if (startup) {
|
||||
consumeTabStartupCommand(tabId)
|
||||
consumeTabStartupCommand(tabId, startup)
|
||||
}
|
||||
}, [startup, tabId, consumeTabStartupCommand])
|
||||
}, [consumeTabStartupCommand, startup, tabId])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isVisible && shouldMeasureHiddenStartup) {
|
||||
@@ -1418,6 +1418,7 @@ function TerminalPane(
|
||||
setCacheTimerStartedAt,
|
||||
syncPanePtyLayoutBinding,
|
||||
clearExitedPanePtyLayoutBinding,
|
||||
onStartupBound: settleTabStartupCommand,
|
||||
setTabPaneExpanded,
|
||||
setTabCanExpandPane,
|
||||
setExpandedPane,
|
||||
|
||||
@@ -98,6 +98,8 @@ export type PtyConnectionDeps = {
|
||||
setCacheTimerStartedAt: (key: string, ts: number | null) => void
|
||||
syncPanePtyLayoutBinding: (paneId: number, ptyId: string | null) => void
|
||||
clearExitedPanePtyLayoutBinding: (paneId: number, exitedPtyId: string) => void
|
||||
/** Settles the captured one-shot startup only after this pane owns a concrete PTY. */
|
||||
onStartupBound?: () => void
|
||||
deferPtyInput?: (paneId: number, data: string, forward: (data: string) => void) => void
|
||||
/** Records a DECSET 2031 subscription answered from main's
|
||||
* '2031-subscribe' fact, mirroring the xterm CSI handler's registry write
|
||||
|
||||
@@ -4005,6 +4005,34 @@ describe('connectPanePty', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('settles a queued startup only after the pane binds its spawned PTY', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('pty-resume')
|
||||
transportFactoryQueue.push(transport)
|
||||
const onStartupBound = vi.fn()
|
||||
const startup = {
|
||||
command: "codex 'resume' 'codex-session-1'",
|
||||
resumeProviderSession: { key: 'session_id', id: 'codex-session-1' } as const
|
||||
}
|
||||
|
||||
connectPanePty(
|
||||
createPane(1) as never,
|
||||
createManager(1) as never,
|
||||
createDeps({ startup, onStartupBound }) as never
|
||||
)
|
||||
|
||||
expect(onStartupBound).not.toHaveBeenCalled()
|
||||
expect(createdTransportOptions[0]).toMatchObject(startup)
|
||||
|
||||
const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as
|
||||
| ((ptyId: string) => void)
|
||||
| undefined
|
||||
onPtySpawn?.('pty-resume')
|
||||
onPtySpawn?.('pty-resume')
|
||||
|
||||
expect(onStartupBound).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('seeds a working status for Command Code startup prompts after spawn', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const sshPtyId = toAppSshPtyId('ssh-a', 'pty-command-code')
|
||||
|
||||
@@ -1202,6 +1202,7 @@ export function connectPanePty(
|
||||
// mutation does not propagate back.
|
||||
const paneStartup = deps.startup ?? null
|
||||
deps.startup = undefined
|
||||
let startupPtyBound = false
|
||||
|
||||
// Why: paneKey crosses PTY env, hook IPC, retained rows, and reload/replay.
|
||||
// Use the stable layout leaf UUID, not the renderer-local numeric pane id.
|
||||
@@ -3023,6 +3024,10 @@ export function connectPanePty(
|
||||
deps.updateTabPtyId(deps.tabId, ptyId)
|
||||
}
|
||||
}
|
||||
if (paneStartup && !startupPtyBound) {
|
||||
startupPtyBound = true
|
||||
deps.onStartupBound?.()
|
||||
}
|
||||
if (options.seedInitialAgentStatus) {
|
||||
applyInitialAgentStatus()
|
||||
}
|
||||
|
||||
@@ -317,6 +317,8 @@ type UseTerminalPaneLifecycleDeps = {
|
||||
setCacheTimerStartedAt: (key: string, ts: number | null) => void
|
||||
syncPanePtyLayoutBinding: (paneId: number, ptyId: string | null) => void
|
||||
clearExitedPanePtyLayoutBinding: (paneId: number, exitedPtyId: string) => void
|
||||
/** Settles the captured one-shot startup only after a pane owns a concrete PTY. */
|
||||
onStartupBound?: () => void
|
||||
setTabPaneExpanded: (tabId: string, expanded: boolean) => void
|
||||
setTabCanExpandPane: (tabId: string, canExpand: boolean) => void
|
||||
setExpandedPane: (paneId: number | null) => void
|
||||
@@ -640,6 +642,7 @@ export function useTerminalPaneLifecycle({
|
||||
setCacheTimerStartedAt,
|
||||
syncPanePtyLayoutBinding,
|
||||
clearExitedPanePtyLayoutBinding,
|
||||
onStartupBound,
|
||||
setTabPaneExpanded,
|
||||
setTabCanExpandPane,
|
||||
setExpandedPane,
|
||||
@@ -871,6 +874,7 @@ export function useTerminalPaneLifecycle({
|
||||
setCacheTimerStartedAt,
|
||||
syncPanePtyLayoutBinding,
|
||||
clearExitedPanePtyLayoutBinding,
|
||||
onStartupBound,
|
||||
deferPtyInput: (paneId, data, forward) => {
|
||||
const suppression = httpLinkClickFallbackDisposables.get(paneId)?.ptyMouseSuppression
|
||||
if (!suppression) {
|
||||
|
||||
@@ -34,6 +34,48 @@ function makeTerminalTab(id: string): Record<string, unknown> {
|
||||
}
|
||||
|
||||
describe('resumeSleepingAgentSessionsForWorktree replay protection', () => {
|
||||
it('publishes the resume command and ownership claim with the first visible tab state', () => {
|
||||
const record = makeRecord()
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: { 'wt-1': [] },
|
||||
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
|
||||
} as never)
|
||||
let firstVisible:
|
||||
| {
|
||||
command: string | undefined
|
||||
claim: unknown
|
||||
layout: ReturnType<typeof useAppStore.getState>['terminalLayoutsByTabId'][string]
|
||||
}
|
||||
| undefined
|
||||
const unsubscribe = useAppStore.subscribe((state) => {
|
||||
const tab = state.tabsByWorktree['wt-1']?.[0]
|
||||
if (tab && !firstVisible) {
|
||||
firstVisible = {
|
||||
command: state.pendingStartupByTabId[tab.id]?.command,
|
||||
claim: state.automaticAgentResumeClaimsByTabId[tab.id],
|
||||
layout: state.terminalLayoutsByTabId[tab.id]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(1)
|
||||
unsubscribe()
|
||||
|
||||
expect(firstVisible?.command).toContain('resume')
|
||||
expect(firstVisible?.command).toContain(record.providerSession.id)
|
||||
expect(firstVisible?.claim).toEqual({
|
||||
worktreeId: record.worktreeId,
|
||||
launchAgent: record.agent,
|
||||
providerSession: record.providerSession
|
||||
})
|
||||
expect(firstVisible?.layout).toMatchObject({
|
||||
root: { type: 'leaf', leafId: expect.any(String) },
|
||||
activeLeafId: expect.any(String)
|
||||
})
|
||||
const root = firstVisible?.layout?.root
|
||||
expect(firstVisible?.layout?.activeLeafId).toBe(root?.type === 'leaf' ? root.leafId : undefined)
|
||||
})
|
||||
|
||||
it('stores provider-session metadata in the queued startup and runtime claim', () => {
|
||||
const record = makeRecord()
|
||||
useAppStore.setState({
|
||||
|
||||
@@ -6,7 +6,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
|
||||
|
||||
const mockCreateTab = vi.fn()
|
||||
const mockQueueTabStartupCommand = vi.fn()
|
||||
|
||||
const store = {
|
||||
settings: {
|
||||
@@ -47,7 +46,6 @@ const store = {
|
||||
browserTabsByWorktree: {} as Record<string, { id: string }[]>,
|
||||
tabBarOrderByWorktree: {} as Record<string, string[]>,
|
||||
createTab: mockCreateTab,
|
||||
queueTabStartupCommand: mockQueueTabStartupCommand,
|
||||
claimAutomaticAgentResume: vi.fn(),
|
||||
clearSleepingAgentSession: vi.fn(),
|
||||
setActiveTabType: vi.fn(),
|
||||
@@ -83,10 +81,10 @@ const record: SleepingAgentSessionRecord = {
|
||||
async function launch(): Promise<string | undefined> {
|
||||
const { launchSleepingAgentSession } = await import('./sleeping-agent-session-launch')
|
||||
launchSleepingAgentSession(record)
|
||||
const queued = mockQueueTabStartupCommand.mock.calls.at(-1)?.[1] as
|
||||
| { command: string }
|
||||
const options = mockCreateTab.mock.calls.at(-1)?.[3] as
|
||||
| { pendingStartup?: { command: string } }
|
||||
| undefined
|
||||
return queued?.command
|
||||
return options?.pendingStartup?.command
|
||||
}
|
||||
|
||||
describe('launchSleepingAgentSession Windows shell quoting', () => {
|
||||
|
||||
@@ -100,30 +100,30 @@ export function launchSleepingAgentSession(
|
||||
|
||||
const tab = state.createTab(record.worktreeId, undefined, undefined, {
|
||||
launchAgent: record.agent,
|
||||
pendingStartup: {
|
||||
command: startupPlan.launchCommand,
|
||||
...(startupPlan.env ? { env: startupPlan.env } : {}),
|
||||
launchConfig: startupPlan.launchConfig,
|
||||
resumeProviderSession: record.providerSession,
|
||||
launchAgent: record.agent,
|
||||
...(launchConfig ? { agentArgsOverride: launchConfig.agentArgs } : {}),
|
||||
...(startupPlan.startupCommandDelivery
|
||||
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
|
||||
: {}),
|
||||
showSessionRestoredBanner: true,
|
||||
telemetry: {
|
||||
agent_kind: tuiAgentToAgentKind(record.agent),
|
||||
launch_source: 'sidebar',
|
||||
request_kind: 'resume'
|
||||
}
|
||||
},
|
||||
automaticResumeClaim: {
|
||||
worktreeId: record.worktreeId,
|
||||
launchAgent: record.agent,
|
||||
providerSession: record.providerSession
|
||||
},
|
||||
...(options?.suppressNavigation ? { activate: false, recordInteraction: false } : {})
|
||||
})
|
||||
state.queueTabStartupCommand(tab.id, {
|
||||
command: startupPlan.launchCommand,
|
||||
...(startupPlan.env ? { env: startupPlan.env } : {}),
|
||||
launchConfig: startupPlan.launchConfig,
|
||||
resumeProviderSession: record.providerSession,
|
||||
launchAgent: record.agent,
|
||||
...(launchConfig ? { agentArgsOverride: launchConfig.agentArgs } : {}),
|
||||
...(startupPlan.startupCommandDelivery
|
||||
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
|
||||
: {}),
|
||||
showSessionRestoredBanner: true,
|
||||
telemetry: {
|
||||
agent_kind: tuiAgentToAgentKind(record.agent),
|
||||
launch_source: 'sidebar',
|
||||
request_kind: 'resume'
|
||||
}
|
||||
})
|
||||
state.claimAutomaticAgentResume(tab.id, {
|
||||
worktreeId: record.worktreeId,
|
||||
launchAgent: record.agent,
|
||||
providerSession: record.providerSession
|
||||
})
|
||||
state.clearSleepingAgentSession(record.paneKey)
|
||||
if (!options?.suppressNavigation) {
|
||||
state.setActiveTabType('terminal')
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
|
||||
import type { PtyListedSession } from '../../../shared/pty-listed-session'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
|
||||
import type { TerminalTab } from '../../../shared/types'
|
||||
import type { TerminalSlice } from '@/store/slices/terminals'
|
||||
import { runWorktreeAgentActivationGate } from './worktree-agent-activation-gate'
|
||||
|
||||
const WORKTREE_ID = 'repo::/worktree'
|
||||
@@ -72,16 +74,41 @@ function testDeps(args: {
|
||||
structured?: boolean
|
||||
resumeCount?: number
|
||||
}) {
|
||||
const createTab = vi.fn()
|
||||
const resume = vi.fn(() => args.resumeCount ?? 1)
|
||||
const sleeping = args.sleeping ?? []
|
||||
const ptyIdsByTabId: Record<string, string[]> = {}
|
||||
const tabsByWorktree: Record<string, TerminalTab[]> = { [WORKTREE_ID]: [] }
|
||||
let createdCount = 0
|
||||
const createTab: TerminalSlice['createTab'] = vi.fn((worktreeId, _group, _shell, options) => {
|
||||
createdCount += 1
|
||||
const id = options?.id ?? `created-${createdCount}`
|
||||
const tab: TerminalTab = {
|
||||
id,
|
||||
ptyId: options?.initialPtyId ?? null,
|
||||
worktreeId,
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: tabsByWorktree[worktreeId]?.length ?? 0,
|
||||
createdAt: 1
|
||||
}
|
||||
tabsByWorktree[worktreeId] ??= []
|
||||
tabsByWorktree[worktreeId].push(tab)
|
||||
ptyIdsByTabId[tab.id] = tab.ptyId ? [tab.ptyId] : []
|
||||
return tab
|
||||
})
|
||||
const updateTabPtyId = vi.fn((tabId: string, ptyId: string) => {
|
||||
ptyIdsByTabId[tabId] = [...new Set([...(ptyIdsByTabId[tabId] ?? []), ptyId])]
|
||||
})
|
||||
const store = {
|
||||
createTab,
|
||||
ptyIdsByTabId,
|
||||
tabsByWorktree,
|
||||
sleepingAgentSessionsByPaneKey: Object.fromEntries(
|
||||
sleeping.map((record) => [record.paneKey, record])
|
||||
),
|
||||
updateTabPtyId,
|
||||
replaceTerminalLayoutPanePtyId: vi.fn(),
|
||||
terminalLayoutsByTabId: Object.fromEntries(
|
||||
sleeping.map((record) => {
|
||||
const leafId = record.paneKey.slice(record.paneKey.indexOf(':') + 1)
|
||||
@@ -158,6 +185,18 @@ describe('worktree agent activation gate', () => {
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads structured ownership before adopting daemon PTYs', async () => {
|
||||
const ptyId = `${WORKTREE_ID}@@live-pty`
|
||||
const { deps } = testDeps({ sessions: [listed(ptyId)] })
|
||||
const hasStructuredSession = vi.fn(async () => false)
|
||||
|
||||
await runWorktreeAgentActivationGate(WORKTREE_ID, { ...deps, hasStructuredSession })
|
||||
|
||||
expect(hasStructuredSession.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
deps.listSessions.mock.invocationCallOrder[0] ?? Infinity
|
||||
)
|
||||
})
|
||||
|
||||
it('blocks automatic resume when packaged restore never becomes ready', async () => {
|
||||
const { deps, createTab, resume } = testDeps({})
|
||||
|
||||
@@ -262,11 +301,17 @@ describe('worktree agent activation gate', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the exact runtime surface while packaged renderer PTY bindings are absent', async () => {
|
||||
it('adopts the exact structured TUI surface without creating a duplicate tab', async () => {
|
||||
const tabId = 'structured-agent-session-live-session'
|
||||
const live = sleepingRecord(tabId, LIVE_LEAF_ID, 'live-session')
|
||||
const livePtyId = `${WORKTREE_ID}@@live-agent`
|
||||
const { deps, resume } = testDeps({ sessions: [listed(livePtyId)], sleeping: [live] })
|
||||
const { deps, createTab, resume } = testDeps({
|
||||
sessions: [listed(livePtyId)],
|
||||
sleeping: [live],
|
||||
resumeCount: 0
|
||||
})
|
||||
const ownerTabId = '3359f7c8-9bd8-4931-8104-52b6bdbd108d'
|
||||
const ownerLeafId = '2659ee80-d3fc-454f-b4ea-0638de1ae345'
|
||||
|
||||
await expect(
|
||||
runWorktreeAgentActivationGate(WORKTREE_ID, {
|
||||
@@ -279,18 +324,25 @@ describe('worktree agent activation gate', () => {
|
||||
{
|
||||
owner: 'tui',
|
||||
terminal: {
|
||||
paneKey:
|
||||
'3359f7c8-9bd8-4931-8104-52b6bdbd108d:2659ee80-d3fc-454f-b4ea-0638de1ae345',
|
||||
paneKey: `${ownerTabId}:${ownerLeafId}`,
|
||||
ptyId: livePtyId,
|
||||
tabId: '3359f7c8-9bd8-4931-8104-52b6bdbd108d'
|
||||
tabId: ownerTabId
|
||||
}
|
||||
}
|
||||
]
|
||||
])
|
||||
})
|
||||
})
|
||||
).resolves.toBe('resumed')
|
||||
).resolves.toBe('adopted')
|
||||
|
||||
expect(createTab).toHaveBeenCalledOnce()
|
||||
expect(createTab).toHaveBeenCalledWith(WORKTREE_ID, undefined, undefined, {
|
||||
id: ownerTabId,
|
||||
initialLeafId: ownerLeafId,
|
||||
initialPtyId: livePtyId,
|
||||
activate: false,
|
||||
recordInteraction: false
|
||||
})
|
||||
expect(resume).toHaveBeenCalledWith(WORKTREE_ID, {
|
||||
skipClaimKeys: new Set([`${WORKTREE_ID}\0codex\0session_id\0live-session`])
|
||||
})
|
||||
@@ -307,7 +359,7 @@ describe('worktree agent activation gate', () => {
|
||||
expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { skipClaimKeys: new Set() })
|
||||
})
|
||||
|
||||
it('does not claim a structured owner whose PTY is absent from live inventory', async () => {
|
||||
it('blocks when a structured TUI owner is absent from live inventory', async () => {
|
||||
const tabId = 'structured-agent-session-live-session'
|
||||
const live = sleepingRecord(tabId, LIVE_LEAF_ID, 'live-session')
|
||||
const livePtyId = `${WORKTREE_ID}@@live-agent`
|
||||
@@ -333,12 +385,12 @@ describe('worktree agent activation gate', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
).resolves.toBe('resumed')
|
||||
).resolves.toBe('blocked')
|
||||
|
||||
expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { skipClaimKeys: new Set() })
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not claim an owner for a different structured session tab', async () => {
|
||||
it('uses the restored owner surface even when its tab predates structured naming', async () => {
|
||||
const tabId = 'structured-agent-session-other-session'
|
||||
const live = sleepingRecord(tabId, LIVE_LEAF_ID, 'live-session')
|
||||
const livePtyId = `${WORKTREE_ID}@@live-agent`
|
||||
@@ -362,7 +414,9 @@ describe('worktree agent activation gate', () => {
|
||||
})
|
||||
).resolves.toBe('resumed')
|
||||
|
||||
expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { skipClaimKeys: new Set() })
|
||||
expect(resume).toHaveBeenCalledWith(WORKTREE_ID, {
|
||||
skipClaimKeys: new Set([`${WORKTREE_ID}\0codex\0session_id\0live-session`])
|
||||
})
|
||||
})
|
||||
|
||||
it('suppresses the exact structured session when native already owns it', async () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import type { PtyListedSession } from '../../../shared/pty-listed-session'
|
||||
import { parsePtySessionId, PTY_SESSION_ID_SEPARATOR } from '../../../shared/pty-session-id-format'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
|
||||
import { parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
@@ -10,27 +9,24 @@ import {
|
||||
type ResumeSleepingAgentSessionsOptions
|
||||
} from './resume-sleeping-agent-session'
|
||||
import { getProviderSessionClaimKey } from './sleeping-agent-pane-ownership'
|
||||
import { bindLivePtyToExactSurface } from './worktree-agent-live-surface-adoption'
|
||||
import {
|
||||
readWorktreeStructuredActivationInventory,
|
||||
type StructuredActivationInventory
|
||||
} from './worktree-agent-structured-inventory'
|
||||
|
||||
type ActivationStore = Pick<
|
||||
ReturnType<typeof useAppStore.getState>,
|
||||
| 'createTab'
|
||||
| 'ptyIdsByTabId'
|
||||
| 'sleepingAgentSessionsByPaneKey'
|
||||
| 'tabsByWorktree'
|
||||
| 'terminalLayoutsByTabId'
|
||||
| 'unifiedTabsByWorktree'
|
||||
| 'updateTabPtyId'
|
||||
| 'replaceTerminalLayoutPanePtyId'
|
||||
>
|
||||
|
||||
type StructuredActivationInventory = {
|
||||
snapshot: RuntimeMobileSessionTabsResult
|
||||
ownerBySessionId: ReadonlyMap<
|
||||
string,
|
||||
{
|
||||
owner: 'native' | 'tui'
|
||||
terminal?: { paneKey: string; ptyId: string; tabId: string }
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
type ActivationGateDeps = {
|
||||
getState: () => ActivationStore
|
||||
awaitReady?: () => Promise<boolean>
|
||||
@@ -45,7 +41,11 @@ const inFlightByWorktreeId = new Map<string, Promise<WorktreeAgentActivationOutc
|
||||
const WORKSPACE_SESSION_READY_TIMEOUT_MS = 30_000
|
||||
|
||||
function waitForWorkspaceSessionReady(): Promise<boolean> {
|
||||
if (useAppStore.getState().workspaceSessionReady) {
|
||||
const isReady = () => {
|
||||
const state = useAppStore.getState()
|
||||
return state.workspaceSessionReady && state.terminalStartupRestorationReady
|
||||
}
|
||||
if (isReady()) {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
@@ -55,16 +55,13 @@ function waitForWorkspaceSessionReady(): Promise<boolean> {
|
||||
unsubscribe?.()
|
||||
resolve(ready)
|
||||
}
|
||||
const timeout = setTimeout(
|
||||
() => settle(useAppStore.getState().workspaceSessionReady),
|
||||
WORKSPACE_SESSION_READY_TIMEOUT_MS
|
||||
)
|
||||
const timeout = setTimeout(() => settle(isReady()), WORKSPACE_SESSION_READY_TIMEOUT_MS)
|
||||
unsubscribe = useAppStore.subscribe((state) => {
|
||||
if (state.workspaceSessionReady) {
|
||||
if (state.workspaceSessionReady && state.terminalStartupRestorationReady) {
|
||||
settle(true)
|
||||
}
|
||||
})
|
||||
if (useAppStore.getState().workspaceSessionReady) {
|
||||
if (isReady()) {
|
||||
settle(true)
|
||||
}
|
||||
})
|
||||
@@ -152,6 +149,42 @@ export async function runWorktreeAgentActivationGate(
|
||||
} catch {
|
||||
return 'blocked'
|
||||
}
|
||||
let structured = false
|
||||
let structuredInventory: StructuredActivationInventory | null = null
|
||||
try {
|
||||
const reportedStructuredSession = await deps.hasStructuredSession?.(worktreeId)
|
||||
structuredInventory =
|
||||
typeof reportedStructuredSession === 'object' ? reportedStructuredSession : null
|
||||
structured = Boolean(
|
||||
hasStructuredSession(deps.getState(), worktreeId) || reportedStructuredSession
|
||||
)
|
||||
} catch {
|
||||
return 'blocked'
|
||||
}
|
||||
|
||||
const structuredTabs = structuredInventory?.snapshot.tabs.filter(
|
||||
(tab) => tab.type === 'agent-session'
|
||||
)
|
||||
if (
|
||||
structuredTabs?.some((tab) => {
|
||||
const owner = structuredInventory?.ownerBySessionId.get(tab.sessionId)
|
||||
return (
|
||||
!owner ||
|
||||
(owner.owner === 'tui' &&
|
||||
(!owner.terminal || parsePaneKey(owner.terminal.paneKey)?.tabId !== owner.terminal.tabId))
|
||||
)
|
||||
})
|
||||
) {
|
||||
return 'blocked'
|
||||
}
|
||||
if (
|
||||
structured &&
|
||||
!structuredInventory &&
|
||||
workspaceHasSleepingAgentSessions(deps.getState(), worktreeId)
|
||||
) {
|
||||
return 'blocked'
|
||||
}
|
||||
|
||||
let sessions: PtyListedSession[]
|
||||
try {
|
||||
sessions = await deps.listSessions()
|
||||
@@ -164,6 +197,18 @@ export async function runWorktreeAgentActivationGate(
|
||||
sessionBelongsToWorkspace(session.id, worktreeId)
|
||||
)
|
||||
const liveWorkspacePtyIds = new Set(liveWorkspaceSessions.map((session) => session.id))
|
||||
for (const owner of structuredInventory?.ownerBySessionId.values() ?? []) {
|
||||
if (owner.owner !== 'tui') {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
!owner.terminal ||
|
||||
!liveWorkspacePtyIds.has(owner.terminal.ptyId) ||
|
||||
!bindLivePtyToExactSurface(deps.getState(), worktreeId, owner.terminal)
|
||||
) {
|
||||
return 'blocked'
|
||||
}
|
||||
}
|
||||
if (liveWorkspaceSessions.length > 0) {
|
||||
for (const session of liveWorkspaceSessions) {
|
||||
const store = deps.getState()
|
||||
@@ -181,20 +226,8 @@ export async function runWorktreeAgentActivationGate(
|
||||
}
|
||||
}
|
||||
|
||||
let structured = false
|
||||
let structuredInventory: StructuredActivationInventory | null = null
|
||||
try {
|
||||
const reportedStructuredSession = await deps.hasStructuredSession?.(worktreeId)
|
||||
structuredInventory =
|
||||
typeof reportedStructuredSession === 'object' ? reportedStructuredSession : null
|
||||
structured = Boolean(
|
||||
hasStructuredSession(deps.getState(), worktreeId) || reportedStructuredSession
|
||||
)
|
||||
if (structured && !workspaceHasSleepingAgentSessions(deps.getState(), worktreeId)) {
|
||||
return 'structured'
|
||||
}
|
||||
} catch {
|
||||
return 'blocked'
|
||||
if (structured && !workspaceHasSleepingAgentSessions(deps.getState(), worktreeId)) {
|
||||
return 'structured'
|
||||
}
|
||||
const launched = deps.resume(worktreeId, {
|
||||
skipClaimKeys: liveSleepingAgentClaimKeys(
|
||||
@@ -225,76 +258,7 @@ export function gateWorktreeAgentActivation(
|
||||
awaitReady: waitForWorkspaceSessionReady,
|
||||
listSessions: () =>
|
||||
typeof window === 'undefined' ? Promise.resolve([]) : window.api.pty.listSessions(),
|
||||
hasStructuredSession: async (candidate) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
const response = await window.api.runtime.call({ method: 'session.tabs.listAll', params: {} })
|
||||
if (!response.ok) {
|
||||
throw new Error('structured session inventory unavailable')
|
||||
}
|
||||
const result = response.result as { snapshots?: RuntimeMobileSessionTabsResult[] }
|
||||
const snapshot = (result.snapshots ?? []).find(
|
||||
(candidateSnapshot) =>
|
||||
candidateSnapshot.worktree === candidate &&
|
||||
candidateSnapshot.tabs.some((tab) => tab.type === 'agent-session')
|
||||
)
|
||||
if (!snapshot) {
|
||||
return false
|
||||
}
|
||||
const ownerBySessionId = new Map<
|
||||
string,
|
||||
{
|
||||
owner: 'native' | 'tui'
|
||||
terminal?: { paneKey: string; ptyId: string; tabId: string }
|
||||
}
|
||||
>()
|
||||
await Promise.all(
|
||||
snapshot.tabs.flatMap((tab) =>
|
||||
tab.type === 'agent-session'
|
||||
? [
|
||||
window.api.runtime
|
||||
.call({
|
||||
method: 'agentSession.handoffStatus',
|
||||
params: { sessionId: tab.sessionId }
|
||||
})
|
||||
.then((statusResponse) => {
|
||||
if (!statusResponse.ok) {
|
||||
return
|
||||
}
|
||||
const status = statusResponse.result as {
|
||||
owner?: unknown
|
||||
terminal?: {
|
||||
paneKey?: unknown
|
||||
ptyId?: unknown
|
||||
tabId?: unknown
|
||||
}
|
||||
}
|
||||
if (status.owner === 'native') {
|
||||
ownerBySessionId.set(tab.sessionId, { owner: 'native' })
|
||||
} else if (
|
||||
status.owner === 'tui' &&
|
||||
typeof status.terminal?.paneKey === 'string' &&
|
||||
typeof status.terminal?.ptyId === 'string' &&
|
||||
status.terminal.ptyId.length > 0 &&
|
||||
typeof status.terminal.tabId === 'string'
|
||||
) {
|
||||
ownerBySessionId.set(tab.sessionId, {
|
||||
owner: 'tui',
|
||||
terminal: {
|
||||
paneKey: status.terminal.paneKey,
|
||||
ptyId: status.terminal.ptyId,
|
||||
tabId: status.terminal.tabId
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
]
|
||||
: []
|
||||
)
|
||||
)
|
||||
return { snapshot, ownerBySessionId }
|
||||
},
|
||||
hasStructuredSession: readWorktreeStructuredActivationInventory,
|
||||
resume: resumeSleepingAgentSessionsForWorktree
|
||||
}).finally(() => {
|
||||
if (inFlightByWorktreeId.get(worktreeId) === gate) {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import type { useAppStore } from '@/store'
|
||||
|
||||
type LiveSurfaceAdoptionStore = Pick<
|
||||
ReturnType<typeof useAppStore.getState>,
|
||||
| 'createTab'
|
||||
| 'ptyIdsByTabId'
|
||||
| 'tabsByWorktree'
|
||||
| 'terminalLayoutsByTabId'
|
||||
| 'updateTabPtyId'
|
||||
| 'replaceTerminalLayoutPanePtyId'
|
||||
>
|
||||
|
||||
function layoutContainsLeaf(
|
||||
root: LiveSurfaceAdoptionStore['terminalLayoutsByTabId'][string]['root'],
|
||||
leafId: string
|
||||
): boolean {
|
||||
if (!root) {
|
||||
return false
|
||||
}
|
||||
return root.type === 'leaf'
|
||||
? root.leafId === leafId
|
||||
: layoutContainsLeaf(root.first, leafId) || layoutContainsLeaf(root.second, leafId)
|
||||
}
|
||||
|
||||
export function bindLivePtyToExactSurface(
|
||||
store: LiveSurfaceAdoptionStore,
|
||||
worktreeId: string,
|
||||
terminal: { paneKey: string; ptyId: string; tabId: string }
|
||||
): boolean {
|
||||
const pane = parsePaneKey(terminal.paneKey)
|
||||
if (!pane || pane.tabId !== terminal.tabId) {
|
||||
return false
|
||||
}
|
||||
const ownerEntries = Object.entries(store.tabsByWorktree).flatMap(([ownerWorktreeId, tabs]) =>
|
||||
tabs.filter((tab) => tab.id === terminal.tabId).map((tab) => ({ ownerWorktreeId, tab }))
|
||||
)
|
||||
const competingBinding = Object.entries(store.ptyIdsByTabId).some(
|
||||
([tabId, ptyIds]) => tabId !== terminal.tabId && ptyIds.includes(terminal.ptyId)
|
||||
)
|
||||
if (ownerEntries.length > 1 || competingBinding) {
|
||||
return false
|
||||
}
|
||||
const existing = ownerEntries[0]
|
||||
if (existing) {
|
||||
const layout = store.terminalLayoutsByTabId[terminal.tabId]
|
||||
if (
|
||||
existing.ownerWorktreeId !== worktreeId ||
|
||||
!layoutContainsLeaf(layout?.root ?? null, pane.leafId)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
store.updateTabPtyId(terminal.tabId, terminal.ptyId)
|
||||
store.replaceTerminalLayoutPanePtyId(terminal.tabId, pane.leafId, terminal.ptyId)
|
||||
return true
|
||||
}
|
||||
const created = store.createTab(worktreeId, undefined, undefined, {
|
||||
id: terminal.tabId,
|
||||
initialLeafId: pane.leafId,
|
||||
initialPtyId: terminal.ptyId,
|
||||
activate: false,
|
||||
recordInteraction: false
|
||||
})
|
||||
return created.id === terminal.tabId
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
|
||||
|
||||
export type StructuredActivationInventory = {
|
||||
snapshot: RuntimeMobileSessionTabsResult
|
||||
ownerBySessionId: ReadonlyMap<
|
||||
string,
|
||||
{
|
||||
owner: 'native' | 'tui'
|
||||
terminal?: { paneKey: string; ptyId: string; tabId: string }
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
export async function readWorktreeStructuredActivationInventory(
|
||||
worktreeId: string
|
||||
): Promise<false | StructuredActivationInventory> {
|
||||
if (typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
const response = await window.api.runtime.call({ method: 'session.tabs.listAll', params: {} })
|
||||
if (!response.ok) {
|
||||
throw new Error('structured session inventory unavailable')
|
||||
}
|
||||
const result = response.result as { snapshots?: RuntimeMobileSessionTabsResult[] }
|
||||
const snapshot = (result.snapshots ?? []).find(
|
||||
(candidate) =>
|
||||
candidate.worktree === worktreeId &&
|
||||
candidate.tabs.some((tab) => tab.type === 'agent-session')
|
||||
)
|
||||
if (!snapshot) {
|
||||
return false
|
||||
}
|
||||
const ownerBySessionId = new Map<
|
||||
string,
|
||||
{
|
||||
owner: 'native' | 'tui'
|
||||
terminal?: { paneKey: string; ptyId: string; tabId: string }
|
||||
}
|
||||
>()
|
||||
await Promise.all(
|
||||
snapshot.tabs.flatMap((tab) =>
|
||||
tab.type === 'agent-session'
|
||||
? [
|
||||
window.api.runtime
|
||||
.call({
|
||||
method: 'agentSession.handoffStatus',
|
||||
params: { sessionId: tab.sessionId }
|
||||
})
|
||||
.then((statusResponse) => {
|
||||
if (!statusResponse.ok) {
|
||||
return
|
||||
}
|
||||
const status = statusResponse.result as {
|
||||
owner?: unknown
|
||||
terminal?: { paneKey?: unknown; ptyId?: unknown; tabId?: unknown }
|
||||
}
|
||||
if (status.owner === 'native') {
|
||||
ownerBySessionId.set(tab.sessionId, { owner: 'native' })
|
||||
} else if (
|
||||
status.owner === 'tui' &&
|
||||
typeof status.terminal?.paneKey === 'string' &&
|
||||
typeof status.terminal?.ptyId === 'string' &&
|
||||
status.terminal.ptyId.length > 0 &&
|
||||
typeof status.terminal.tabId === 'string'
|
||||
) {
|
||||
ownerBySessionId.set(tab.sessionId, {
|
||||
owner: 'tui',
|
||||
terminal: {
|
||||
paneKey: status.terminal.paneKey,
|
||||
ptyId: status.terminal.ptyId,
|
||||
tabId: status.terminal.tabId
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
]
|
||||
: []
|
||||
)
|
||||
)
|
||||
return { snapshot, ownerBySessionId }
|
||||
}
|
||||
@@ -31,6 +31,7 @@ function baseState(worktree: ReturnType<typeof makeWorktree>): Partial<AppState>
|
||||
activeRepoId: 'repo-1',
|
||||
activeView: 'terminal',
|
||||
workspaceSessionReady: true,
|
||||
terminalStartupRestorationReady: true,
|
||||
tabsByWorktree: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
|
||||
@@ -22,6 +22,7 @@ function baseState(worktree: ReturnType<typeof makeWorktree>): Partial<AppState>
|
||||
activeRepoId: 'repo-1',
|
||||
activeView: 'terminal',
|
||||
workspaceSessionReady: true,
|
||||
terminalStartupRestorationReady: true,
|
||||
tabsByWorktree: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
@@ -57,7 +58,11 @@ afterEach(() => {
|
||||
describe('STA-1111 worktree reopen does not fork-bomb tabs', () => {
|
||||
it('defers packaged-startup resume until restored PTYs finish reconnecting', async () => {
|
||||
const worktree = { ...makeWorktree(), createdWithAgent: undefined }
|
||||
useAppStore.setState({ ...baseState(worktree), workspaceSessionReady: false })
|
||||
useAppStore.setState({
|
||||
...baseState(worktree),
|
||||
workspaceSessionReady: false,
|
||||
terminalStartupRestorationReady: false
|
||||
})
|
||||
const paneKey = 'packaged-restart-pane:0'
|
||||
useAppStore.setState({
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
@@ -96,7 +101,10 @@ describe('STA-1111 worktree reopen does not fork-bomb tabs', () => {
|
||||
await Promise.resolve()
|
||||
expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0)
|
||||
|
||||
useAppStore.setState({ workspaceSessionReady: true })
|
||||
useAppStore.setState({
|
||||
workspaceSessionReady: true,
|
||||
terminalStartupRestorationReady: true
|
||||
})
|
||||
await gate
|
||||
const restored = useAppStore.getState()
|
||||
expect(restored.tabsByWorktree[worktree.id]).toHaveLength(1)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAppStore } from '../store'
|
||||
import { applyWebSessionTabsSnapshot, applyWebSessionTabsStorePatch } from './web-session-tabs-sync'
|
||||
|
||||
const LOCAL_STRUCTURED_SESSION_OWNER = 'local-structured-session'
|
||||
let localStructuredSessionTabsRestorePromise: Promise<void> | null = null
|
||||
|
||||
type SessionTabsEvent =
|
||||
| (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' })
|
||||
@@ -54,6 +55,23 @@ export function applyStructuredSessionTabSnapshots(
|
||||
})
|
||||
}
|
||||
|
||||
export function restoreLocalStructuredSessionTabsOnce(): Promise<void> {
|
||||
localStructuredSessionTabsRestorePromise ??= window.api.runtime
|
||||
.call({ method: 'session.tabs.listAll', params: {} })
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('structured session inventory unavailable')
|
||||
}
|
||||
const result = response.result as { snapshots?: RuntimeMobileSessionTabsResult[] }
|
||||
applyStructuredSessionTabSnapshots(result.snapshots ?? [])
|
||||
})
|
||||
.catch((error) => {
|
||||
localStructuredSessionTabsRestorePromise = null
|
||||
throw error
|
||||
})
|
||||
return localStructuredSessionTabsRestorePromise
|
||||
}
|
||||
|
||||
async function startLocalStructuredSessionTabsSync(args: {
|
||||
isDisposed: () => boolean
|
||||
setUnsubscribe: (unsubscribe: () => void) => void
|
||||
@@ -63,12 +81,10 @@ async function startLocalStructuredSessionTabsSync(args: {
|
||||
return
|
||||
}
|
||||
const supported = status.capabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)
|
||||
void window.api.runtime.call({ method: 'session.tabs.listAll', params: {} }).then((response) => {
|
||||
if (!args.isDisposed() && response.ok) {
|
||||
const result = response.result as { snapshots?: RuntimeMobileSessionTabsResult[] }
|
||||
applyStructuredSessionTabSnapshots(result.snapshots ?? [])
|
||||
}
|
||||
})
|
||||
await restoreLocalStructuredSessionTabsOnce()
|
||||
if (args.isDisposed()) {
|
||||
return
|
||||
}
|
||||
if (!supported) {
|
||||
return
|
||||
}
|
||||
@@ -94,7 +110,9 @@ async function startLocalStructuredSessionTabsSync(args: {
|
||||
}
|
||||
|
||||
export function useLocalStructuredSessionTabsSync(): void {
|
||||
const ready = useAppStore((state) => state.workspaceSessionReady)
|
||||
const ready = useAppStore(
|
||||
(state) => state.workspaceSessionReady && state.terminalStartupRestorationReady
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!ready) {
|
||||
return
|
||||
|
||||
@@ -57,7 +57,11 @@ import { forgetAgentHibernationTabOutput } from '@/lib/agent-hibernation-output-
|
||||
import { forgetForegroundTerminalTabs } from '@/lib/foreground-terminal-tabs'
|
||||
import { terminalLayoutEqual } from '@/lib/terminal-layout-equality'
|
||||
import { forgetAgentStartupDeliveriesForTabs } from '@/lib/agent-startup-delivery-guards'
|
||||
import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers'
|
||||
import {
|
||||
clearTransientTerminalState,
|
||||
emptyLayoutSnapshot,
|
||||
singlePaneLayoutSnapshot
|
||||
} from './terminal-helpers'
|
||||
import {
|
||||
collectReleasedLeafIds,
|
||||
hydrateWorkspaceTerminalRows,
|
||||
@@ -529,6 +533,40 @@ export type AutomaticAgentResumeClaim = {
|
||||
providerSession: AgentProviderSessionMetadata
|
||||
}
|
||||
|
||||
type TabStartupCommand = {
|
||||
command: string
|
||||
/** Renderer-delivered startup input for callers needing xterm paste semantics before the submit Enter. */
|
||||
delivery?: 'terminal-paste'
|
||||
startupCommandDelivery?: StartupCommandDelivery
|
||||
env?: Record<string, string>
|
||||
envToDelete?: string[]
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
resumeProviderSession?: AgentProviderSessionMetadata
|
||||
launchToken?: string
|
||||
launchAgent?: TuiAgent
|
||||
/** Explicit CLI override for host-owned agent launches; omission uses host settings. */
|
||||
agentArgsOverride?: string | null
|
||||
draftPrompt?: string
|
||||
sessionOptions?: Record<string, SessionOptionValue>
|
||||
/** Initial prompt-start status for agents that lack native prompt hooks. */
|
||||
initialAgentStatus?: { agent: TuiAgent; prompt: string }
|
||||
/** Show the restored-session banner when this startup command mounts. */
|
||||
showSessionRestoredBanner?: boolean
|
||||
/** Telemetry for the `agent_started` event; threaded to the pty:spawn handler so it fires only after spawn confirms, not on click-intent. */
|
||||
telemetry?: AgentStartedTelemetry
|
||||
}
|
||||
|
||||
function normalizeTabStartupCommand(startup: TabStartupCommand): TabStartupCommand {
|
||||
// Why: launchToken is only meaningful for tracked launch-config reuse; plain startup commands must not mint a synthetic token.
|
||||
const launchToken = startup.launchConfig
|
||||
? (startup.launchToken ?? createBrowserUuid())
|
||||
: undefined
|
||||
return {
|
||||
...startup,
|
||||
...(launchToken ? { launchToken } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export type CodexRestartNotice = {
|
||||
previousAccountLabel: string
|
||||
nextAccountLabel: string
|
||||
@@ -595,31 +633,7 @@ export type TerminalSlice = {
|
||||
resolution: Pick<NativeChatLaunchDraft, 'createdAt' | 'text'>
|
||||
) => void
|
||||
clearNativeChatLaunchDraft: (tabId: string) => void
|
||||
pendingStartupByTabId: Record<
|
||||
string,
|
||||
{
|
||||
command: string
|
||||
/** Renderer-delivered startup input for callers needing xterm paste semantics before the submit Enter. */
|
||||
delivery?: 'terminal-paste'
|
||||
startupCommandDelivery?: StartupCommandDelivery
|
||||
env?: Record<string, string>
|
||||
envToDelete?: string[]
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
resumeProviderSession?: AgentProviderSessionMetadata
|
||||
launchToken?: string
|
||||
launchAgent?: TuiAgent
|
||||
/** Explicit CLI override for host-owned agent launches; omission uses host settings. */
|
||||
agentArgsOverride?: string | null
|
||||
draftPrompt?: string
|
||||
sessionOptions?: Record<string, SessionOptionValue>
|
||||
/** Initial prompt-start status for agents that lack native prompt hooks. */
|
||||
initialAgentStatus?: { agent: TuiAgent; prompt: string }
|
||||
/** Show the restored-session banner when this startup command mounts. */
|
||||
showSessionRestoredBanner?: boolean
|
||||
/** Telemetry for the `agent_started` event; threaded to the pty:spawn handler so it fires only after spawn confirms, not on click-intent. */
|
||||
telemetry?: AgentStartedTelemetry
|
||||
}
|
||||
>
|
||||
pendingStartupByTabId: Record<string, TabStartupCommand>
|
||||
pendingInitialCwdByTabId: Record<string, string>
|
||||
/** Queued setup-split requests; TerminalPane splits and runs the command in a new pane so the main terminal stays immediately interactive. */
|
||||
pendingSetupSplitByTabId: Record<
|
||||
@@ -630,6 +644,9 @@ export type TerminalSlice = {
|
||||
pendingIssueCommandSplitByTabId: Record<string, { command: string; env?: Record<string, string> }>
|
||||
tabBarOrderByWorktree: Record<string, string[]>
|
||||
workspaceSessionReady: boolean
|
||||
/** True after main ownership restoration, renderer PTY adoption, and structured-tab projection settle. */
|
||||
terminalStartupRestorationReady: boolean
|
||||
setTerminalStartupRestorationReady: (value: boolean) => void
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: Record<string, ExecutionHostId>
|
||||
defaultTerminalTabsAppliedByWorktreeId: Record<string, true>
|
||||
markDefaultTerminalTabsApplied: (worktreeId: string) => void
|
||||
@@ -660,6 +677,12 @@ export type TerminalSlice = {
|
||||
options?: {
|
||||
pendingActivationSpawn?: boolean
|
||||
initialPtyId?: string
|
||||
/** Stable leaf identity for adopting an already-live pane without changing its pane key. */
|
||||
initialLeafId?: string
|
||||
/** Published atomically with the tab so its first mount cannot spawn a bare shell. */
|
||||
pendingStartup?: TabStartupCommand
|
||||
/** Published atomically with pendingStartup for automatic resume ownership. */
|
||||
automaticResumeClaim?: AutomaticAgentResumeClaim
|
||||
activate?: boolean
|
||||
recordInteraction?: boolean
|
||||
/** Pre-allocated tab id (main mints it for CLI/runtime PTYs with a baked pane key); minted fresh on omit or cross-worktree collision. */
|
||||
@@ -776,45 +799,13 @@ export type TerminalSlice = {
|
||||
sourceTabId: string
|
||||
targetTabId: string
|
||||
}) => void
|
||||
queueTabStartupCommand: (
|
||||
tabId: string,
|
||||
startup: {
|
||||
command: string
|
||||
delivery?: 'terminal-paste'
|
||||
startupCommandDelivery?: StartupCommandDelivery
|
||||
env?: Record<string, string>
|
||||
envToDelete?: string[]
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
resumeProviderSession?: AgentProviderSessionMetadata
|
||||
launchToken?: string
|
||||
launchAgent?: TuiAgent
|
||||
agentArgsOverride?: string | null
|
||||
draftPrompt?: string
|
||||
sessionOptions?: Record<string, SessionOptionValue>
|
||||
initialAgentStatus?: { agent: TuiAgent; prompt: string }
|
||||
showSessionRestoredBanner?: boolean
|
||||
telemetry?: AgentStartedTelemetry
|
||||
}
|
||||
) => void
|
||||
queueTabStartupCommand: (tabId: string, startup: TabStartupCommand) => void
|
||||
queueTabInitialCwd: (tabId: string, cwd: string) => void
|
||||
consumeTabInitialCwd: (tabId: string) => string | null
|
||||
consumeTabStartupCommand: (tabId: string) => {
|
||||
command: string
|
||||
delivery?: 'terminal-paste'
|
||||
startupCommandDelivery?: StartupCommandDelivery
|
||||
env?: Record<string, string>
|
||||
envToDelete?: string[]
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
resumeProviderSession?: AgentProviderSessionMetadata
|
||||
launchToken?: string
|
||||
launchAgent?: TuiAgent
|
||||
agentArgsOverride?: string | null
|
||||
draftPrompt?: string
|
||||
sessionOptions?: Record<string, SessionOptionValue>
|
||||
initialAgentStatus?: { agent: TuiAgent; prompt: string }
|
||||
showSessionRestoredBanner?: boolean
|
||||
telemetry?: AgentStartedTelemetry
|
||||
} | null
|
||||
consumeTabStartupCommand: (
|
||||
tabId: string,
|
||||
expected?: TabStartupCommand
|
||||
) => TabStartupCommand | null
|
||||
queueTabSetupSplit: (
|
||||
tabId: string,
|
||||
startup: { command: string; env?: Record<string, string>; direction: SetupSplitDirection }
|
||||
@@ -1106,6 +1097,10 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
nativeChatLaunchDraftByTabId: {},
|
||||
tabBarOrderByWorktree: {},
|
||||
workspaceSessionReady: false,
|
||||
terminalStartupRestorationReady: false,
|
||||
setTerminalStartupRestorationReady: (value) => {
|
||||
set({ terminalStartupRestorationReady: value })
|
||||
},
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: {},
|
||||
defaultTerminalTabsAppliedByWorktreeId: {},
|
||||
markDefaultTerminalTabsApplied: (worktreeId) =>
|
||||
@@ -1361,6 +1356,15 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
)
|
||||
}
|
||||
const id = hintedId !== undefined && !idCollides ? hintedId : createBrowserUuid()
|
||||
const requestedInitialLeafId =
|
||||
options?.initialLeafId && isTerminalLeafId(options.initialLeafId)
|
||||
? options.initialLeafId
|
||||
: undefined
|
||||
// Why: startup delivery is pane-owned; pin its first leaf so an aborted/remounted renderer retries against the same spawn reservation.
|
||||
const initialLeafId =
|
||||
options?.initialPtyId || options?.pendingStartup
|
||||
? (requestedInitialLeafId ?? createBrowserUuid())
|
||||
: undefined
|
||||
const shouldActivate = options?.activate !== false
|
||||
const nextOrdinal = getNextTerminalOrdinal(existing)
|
||||
const defaultTitle = `Terminal ${nextOrdinal}`
|
||||
@@ -1522,9 +1526,23 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
...orphanCleanupPatch.ptyIdsByTabId,
|
||||
[tab.id]: options?.initialPtyId ? [options.initialPtyId] : []
|
||||
},
|
||||
pendingStartupByTabId: options?.pendingStartup
|
||||
? {
|
||||
...orphanCleanupPatch.pendingStartupByTabId,
|
||||
[tab.id]: normalizeTabStartupCommand(options.pendingStartup)
|
||||
}
|
||||
: orphanCleanupPatch.pendingStartupByTabId,
|
||||
automaticAgentResumeClaimsByTabId: options?.automaticResumeClaim
|
||||
? {
|
||||
...orphanCleanupPatch.automaticAgentResumeClaimsByTabId,
|
||||
[tab.id]: options.automaticResumeClaim
|
||||
}
|
||||
: orphanCleanupPatch.automaticAgentResumeClaimsByTabId,
|
||||
terminalLayoutsByTabId: {
|
||||
...orphanCleanupPatch.terminalLayoutsByTabId,
|
||||
[tab.id]: emptyLayoutSnapshot()
|
||||
[tab.id]: initialLeafId
|
||||
? singlePaneLayoutSnapshot(initialLeafId, options?.initialPtyId)
|
||||
: emptyLayoutSnapshot()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -3795,17 +3813,10 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
},
|
||||
|
||||
queueTabStartupCommand: (tabId, startup) => {
|
||||
// Why: launchToken is only meaningful for tracked launch-config reuse; plain startup commands must not mint a synthetic token.
|
||||
const launchToken = startup.launchConfig
|
||||
? (startup.launchToken ?? createBrowserUuid())
|
||||
: undefined
|
||||
set((s) => ({
|
||||
pendingStartupByTabId: {
|
||||
...s.pendingStartupByTabId,
|
||||
[tabId]: {
|
||||
...startup,
|
||||
...(launchToken ? { launchToken } : {})
|
||||
}
|
||||
[tabId]: normalizeTabStartupCommand(startup)
|
||||
}
|
||||
}))
|
||||
},
|
||||
@@ -3832,13 +3843,16 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
return pending
|
||||
},
|
||||
|
||||
consumeTabStartupCommand: (tabId) => {
|
||||
consumeTabStartupCommand: (tabId, expected) => {
|
||||
const pending = get().pendingStartupByTabId[tabId]
|
||||
if (!pending) {
|
||||
if (!pending || (expected && pending !== expected)) {
|
||||
return null
|
||||
}
|
||||
|
||||
set((s) => {
|
||||
if (s.pendingStartupByTabId[tabId] !== pending) {
|
||||
return {}
|
||||
}
|
||||
const next = { ...s.pendingStartupByTabId }
|
||||
delete next[tabId]
|
||||
return { pendingStartupByTabId: next }
|
||||
|
||||
@@ -571,6 +571,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
|
||||
// Staging already wrote through to browser storage, so there is nothing left to join.
|
||||
awaitBeforeUnloadCheckpoint: () => Promise.resolve(),
|
||||
awaitFirstWindowStartupServices: () => Promise.resolve(),
|
||||
prepareTerminalStartupRestoration: () => Promise.resolve(),
|
||||
recoverLegacyWorkerTerminalsForRendererStartup: () => Promise.resolve(),
|
||||
startupDiagnostic: () => Promise.resolve(),
|
||||
getKeyboardInputSourceId: () => Promise.resolve(null),
|
||||
|
||||
Reference in New Issue
Block a user