diff --git a/src/renderer/src/components/TerminalSurface.tsx b/src/renderer/src/components/TerminalSurface.tsx
index 023f19f7311..e352bbed614 100644
--- a/src/renderer/src/components/TerminalSurface.tsx
+++ b/src/renderer/src/components/TerminalSurface.tsx
@@ -5,6 +5,7 @@ import { TerminalSplitWorkspaceSurfaces } from './TerminalSplitWorkspaceSurfaces
import { TerminalLegacyWorkspaceSurface } from './TerminalLegacyWorkspaceSurface'
import { TerminalWorkspaceDialogs } from './TerminalWorkspaceDialogs'
import type { TerminalController } from './use-terminal-controller'
+import { WorkspaceActivationRecoverySurface } from './WorkspaceActivationRecoverySurface'
export function TerminalSurface({
controller
@@ -17,7 +18,7 @@ export function TerminalSurface({
+ {renderedActiveWorktreeId ? (
+
+ ) : null}
)
diff --git a/src/renderer/src/components/WorkspaceActivationRecoverySurface.test.tsx b/src/renderer/src/components/WorkspaceActivationRecoverySurface.test.tsx
new file mode 100644
index 00000000000..aa70649e480
--- /dev/null
+++ b/src/renderer/src/components/WorkspaceActivationRecoverySurface.test.tsx
@@ -0,0 +1,88 @@
+// @vitest-environment happy-dom
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { WorkspaceActivationRecoverySurface } from './WorkspaceActivationRecoverySurface'
+import {
+ publishWorkspaceActivationRecoveryPresentation,
+ resetWorkspaceActivationRecoveryPresentationsForTests
+} from '@/lib/workspace-activation-recovery-presentation'
+
+const mocks = vi.hoisted(() => {
+ const state: { unifiedTabsByWorktree: Record } = {
+ unifiedTabsByWorktree: {}
+ }
+ return { state }
+})
+
+vi.mock('@/store', () => ({
+ useAppStore: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state)
+}))
+vi.mock('@/lib/worktree-runtime-owner', () => ({
+ getExecutionHostIdForWorktree: () => 'local'
+}))
+
+const WORKSPACE_KEY = 'worktree-1'
+
+afterEach(() => {
+ cleanup()
+ resetWorkspaceActivationRecoveryPresentationsForTests()
+ mocks.state.unifiedTabsByWorktree = {}
+})
+
+describe('WorkspaceActivationRecoverySurface', () => {
+ it('renders a target-scoped actionable failure without adding a tab', () => {
+ const retry = vi.fn()
+ publishWorkspaceActivationRecoveryPresentation({
+ workspaceKey: WORKSPACE_KEY,
+ executionHostId: 'local',
+ attemptId: 'failure-1',
+ kind: 'producer-failed',
+ detail: 'Agent executable was not found.',
+ retry
+ })
+
+ render()
+
+ expect(screen.getByRole('alert').getAttribute('data-workspace-activation-recovery')).toBe(
+ 'producer-failed'
+ )
+ expect(screen.getByText('Agent executable was not found.')).not.toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
+ expect(retry).toHaveBeenCalledOnce()
+ expect(mocks.state.unifiedTabsByWorktree[WORKSPACE_KEY]).toBeUndefined()
+ })
+
+ it('renders bounded recovery progress as non-tab workspace content', () => {
+ publishWorkspaceActivationRecoveryPresentation({
+ workspaceKey: WORKSPACE_KEY,
+ executionHostId: 'local',
+ attemptId: 'progress-1',
+ kind: 'recovering',
+ retry: vi.fn()
+ })
+
+ render()
+
+ expect(screen.getByRole('status').getAttribute('data-workspace-activation-recovery')).toBe(
+ 'recovering'
+ )
+ expect(screen.queryByRole('tab')).toBeNull()
+ })
+
+ it.each([
+ ['blocked', 'Workspace recovery is paused'],
+ ['unexpected', 'Workspace recovery failed']
+ ] as const)('distinguishes %s recovery copy', (kind, title) => {
+ publishWorkspaceActivationRecoveryPresentation({
+ workspaceKey: WORKSPACE_KEY,
+ executionHostId: 'local',
+ attemptId: `${kind}-1`,
+ kind,
+ retry: vi.fn()
+ })
+
+ render()
+
+ expect(screen.getByRole('heading', { name: title })).not.toBeNull()
+ })
+})
diff --git a/src/renderer/src/components/WorkspaceActivationRecoverySurface.tsx b/src/renderer/src/components/WorkspaceActivationRecoverySurface.tsx
new file mode 100644
index 00000000000..e0efb2295d8
--- /dev/null
+++ b/src/renderer/src/components/WorkspaceActivationRecoverySurface.tsx
@@ -0,0 +1,122 @@
+import { useSyncExternalStore } from 'react'
+import { CircleAlert, Loader2, RefreshCw } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { translate } from '@/i18n/i18n'
+import { useAppStore } from '@/store'
+import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
+import {
+ readWorkspaceActivationRecoveryPresentation,
+ subscribeWorkspaceActivationRecoveryPresentation,
+ type WorkspaceActivationRecoveryPresentation
+} from '@/lib/workspace-activation-recovery-presentation'
+
+function presentationCopy(presentation: WorkspaceActivationRecoveryPresentation): {
+ title: string
+ description: string
+} {
+ if (presentation.kind === 'blocked') {
+ return {
+ title: translate(
+ 'auto.components.workspace.activation.recovery.blocked.title',
+ 'Workspace recovery is paused'
+ ),
+ description:
+ presentation.detail ??
+ translate(
+ 'auto.components.workspace.activation.recovery.blocked.description',
+ 'Orca could not safely determine whether this workspace already owns a running surface.'
+ )
+ }
+ }
+ if (presentation.kind === 'producer-failed') {
+ return {
+ title: translate(
+ 'auto.components.workspace.activation.recovery.producerFailed.title',
+ 'The requested surface did not open'
+ ),
+ description:
+ presentation.detail ??
+ translate(
+ 'auto.components.workspace.activation.recovery.producerFailed.description',
+ 'The surface producer reported a failure. Retry after correcting the problem.'
+ )
+ }
+ }
+ if (presentation.kind === 'unverifiable') {
+ return {
+ title: translate(
+ 'auto.components.workspace.activation.recovery.unverifiable.title',
+ 'Reconnect to continue'
+ ),
+ description:
+ presentation.detail ??
+ translate(
+ 'auto.components.workspace.activation.recovery.unverifiable.description',
+ 'Orca cannot verify the execution host, so it will not start another process.'
+ )
+ }
+ }
+ return {
+ title: translate(
+ 'auto.components.workspace.activation.recovery.unexpected.title',
+ 'Workspace recovery failed'
+ ),
+ description:
+ presentation.detail ??
+ translate(
+ 'auto.components.workspace.activation.recovery.unexpected.description',
+ 'An unexpected error interrupted workspace recovery.'
+ )
+ }
+}
+
+export function WorkspaceActivationRecoverySurface({
+ worktreeId
+}: {
+ worktreeId: string
+}): React.JSX.Element | null {
+ const executionHostId = useAppStore((state) => getExecutionHostIdForWorktree(state, worktreeId))
+ const presentation = useSyncExternalStore(
+ subscribeWorkspaceActivationRecoveryPresentation,
+ () => readWorkspaceActivationRecoveryPresentation(worktreeId, executionHostId),
+ () => null
+ )
+ if (!presentation) {
+ return null
+ }
+ if (presentation.kind === 'recovering') {
+ return (
+
+
+
+ {translate(
+ 'auto.components.workspace.activation.recovery.progress',
+ 'Checking workspace surfaces…'
+ )}
+
+
+ )
+ }
+ const copy = presentationCopy(presentation)
+ return (
+
+
+
+
{copy.title}
+
{copy.description}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts b/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts
index a207244cdb9..1c082fe5812 100644
--- a/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts
+++ b/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts
@@ -1,27 +1,48 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
+ activateAndRevealWorktree: vi.fn(),
getKnownWorktreeById: vi.fn(),
- setActiveWorktree: vi.fn(),
launchAgentInNewTab: vi.fn(),
- getExecutionHostIdForWorktree: vi.fn()
+ getExecutionHostIdForWorktree: vi.fn(),
+ registerWorkspaceSurfaceProducer: vi.fn(),
+ surfaceProducer: {
+ attempt: {
+ id: 'dashboard-attempt',
+ workspaceKey: 'folder:docs',
+ executionHostId: 'ssh:docs',
+ result: Promise.resolve({
+ kind: 'materialized' as const,
+ surface: { kind: 'tab' as const, id: 'tab-1' }
+ })
+ },
+ materialized: vi.fn(),
+ declined: vi.fn(),
+ failed: vi.fn(),
+ unverifiable: vi.fn()
+ }
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => ({
settings: null,
- getKnownWorktreeById: mocks.getKnownWorktreeById,
- setActiveWorktree: mocks.setActiveWorktree
+ getKnownWorktreeById: mocks.getKnownWorktreeById
})
}
}))
+vi.mock('@/lib/worktree-activation', () => ({
+ activateAndRevealWorktree: mocks.activateAndRevealWorktree
+}))
vi.mock('@/lib/launch-agent-in-new-tab', () => ({
launchAgentInNewTab: mocks.launchAgentInNewTab
}))
vi.mock('@/lib/worktree-runtime-owner', () => ({
getExecutionHostIdForWorktree: mocks.getExecutionHostIdForWorktree
}))
+vi.mock('@/lib/workspace-surface-production', () => ({
+ registerWorkspaceSurfaceProducer: mocks.registerWorkspaceSurfaceProducer
+}))
import { launchDashboardAgent } from './launch-dashboard-agent'
@@ -30,17 +51,25 @@ describe('launchDashboardAgent', () => {
vi.clearAllMocks()
mocks.getExecutionHostIdForWorktree.mockReturnValue('ssh:docs')
mocks.getKnownWorktreeById.mockReturnValue({ id: 'folder:docs' })
+ mocks.activateAndRevealWorktree.mockReturnValue({ primaryTabId: null })
mocks.launchAgentInNewTab.mockReturnValue({ tabId: 'tab-1' })
+ mocks.registerWorkspaceSurfaceProducer.mockReturnValue(mocks.surfaceProducer)
})
it('activates a folder or git workspace on its execution host before launching', () => {
expect(launchDashboardAgent({ worktreeId: 'folder:docs', agent: 'codex' })).toBe(true)
expect(mocks.getKnownWorktreeById).toHaveBeenCalledWith('folder:docs', 'ssh:docs')
- expect(mocks.setActiveWorktree).toHaveBeenCalledWith('folder:docs', 'ssh:docs')
+ expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('folder:docs', {
+ executionHostId: 'ssh:docs'
+ })
expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith({
agent: 'codex',
worktreeId: 'folder:docs',
launchSource: 'unknown'
})
+ expect(mocks.surfaceProducer.materialized).toHaveBeenCalledWith({
+ kind: 'tab',
+ id: 'tab-1'
+ })
})
})
diff --git a/src/renderer/src/components/dashboard/launch-dashboard-agent.ts b/src/renderer/src/components/dashboard/launch-dashboard-agent.ts
index be3b644e5a6..c6cf6bea9ad 100644
--- a/src/renderer/src/components/dashboard/launch-dashboard-agent.ts
+++ b/src/renderer/src/components/dashboard/launch-dashboard-agent.ts
@@ -3,6 +3,9 @@ import { useAppStore } from '@/store'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
import type { DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot'
import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
+import { activateAndRevealWorktree } from '@/lib/worktree-activation'
+import { registerWorkspaceSurfaceProducer } from '@/lib/workspace-surface-production'
+import { settleStructuredAgentSurfaceProducer } from '@/lib/structured-agent-surface-production'
/** Starts the requested agent through the same host-aware tab path as Quick Launch. */
export function launchDashboardAgent({ worktreeId, agent }: DashboardSpawnAgentArgs): boolean {
@@ -12,12 +15,34 @@ export function launchDashboardAgent({ worktreeId, agent }: DashboardSpawnAgentA
if (!worktree || !isTuiAgentEnabled(agent, state.settings?.disabledTuiAgents)) {
return false
}
- state.setActiveWorktree(worktreeId, executionHostId)
- return (
- launchAgentInNewTab({
+ const producer = registerWorkspaceSurfaceProducer({ workspaceKey: worktreeId, executionHostId })
+ try {
+ if (activateAndRevealWorktree(worktreeId, { executionHostId }) === false) {
+ producer.failed('The workspace is no longer available.')
+ return false
+ }
+ const result = launchAgentInNewTab({
agent,
worktreeId,
launchSource: 'unknown'
- }) !== null
- )
+ })
+ if (!result) {
+ producer.failed('The agent launch did not start.')
+ return false
+ }
+ if (result.tabId) {
+ producer.materialized({ kind: 'tab', id: result.tabId })
+ } else if (result.structuredSettlement) {
+ void result.structuredSettlement.then(
+ (settlement) => settleStructuredAgentSurfaceProducer(producer, worktreeId, settlement),
+ (error: unknown) => producer.failed(error)
+ )
+ } else {
+ producer.failed('The agent launch did not publish a surface.')
+ }
+ return true
+ } catch (error) {
+ producer.failed(error)
+ return false
+ }
}
diff --git a/src/renderer/src/components/editor/check-annotation-open.test.ts b/src/renderer/src/components/editor/check-annotation-open.test.ts
index 97f0fbf09b2..610df5aa4c7 100644
--- a/src/renderer/src/components/editor/check-annotation-open.test.ts
+++ b/src/renderer/src/components/editor/check-annotation-open.test.ts
@@ -23,7 +23,7 @@ afterEach(() => {
})
describe('openAnnotationLocation', () => {
- it('activates as a surface-providing caller before opening the editor', () => {
+ it('activates before opening the editor through its concrete producer', () => {
vi.stubGlobal('requestAnimationFrame', vi.fn().mockReturnValue(1))
vi.stubGlobal('cancelAnimationFrame', vi.fn())
@@ -35,11 +35,7 @@ describe('openAnnotationLocation', () => {
revealInnerRafRef: { current: null }
})
- // Why: the annotation's editor file is the surface — the jump must not re-seed
- // a shell into a workspace whose last terminal the user closed.
- expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', {
- providesInitialSurface: true
- })
+ expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1')
expect(mocks.state.openFile).toHaveBeenCalledWith(
expect.objectContaining({ worktreeId: 'wt-1', relativePath: 'src/a.ts' }),
{ forceContentReload: true }
diff --git a/src/renderer/src/components/editor/check-annotation-open.ts b/src/renderer/src/components/editor/check-annotation-open.ts
index afcb3600efa..61ee5142e78 100644
--- a/src/renderer/src/components/editor/check-annotation-open.ts
+++ b/src/renderer/src/components/editor/check-annotation-open.ts
@@ -6,6 +6,8 @@ import {
getOpenableAnnotationLine,
resolveAnnotationPathInsideWorktree
} from './check-annotation-path'
+import { registerWorkspaceSurfaceProducer } from '@/lib/workspace-surface-production'
+import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
export { getOpenableAnnotationLine }
@@ -27,24 +29,36 @@ export function openAnnotationLocation(params: {
return
}
const { absolutePath, relativePath } = resolvedPath
+ cancelAnnotationRevealFrame(revealRafRef)
+ cancelAnnotationRevealFrame(revealInnerRafRef)
// Why: reuse the shared activation path so an annotation jump lands in the
// same history stack as sidebar, palette, and terminal-link navigation.
- activateAndRevealWorktree(worktreeId, { providesInitialSurface: true })
-
- store.openFile(
- {
- filePath: absolutePath,
- relativePath,
- worktreeId,
- language: detectLanguage(relativePath),
- mode: 'edit'
- },
- { forceContentReload: true }
- )
-
- cancelAnnotationRevealFrame(revealRafRef)
- cancelAnnotationRevealFrame(revealInnerRafRef)
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey: worktreeId,
+ executionHostId: getExecutionHostIdForWorktree(store, worktreeId)
+ })
+ try {
+ const activation = activateAndRevealWorktree(worktreeId)
+ if (activation === false) {
+ producer.failed('The workspace is no longer available.')
+ return
+ }
+ const surfaceId = store.openFile(
+ {
+ filePath: absolutePath,
+ relativePath,
+ worktreeId,
+ language: detectLanguage(relativePath),
+ mode: 'edit'
+ },
+ { forceContentReload: true }
+ )
+ producer.materialized({ kind: 'tab', id: surfaceId })
+ } catch (error) {
+ producer.failed(error)
+ return
+ }
store.setPendingEditorReveal(null)
// Why: opening can replace the active tab and mount Monaco asynchronously.
diff --git a/src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx b/src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx
index 4567f75932e..684d5b3a3b9 100644
--- a/src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx
+++ b/src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx
@@ -17,6 +17,8 @@ import {
useSetupTargetWorktree
} from './FeatureWallSetupWorkflowActions'
import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy'
+import { registerWorkspaceSurfaceProducer } from '@/lib/workspace-surface-production'
+import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
export function BrowserAction(props: { done: boolean }): React.JSX.Element {
const targetWorktree = useSetupTargetWorktree()
@@ -37,18 +39,49 @@ export function BrowserAction(props: { done: boolean }): React.JSX.Element {
return
}
closeModal()
- activateAndRevealWorktree(targetWorktree.id, { providesInitialSurface: true })
const state = useAppStore.getState()
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey: targetWorktree.id,
+ executionHostId: getExecutionHostIdForWorktree(state, targetWorktree.id)
+ })
+ try {
+ if (activateAndRevealWorktree(targetWorktree.id) === false) {
+ producer.failed('The workspace is no longer available.')
+ return
+ }
+ } catch (error) {
+ producer.failed(error)
+ toast.error(error instanceof Error ? error.message : String(error))
+ return
+ }
// Why: open the browser into the worktree's active group so it lands beside
// the user's current work rather than spawning a detached surface.
const groupId =
state.activeGroupIdByWorktree[targetWorktree.id] ??
state.groupsByWorktree[targetWorktree.id]?.[0]?.id
if (groupId) {
- void openNewBrowserTabInActiveWorkspace(groupId).catch((error) => {
- toast.error(error instanceof Error ? error.message : String(error))
- })
+ const existingSurfaceIds = new Set(
+ (useAppStore.getState().unifiedTabsByWorktree[targetWorktree.id] ?? []).map((tab) => tab.id)
+ )
+ void openNewBrowserTabInActiveWorkspace(groupId)
+ .then(() => {
+ const settledState = useAppStore.getState()
+ settledState.reconcileWorktreeTabModel(targetWorktree.id)
+ const surfaceId = (
+ useAppStore.getState().unifiedTabsByWorktree[targetWorktree.id] ?? []
+ ).find((tab) => !existingSurfaceIds.has(tab.id))?.id
+ if (surfaceId) {
+ producer.materialized({ kind: 'tab', id: surfaceId })
+ return
+ }
+ producer.failed('The browser did not publish a workspace surface.')
+ })
+ .catch((error) => {
+ producer.failed(error)
+ toast.error(error instanceof Error ? error.message : String(error))
+ })
} else {
+ producer.failed('No workspace group is available for this worktree yet.')
toast.warning(
translate(
'auto.components.feature.wall.FeatureWallBrowserAction.5022c43a88',
diff --git a/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx b/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx
index 1121a05ae64..386be815753 100644
--- a/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx
+++ b/src/renderer/src/components/right-sidebar/PortsPanel.test.tsx
@@ -350,9 +350,8 @@ describe('PortsPanel runtime routing', () => {
).resolves.toEqual({ ok: true })
expect(activateAndRevealWorktreeMock).toHaveBeenCalledTimes(2)
- // Why: the browser tab is the surface — port opens must not re-seed a shell.
expect(activateAndRevealWorktreeMock).toHaveBeenCalledWith('repo::/workspace/app', {
- providesInitialSurface: true
+ executionHostId: 'runtime:env-1'
})
expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([
'status.get',
diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts
index a84b8323d03..7eb0722f555 100644
--- a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts
+++ b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts
@@ -359,12 +359,8 @@ describe('delete worktree flow', () => {
const toastOptions = vi.mocked(showDeleteWorktreeFailureToast).mock.calls[0]?.[0]
toastOptions?.onViewChanges()
- // Why: the Source Control panel is the surface; seeding a shell would repopulate a
- // workspace the user is trying to delete and erase its closed-last-terminal tombstone.
const { activateAndRevealWorktree } = await import('@/lib/worktree-activation')
- expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', {
- providesInitialSurface: true
- })
+ expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', {})
expect(mocks.state.setRightSidebarTab).toHaveBeenCalledWith('source-control')
expect(mocks.state.setRightSidebarOpen).toHaveBeenCalledWith(true)
})
diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts
index 77ff1c193b8..e31b518e36c 100644
--- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts
+++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts
@@ -26,6 +26,12 @@ import {
getFolderWorkspaceAgentLaunchPlatform,
resolveFolderWorkspaceLaunchDraft
} from './folder-workspace-agent-startup'
+import {
+ registerWorkspaceSurfaceProducer,
+ type WorkspaceSurfaceProducer
+} from '@/lib/workspace-surface-production'
+import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
+import { settleStructuredAgentSurfaceProducer } from '@/lib/structured-agent-surface-production'
export {
buildFolderWorkspaceLinkedStartupPlan,
@@ -205,13 +211,23 @@ export async function submitFolderWorkspaceCreate({
}
: undefined
onOpenChange(false)
+ let structuredProducer: WorkspaceSurfaceProducer | null = null
try {
+ const workspaceKey = folderWorkspaceKey(workspace.id)
+ structuredProducer = structuredLaunch
+ ? registerWorkspaceSurfaceProducer({
+ workspaceKey,
+ executionHostId: getExecutionHostIdForWorktree(useAppStore.getState(), workspaceKey)
+ })
+ : null
let activation = activateAndRevealFolderWorkspace(workspace.id, {
agent: quickAgent,
...(!structuredLaunch && startup ? { startup } : {}),
- ...(structuredLaunch ? { providesInitialSurface: true } : {}),
runtimeEnvironmentId
})
+ if (activation === false) {
+ structuredProducer?.failed('The workspace is no longer available.')
+ }
let structuredLaunchAccepted = structuredLaunch
const settlement =
plan?.route === 'structured-native-chat'
@@ -244,6 +260,9 @@ export async function submitFolderWorkspaceCreate({
{ worktreeId: folderWorkspaceKey(workspace.id) }
)
: null
+ if (structuredProducer) {
+ settleStructuredAgentSurfaceProducer(structuredProducer, workspaceKey, settlement)
+ }
if (settlement) {
// Why: the workspace exists either way. Unknown keeps reporting false and failed true, as
// the boolean did before the loop was shared; the launch layer owns the failure toast.
@@ -288,6 +307,7 @@ export async function submitFolderWorkspaceCreate({
})
}
} catch (error) {
+ structuredProducer?.failed(error)
// Why: creation already succeeded. Do not leave the completed create modal
// open if the follow-up reveal/startup path hits a transient issue.
console.error('Failed to activate folder workspace after create:', error)
diff --git a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts
index 3f844d6f09d..7464d7d62a8 100644
--- a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts
+++ b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts
@@ -7,21 +7,34 @@ import { prepareActiveWorktreeFocusAfterDelete } from './active-worktree-focus-a
import { showDeleteWorktreeFailureToast } from './delete-worktree-failure-toast'
import type { WorktreeDeleteWithToastOptions } from './worktree-delete-request'
import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match'
+import { registerWorkspaceSurfaceProducer } from '@/lib/workspace-surface-production'
+import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
// A failed delete usually means unresolved changes, so land on the diff panel.
function viewWorktreeDiff(
worktreeId: string,
executionHostId: WorktreeRemovalTarget['executionHostId']
): void {
- // The Source Control panel is the requested surface — don't re-seed a shell in a
- // workspace the user is trying to delete.
- activateAndRevealWorktree(worktreeId, {
- providesInitialSurface: true,
- ...(executionHostId ? { executionHostId } : {})
- })
const state = useAppStore.getState()
- state.setRightSidebarTab('source-control')
- state.setRightSidebarOpen(true)
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey: worktreeId,
+ executionHostId: executionHostId ?? getExecutionHostIdForWorktree(state, worktreeId)
+ })
+ try {
+ const activation = activateAndRevealWorktree(
+ worktreeId,
+ executionHostId ? { executionHostId } : {}
+ )
+ if (activation === false) {
+ producer.failed('The workspace is no longer available.')
+ return
+ }
+ state.setRightSidebarTab('source-control')
+ state.setRightSidebarOpen(true)
+ producer.materialized({ kind: 'workspace-content', id: 'source-control' })
+ } catch (error) {
+ producer.failed(error)
+ }
}
export function runWorktreeDeleteWithToast(
diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts
index 9ea97f47307..7b9d3f7b5a0 100644
--- a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts
@@ -33,6 +33,10 @@ const store = {
agentStatusByPaneKey: {} as Record,
tabsByWorktree: {} as Record,
getKnownWorktreeById: vi.fn(),
+ reconcileWorktreeTabModel: vi.fn(() => ({
+ renderableTabCount: 1,
+ activeRenderableTabId: 'agent-session:session-1'
+ })),
createWorktree: mockCreateWorktree
}
@@ -183,8 +187,7 @@ describe('forkAgentSessionFromPane', () => {
await vi.waitFor(() => expect(mockActivateAndRevealWorktree).toHaveBeenCalled())
expect(mockActivateAndRevealWorktree).toHaveBeenCalledWith('wt-fork', {
- sidebarRevealBehavior: 'auto',
- providesInitialSurface: true
+ sidebarRevealBehavior: 'auto'
})
expect(mockToast.success).not.toHaveBeenCalled()
settle({ kind: 'structured', sessionId: 'session-1' })
@@ -274,8 +277,7 @@ describe('forkAgentSessionFromPane', () => {
expect(mockToast.success).not.toHaveBeenCalled()
expect(mockWriteClipboardText).toHaveBeenCalledTimes(copiesContext ? 1 : 0)
expect(mockActivateAndRevealWorktree).toHaveBeenCalledWith('wt-fork', {
- sidebarRevealBehavior: 'auto',
- providesInitialSurface: true
+ sidebarRevealBehavior: 'auto'
})
}
)
diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts
index fc22eae6417..d451c792cf6 100644
--- a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts
@@ -16,6 +16,9 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import type { TuiAgent } from '../../../../shared/tui-agent'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import { translate } from '@/i18n/i18n'
+import { registerWorkspaceSurfaceProducer } from '@/lib/workspace-surface-production'
+import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
+import { settleStructuredAgentSurfaceProducer } from '@/lib/structured-agent-surface-production'
type ForkAgentSessionFromPaneArgs = {
pane: ManagedPane
@@ -239,29 +242,56 @@ export async function startAgentSessionFork(fork: PreparedAgentSessionFork): Pro
worktreePath: created.worktree.path,
projectRuntime: sourceProjectRuntime
})
- const result = launchAgentInNewTab({
- agent: fork.agent,
- worktreeId: forkWorktreeId,
- prompt: fork.prompt,
- promptDelivery: 'draft',
- launchSource: 'terminal_context_menu',
- ...(launchPlatform ? { launchPlatform } : {})
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey: forkWorktreeId,
+ executionHostId: getExecutionHostIdForWorktree(useAppStore.getState(), forkWorktreeId)
})
+ let result: ReturnType
+ try {
+ result = launchAgentInNewTab({
+ agent: fork.agent,
+ worktreeId: forkWorktreeId,
+ prompt: fork.prompt,
+ promptDelivery: 'draft',
+ launchSource: 'terminal_context_menu',
+ ...(launchPlatform ? { launchPlatform } : {})
+ })
+ } catch (error) {
+ producer.failed(error)
+ throw error
+ }
if (!result?.structuredSettlement) {
- activateAndRevealWorktree(forkWorktreeId, { sidebarRevealBehavior: 'auto' })
+ try {
+ activateAndRevealWorktree(forkWorktreeId, { sidebarRevealBehavior: 'auto' })
+ } catch (error) {
+ producer.failed(error)
+ throw error
+ }
if (!result) {
+ producer.failed('The agent launch did not start.')
return copyAgentSessionForkContext(fork)
}
+ if (result.tabId) {
+ producer.materialized({ kind: 'tab', id: result.tabId })
+ } else {
+ producer.failed('The agent launch did not publish a surface.')
+ }
notifyForkOpened()
return true
}
// Why: the fresh worktree has no tabs yet; without the opt-out activation seeds a shell beside
// the structured tab that is still on its way.
- activateAndRevealWorktree(forkWorktreeId, {
- sidebarRevealBehavior: 'auto',
- providesInitialSurface: true
- })
- const settlement = await result.structuredSettlement
+ let settlement: Awaited
+ try {
+ activateAndRevealWorktree(forkWorktreeId, {
+ sidebarRevealBehavior: 'auto'
+ })
+ settlement = await result.structuredSettlement
+ } catch (error) {
+ producer.failed(error)
+ throw error
+ }
+ settleStructuredAgentSurfaceProducer(producer, forkWorktreeId, settlement)
// Why: a refusal whose terminal fallback opened nothing is the structured twin of a null launch.
if (settlement.kind === 'refused-then-legacy' && settlement.primaryTabId === null) {
return copyAgentSessionForkContext(fork)
diff --git a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts
index 346424c960a..60d775979d2 100644
--- a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts
@@ -1,4 +1,3 @@
-import { absolutePathToFileUri } from '@/components/editor/markdown-internal-links'
import { getWorkspaceFilePreviewPlan, openFileInBrowserTab } from '@/lib/file-preview'
import { downloadAndOpenRemoteTerminalFile } from './terminal-remote-file-download-open'
import { detectLanguage } from '@/lib/language-detect'
@@ -19,6 +18,12 @@ import {
toSshExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
+import { registerWorkspaceSurfaceProducer } from '@/lib/workspace-surface-production'
+import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
+import {
+ openTerminalHtmlFileInBrowser,
+ settleTerminalFileProducedTab
+} from './terminal-file-open-surface-production'
type TerminalFileOpenDeps = {
worktreeId: string
@@ -32,19 +37,6 @@ export function isHtmlFilePath(filePath: string): boolean {
return /\.html?$/i.test(filePath)
}
-function openHtmlFileInBrowser(filePath: string, worktreeId: string): void {
- const store = useAppStore.getState()
- if (worktreeId) {
- // Why: following an HTML file link changes which worktree is foregrounded,
- // so it must record a history visit before opening the browser tab — but the
- // browser tab is the surface, so an emptied workspace must not gain a shell.
- activateAndRevealWorktree(worktreeId, { providesInitialSurface: true })
- }
- const fileUrl = absolutePathToFileUri(filePath)
- const title = filePath.split(/[/\\]/).pop() ?? filePath
- store.createBrowserTab(worktreeId, fileUrl, { title, activate: true })
-}
-
export function getTerminalFileContext(
worktreeId: string,
worktreePath: string,
@@ -201,15 +193,27 @@ export function openDetectedFilePath(
// and remain the fallback if Shift+Cmd/Ctrl cannot launch the OS default.
if (isHtmlFilePath(mappedFilePath)) {
if (shouldOpenTerminalFileWithSystemDefault(fileContext, mappedFilePath)) {
- openHtmlFileInBrowser(mappedFilePath, worktreeId)
+ openTerminalHtmlFileInBrowser(mappedFilePath, worktreeId)
return
}
// Why: the same gesture renders remote HTML too, through the doc preview; only an
// unsupported plan (e.g. a paired doc outside the worktree) falls back to source.
const plan = getWorkspaceFilePreviewPlan(useAppStore.getState(), worktreeId, mappedFilePath)
if (plan.status === 'doc-preview') {
- activateAndRevealWorktree(worktreeId, { providesInitialSurface: true })
- openFileInBrowserTab({ filePath: mappedFilePath, worktreeId })
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey: worktreeId,
+ executionHostId: getExecutionHostIdForWorktree(useAppStore.getState(), worktreeId)
+ })
+ try {
+ if (activateAndRevealWorktree(worktreeId) === false) {
+ producer.failed('The workspace is no longer available.')
+ return
+ }
+ openFileInBrowserTab({ filePath: mappedFilePath, worktreeId })
+ settleTerminalFileProducedTab(producer, worktreeId)
+ } catch (error) {
+ producer.failed(error)
+ }
return
}
}
@@ -246,32 +250,65 @@ export function openDetectedFilePath(
if (targetWorktreeId) {
// Why: the route may name a folder-workspace key, and the same worktree id can exist
// on several hosts — dispatch by workspace shape and keep the resolved host.
- activateAndRevealWorkspace(targetWorktreeId, {
- providesInitialSurface: true,
- ...(targetExecutionHostId ? { executionHostId: targetExecutionHostId } : {})
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey: targetWorktreeId,
+ executionHostId:
+ targetExecutionHostId ?? getExecutionHostIdForWorktree(store, targetWorktreeId)
})
+ try {
+ const activation = activateAndRevealWorkspace(
+ targetWorktreeId,
+ targetExecutionHostId ? { executionHostId: targetExecutionHostId } : {}
+ )
+ if (activation === false) {
+ producer.failed('The workspace is no longer available.')
+ return
+ }
+ const language = detectLanguage(mappedFilePath)
+ const surfaceId = store.openFile(
+ {
+ filePath: mappedFilePath,
+ relativePath,
+ worktreeId: targetWorktreeId || '',
+ language,
+ mode: 'edit',
+ runtimeEnvironmentId,
+ // Why: absolute SSH paths outside the worktree otherwise look identical
+ // to client-local external files when the editor reloads or restores.
+ ...(relativePath === filePath &&
+ !fileContext.settings?.activeRuntimeEnvironmentId?.trim() &&
+ fileContext.connectionId
+ ? { externalSshTargetId: fileContext.connectionId }
+ : {})
+ },
+ { forceContentReload: true }
+ )
+ settleTerminalFileProducedTab(producer, targetWorktreeId, surfaceId)
+ } catch (error) {
+ producer.failed(error)
+ return
+ }
+ } else {
+ const language = detectLanguage(mappedFilePath)
+ store.openFile(
+ {
+ filePath: mappedFilePath,
+ relativePath,
+ worktreeId: '',
+ language,
+ mode: 'edit',
+ runtimeEnvironmentId,
+ ...(relativePath === filePath &&
+ !fileContext.settings?.activeRuntimeEnvironmentId?.trim() &&
+ fileContext.connectionId
+ ? { externalSshTargetId: fileContext.connectionId }
+ : {})
+ },
+ { forceContentReload: true }
+ )
}
const language = detectLanguage(mappedFilePath)
- store.openFile(
- {
- filePath: mappedFilePath,
- relativePath,
- worktreeId: targetWorktreeId || '',
- language,
- mode: 'edit',
- runtimeEnvironmentId,
- // Why: absolute SSH paths outside the worktree otherwise look identical
- // to client-local external files when the editor reloads or restores.
- ...(relativePath === filePath &&
- !fileContext.settings?.activeRuntimeEnvironmentId?.trim() &&
- fileContext.connectionId
- ? { externalSshTargetId: fileContext.connectionId }
- : {})
- },
- { forceContentReload: true }
- )
-
if (line !== null) {
const openedStore = useAppStore.getState()
// Why: scope the reveal to the opened editor tab id so owner-qualified tabs
diff --git a/src/renderer/src/components/terminal-pane/terminal-file-open-surface-production.ts b/src/renderer/src/components/terminal-pane/terminal-file-open-surface-production.ts
new file mode 100644
index 00000000000..251b1c05778
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-file-open-surface-production.ts
@@ -0,0 +1,57 @@
+import { absolutePathToFileUri } from '@/components/editor/markdown-internal-links'
+import { useAppStore } from '@/store'
+import { activateAndRevealWorktree } from '@/lib/worktree-activation'
+import {
+ registerWorkspaceSurfaceProducer,
+ type WorkspaceSurfaceProducer
+} from '@/lib/workspace-surface-production'
+import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
+
+export function settleTerminalFileProducedTab(
+ producer: WorkspaceSurfaceProducer,
+ worktreeId: string,
+ expectedSurfaceId?: string
+): void {
+ try {
+ const state = useAppStore.getState()
+ const reconciliation = state.reconcileWorktreeTabModel(worktreeId)
+ const surfaceId = expectedSurfaceId ?? reconciliation.activeRenderableTabId
+ const published = surfaceId
+ ? (useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).some(
+ (tab) => tab.id === surfaceId
+ )
+ : false
+ if (surfaceId && published) {
+ producer.materialized({ kind: 'tab', id: surfaceId })
+ return
+ }
+ producer.failed('The requested tab did not become available.')
+ } catch (error) {
+ // Why: recovery bookkeeping must not interrupt an editor/browser open that already succeeded.
+ producer.failed(error)
+ }
+}
+
+export function openTerminalHtmlFileInBrowser(filePath: string, worktreeId: string): void {
+ const store = useAppStore.getState()
+ const fileUrl = absolutePathToFileUri(filePath)
+ const title = filePath.split(/[/\\]/).pop() ?? filePath
+ if (!worktreeId) {
+ store.createBrowserTab(worktreeId, fileUrl, { title, activate: true })
+ return
+ }
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey: worktreeId,
+ executionHostId: getExecutionHostIdForWorktree(store, worktreeId)
+ })
+ try {
+ if (activateAndRevealWorktree(worktreeId) === false) {
+ producer.failed('The workspace is no longer available.')
+ return
+ }
+ const tab = store.createBrowserTab(worktreeId, fileUrl, { title, activate: true })
+ settleTerminalFileProducedTab(producer, worktreeId, tab.id)
+ } catch (error) {
+ producer.failed(error)
+ }
+}
diff --git a/src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts
index 61fc1e2c1ba..760f2ad475f 100644
--- a/src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-link-file-open-routing.test.ts
@@ -65,11 +65,7 @@ describe('handleOscLink', () => {
expect.objectContaining({ title: 'report.html', activate: true })
)
expect(openFilePathMock).not.toHaveBeenCalled()
- // Why: the browser tab is the surface — activation must not re-seed a shell into a
- // workspace whose last terminal the user closed.
- expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', {
- providesInitialSurface: true
- })
+ expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1')
})
it('also opens local .htm paths in Orca browser tabs with the platform modifier', async () => {
@@ -108,10 +104,7 @@ describe('handleOscLink', () => {
matchLength: 0
})
expect(openFilePathMock).not.toHaveBeenCalled()
- // Why: the editor file is the surface — the cross-worktree jump must not add a shell.
- expect(activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', {
- providesInitialSurface: true
- })
+ expect(activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', {})
})
it('opens a sibling folder-workspace path under its owning host and workspace', async () => {
@@ -131,7 +124,6 @@ describe('handleOscLink', () => {
await flushDoubleRaf()
expect(activateAndRevealWorkspace).toHaveBeenCalledWith('folder:notes', {
- providesInitialSurface: true,
executionHostId: 'local'
})
expect(openFileMock).toHaveBeenCalledWith(
diff --git a/src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts
index 67562aec052..8b10fbd0162 100644
--- a/src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts
@@ -252,11 +252,7 @@ describe('handleOscLink', () => {
filePath: '/home/me/repo/report.html',
worktreeId: 'wt-1'
})
- // Why: the preview tab is the surface — activation must not re-seed a shell into a
- // workspace whose last terminal the user closed.
- expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', {
- providesInitialSurface: true
- })
+ expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1')
})
it('falls back to the source editor when the preview plan is unsupported', async () => {
diff --git a/src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx b/src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx
index efcc2fb8d44..21dce0bfa2f 100644
--- a/src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx
+++ b/src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx
@@ -65,8 +65,6 @@ function renderWatcherEffects(overrides: Partial = {}): Promi
activityTerminalPortals: [],
anyMountedWorktreeHasLayout: false,
backgroundMountRevision: 0,
- createTab: vi.fn(),
- reconcileWorktreeTabModel: vi.fn(),
pairedRuntimeParkingEnvironmentIds: new Set(),
terminalSshParkingEnabled: true,
terminalProviderSnapshotCapabilityRevision: 0,
diff --git a/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx b/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx
index 7a76434ce2f..7896c3ca03a 100644
--- a/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx
+++ b/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx
@@ -5,22 +5,30 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { useTerminalWatcherEffects } from '../use-terminal-watcher-effects'
import type { TerminalColdActivationController } from '../terminal-cold-activation'
-const mocks = vi.hoisted(() => ({
- gate: vi.fn(),
- launchStatus: vi.fn((_worktreeId: string, _provider: string): string => 'idle'),
- createTab: vi.fn()
-}))
+const mocks = vi.hoisted(() => {
+ let uuid = 0
+ return {
+ recover: vi.fn(),
+ nextUuid: () => `startup-recovery-${++uuid}`,
+ resetUuid: () => {
+ uuid = 0
+ }
+ }
+})
+
vi.mock('@/store', () => ({
useAppStore: Object.assign(() => 'none', {
- getState: () => ({ activeWorktreeId: 'wt-1' })
+ getState: () => ({ activeWorktreeId: 'folder:workspace-1' })
})
}))
-vi.mock('@/lib/worktree-agent-activation-gate', () => ({
- gateWorktreeAgentActivation: mocks.gate
+vi.mock('@/lib/worktree-activation-recovery', () => ({
+ recoverWorkspaceActivation: mocks.recover
}))
-vi.mock('@/lib/structured-agent-session-launch', () => ({
- getStructuredAgentLaunchStatus: mocks.launchStatus
+vi.mock('@/lib/worktree-runtime-owner', () => ({
+ getExecutionHostIdForWorktree: () => 'local',
+ getRuntimeEnvironmentIdForWorktree: () => null
}))
+vi.mock('@/lib/browser-uuid', () => ({ createBrowserUuid: mocks.nextUuid }))
vi.mock('@/lib/resume-sleeping-agent-session', () => ({
resumeSleepingAgentSessionsForWorktree: vi.fn()
}))
@@ -34,50 +42,77 @@ vi.mock('../terminal-pane/terminal-parked-tab-watchers', () => ({
disposeAllParkedTerminalWatchers: vi.fn()
}))
-;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
+ configurable: true,
+ value: true,
+ writable: true
+})
let root: Root | undefined
+
afterEach(async () => {
await act(async () => root?.unmount())
- vi.clearAllMocks()
+ root = undefined
+ mocks.recover.mockReset()
+ mocks.resetUuid()
})
-function Watcher(): null {
- useTerminalWatcherEffects({
- activeWorktreeId: 'wt-1',
+function Watcher({ activeWorktreeId }: { activeWorktreeId: string }): null {
+ const controller = {
+ activeWorktreeId,
workspaceSessionReady: true,
terminalStartupRestorationReady: true,
workspaceSurfaceIds: [],
- tabsByWorktree: {},
- createTab: mocks.createTab,
- reconcileWorktreeTabModel: () => ({ renderableTabCount: 0 })
- } as unknown as TerminalColdActivationController)
+ tabsByWorktree: {}
+ }
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This hook test exercises only the startup fields; unused controller dependencies stay inert.
+ useTerminalWatcherEffects(controller as unknown as TerminalColdActivationController)
return null
}
-describe('passive terminal seeding during native chat creation', () => {
- it.each([
- ['claude', 'pending', 0],
- ['codex', 'pending', 0],
- ['claude', 'unknown', 0],
- ['codex', 'unknown', 0],
- ['claude', 'idle', 1]
- ] as const)('handles %s launch status %s', async (agent, status, expectedTabs) => {
- let finishGate!: (outcome: 'empty') => void
- mocks.gate.mockReturnValue(
+describe('passive activation recovery', () => {
+ it('observes the folder-key general-setter path during startup', async () => {
+ mocks.recover.mockResolvedValue({ kind: 'intentional-empty' })
+ root = createRoot(document.createElement('div'))
+
+ await act(async () => root?.render())
+
+ expect(mocks.recover).toHaveBeenCalledWith(
+ {
+ workspaceKey: 'folder:workspace-1',
+ executionHostId: 'local',
+ runtimeEnvironmentId: null,
+ attemptId: 'startup-recovery-1'
+ },
+ { mode: 'startup', signal: expect.any(AbortSignal) }
+ )
+ })
+
+ it('does not turn later state-only selections into new recovery launches', async () => {
+ mocks.recover.mockResolvedValue({ kind: 'materialized' })
+ root = createRoot(document.createElement('div'))
+ await act(async () => root?.render())
+ await act(async () => undefined)
+
+ await act(async () => root?.render())
+
+ expect(mocks.recover).toHaveBeenCalledOnce()
+ })
+
+ it('aborts an unsettled request without consuming the next startup assessment', async () => {
+ let settleSecond!: () => void
+ mocks.recover.mockReturnValueOnce(new Promise(() => undefined)).mockReturnValueOnce(
new Promise((resolve) => {
- finishGate = resolve
+ settleSecond = () => resolve({ kind: 'intentional-empty' })
})
)
- mocks.launchStatus.mockReturnValue('idle')
root = createRoot(document.createElement('div'))
- await act(async () => root?.render())
+ await act(async () => root?.render())
+ const firstSignal = mocks.recover.mock.calls[0]?.[1]?.signal
- // A create starts after the inventory probe but before its empty result returns.
- mocks.launchStatus.mockImplementation((_worktreeId, provider) =>
- provider === agent ? status : 'idle'
- )
- await act(async () => finishGate('empty'))
+ await act(async () => root?.render())
- expect(mocks.createTab).toHaveBeenCalledTimes(expectedTabs)
+ expect(firstSignal?.aborted).toBe(true)
+ expect(mocks.recover).toHaveBeenCalledTimes(2)
+ await act(async () => settleSecond())
})
})
diff --git a/src/renderer/src/components/terminal/initial-terminal-wiring.test.ts b/src/renderer/src/components/terminal/initial-terminal-wiring.test.ts
index ed56289625e..bb4fdcc7210 100644
--- a/src/renderer/src/components/terminal/initial-terminal-wiring.test.ts
+++ b/src/renderer/src/components/terminal/initial-terminal-wiring.test.ts
@@ -2,39 +2,32 @@ import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
-// Why: the watcher owner is the passive half of the closed-last-terminal contract — it must keep
-// honouring the tombstone while explicit activation re-seeds. Assert the wiring as source text;
-// mounting the full terminal surface costs far more than it returns, and the e2e that covers it
-// only runs when an e2e spec changes.
+// Why: mounting the full terminal surface costs far more than the startup wiring assertion returns.
const TERMINAL_PATH = 'src/renderer/src/components/use-terminal-watcher-effects.ts'
function readSource(relativePath: string): string {
return readFileSync(join(process.cwd(), relativePath), 'utf8')
}
-describe('Terminal auto-create wiring', () => {
+describe('Terminal startup recovery wiring', () => {
const source = readSource(TERMINAL_PATH)
- it('derives the tombstone from the active worktree row', () => {
- expect(source).toContain('Object.hasOwn(tabsByWorktree, activeWorktreeId)')
+ it('routes startup materialization through the recovery boundary', () => {
+ expect(source.split('recoverWorkspaceActivation(').length - 1).toBe(1)
+ expect(source).toContain("{ mode: 'startup', signal: abort.signal }")
})
- it('passes that derivation into shouldAutoCreateInitialTerminal', () => {
- // Why: the regression #14590 fixed is re-introduced by dropping this second argument, and a
- // literal here would pin nothing — it has to be the identifier the effect actually derives.
- // Exactly one call site: a second (flagless) call could otherwise hide behind this one.
- expect(
- source.split('shouldAutoCreateInitialTerminal(').length - 1,
- 'expected exactly one shouldAutoCreateInitialTerminal call in the watcher owner'
- ).toBe(1)
+ it('captures target, host, runtime, and attempt identity', () => {
+ expect(source).toContain('workspaceKey: activeWorktreeId')
+ expect(source).toContain('getExecutionHostIdForWorktree(state, activeWorktreeId)')
+ expect(source).toContain('getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId)')
+ expect(source).toContain('attemptId: createBrowserUuid()')
+ })
+
+ it('keeps recovery retryable until the asynchronous assessment settles', () => {
expect(source).toContain(
- 'shouldAutoCreateInitialTerminal(renderableTabCount, activeWorktreeHasTerminalState)'
+ 'if (!abort.signal.aborted) {\n startupRecoverySettledRef.current = true'
)
- })
-
- it('keeps that derivation in the effect dependencies', () => {
- // Why: without the dep the effect never re-runs when the row appears or disappears.
- // Anchored to the neighbouring dep so a stray trailing comma elsewhere can't satisfy it.
- expect(source).toContain('activeWorktreeId,\n activeWorktreeHasTerminalState,')
+ expect(source).toContain('return () => {\n abort.abort()')
})
})
diff --git a/src/renderer/src/components/use-terminal-watcher-effects.ts b/src/renderer/src/components/use-terminal-watcher-effects.ts
index 3fae0571342..c7b9868272e 100644
--- a/src/renderer/src/components/use-terminal-watcher-effects.ts
+++ b/src/renderer/src/components/use-terminal-watcher-effects.ts
@@ -1,6 +1,5 @@
import { useEffect, useMemo, useRef } from 'react'
import { findActivityTerminalPortal } from './activity/activity-terminal-portal'
-import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal'
import {
canWatcherCoverParkedTerminalTab,
disposeAllParkedTerminalWatchers,
@@ -10,12 +9,15 @@ import {
type ParkedTerminalTabWatcherSyncEntry
} from './terminal-pane/terminal-parked-tab-watchers'
import { useAppStore } from '@/store'
-import { gateWorktreeAgentActivation } from '@/lib/worktree-agent-activation-gate'
import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session'
import { createWorkspaceTerminalHostAuthoritySelector } from '@/lib/workspace-terminal-host-authority'
-import { getStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch'
-import { AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS } from '../../../shared/agent-session-provider-handle'
import type { TerminalColdActivationController } from './terminal-cold-activation'
+import { recoverWorkspaceActivation } from '@/lib/worktree-activation-recovery'
+import {
+ getExecutionHostIdForWorktree,
+ getRuntimeEnvironmentIdForWorktree
+} from '@/lib/worktree-runtime-owner'
+import { createBrowserUuid } from '@/lib/browser-uuid'
// Why shared: surfaces without watchable live tabs need no per-pass allocation.
const NO_PARKED_TAB_IDS: ReadonlySet = new Set()
@@ -30,7 +32,6 @@ type TerminalWatcherController = Pick<
| 'activityTerminalPortals'
| 'anyMountedWorktreeHasLayout'
| 'backgroundMountRevision'
- | 'createTab'
| 'effectiveParkedTerminalWorktreeIds'
| 'evictionExemptTerminalTabIds'
| 'getEffectiveLayoutForWorktree'
@@ -40,7 +41,6 @@ type TerminalWatcherController = Pick<
| 'mountedWorktreeIdsRef'
| 'pairedRuntimeParkingEnvironmentIds'
| 'pendingStartupByTabId'
- | 'reconcileWorktreeTabModel'
| 'renderedActiveWorktreeId'
| 'tabsByWorktree'
| 'terminalParkingEnabled'
@@ -62,7 +62,6 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController)
activityTerminalPortals,
anyMountedWorktreeHasLayout,
backgroundMountRevision,
- createTab,
effectiveParkedTerminalWorktreeIds,
evictionExemptTerminalTabIds,
getEffectiveLayoutForWorktree,
@@ -72,7 +71,6 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController)
mountedWorktreeIdsRef,
pairedRuntimeParkingEnvironmentIds,
pendingStartupByTabId,
- reconcileWorktreeTabModel,
renderedActiveWorktreeId,
tabsByWorktree,
terminalParkingEnabled,
@@ -83,6 +81,7 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController)
workspaceSessionReady,
workspaceSurfaceIds
} = controller
+ const startupRecoverySettledRef = useRef(false)
useEffect(() => {
pruneParkedTerminalWatchers(terminalWatcherLiveWorkspaceIds(workspaceSurfaceIds))
@@ -183,12 +182,6 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController)
])
useEffect(() => () => disposeAllParkedTerminalWatchers(), [])
- const startupActivationGateWorktreeIdsRef = useRef(new Set())
- // Why (main): a missing row means never initialized, an explicit empty row means the user
- // closed the last terminal — so the gate must not re-seed one in the second case.
- const activeWorktreeHasTerminalState = activeWorktreeId
- ? Object.hasOwn(tabsByWorktree, activeWorktreeId)
- : false
// Why a store subscription rather than a read inside the effects: the verdict flips to `none` the
// moment the execution host answers, and that transition is what re-runs the passes below.
// Why the retained selector: resolution walks the owner catalogs, so recomputing it on every store
@@ -198,53 +191,43 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController)
[activeWorktreeId]
)
const activeWorktreeHostAuthority = useAppStore(hostAuthoritySelector)
+ const activeWorkspaceExecutionHostId = useAppStore(
+ (state) => state.activeWorkspaceExecutionHostId
+ )
useEffect(() => {
- if (!workspaceSessionReady || !terminalStartupRestorationReady || !activeWorktreeId) {
+ if (!workspaceSessionReady || !terminalStartupRestorationReady) {
+ startupRecoverySettledRef.current = false
return
}
- // Why: the execution host owns terminal creation, and a host that has not answered is not a host
- // with no terminals — seeding into that gap duplicates its tabs on every launch (STA-4658).
- if (activeWorktreeHostAuthority !== 'none') {
+ if (!activeWorktreeId || startupRecoverySettledRef.current) {
return
}
- if (startupActivationGateWorktreeIdsRef.current.has(activeWorktreeId)) {
- return
- }
- startupActivationGateWorktreeIdsRef.current.add(activeWorktreeId)
- let cancelled = false
- void gateWorktreeAgentActivation(activeWorktreeId).then((outcome) => {
- if (
- cancelled ||
- outcome !== 'empty' ||
- useAppStore.getState().activeWorktreeId !== activeWorktreeId
- ) {
- return
- }
- // A pending or unanswered chat create owns the surface even before its tab is published.
- if (
- AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS.some(
- (agent) => getStructuredAgentLaunchStatus(activeWorktreeId, agent) !== 'idle'
- )
- ) {
- 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, activeWorktreeHasTerminalState)) {
- // 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 })
- }
- })
+ const state = useAppStore.getState()
+ const abort = new AbortController()
+ void recoverWorkspaceActivation(
+ {
+ workspaceKey: activeWorktreeId,
+ executionHostId: getExecutionHostIdForWorktree(state, activeWorktreeId),
+ runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId),
+ attemptId: createBrowserUuid()
+ },
+ { mode: 'startup', signal: abort.signal }
+ ).then(
+ () => {
+ if (!abort.signal.aborted) {
+ startupRecoverySettledRef.current = true
+ }
+ },
+ () => undefined
+ )
return () => {
- cancelled = true
+ abort.abort()
}
}, [
- activeWorktreeId,
- activeWorktreeHasTerminalState,
+ activeWorkspaceExecutionHostId,
activeWorktreeHostAuthority,
- createTab,
- reconcileWorktreeTabModel,
+ activeWorktreeId,
terminalStartupRestorationReady,
workspaceSessionReady
])
diff --git a/src/renderer/src/hooks/composer-state/full-creation-execution.test.ts b/src/renderer/src/hooks/composer-state/full-creation-execution.test.ts
index 1195a5fa82c..c4a9bdacc40 100644
--- a/src/renderer/src/hooks/composer-state/full-creation-execution.test.ts
+++ b/src/renderer/src/hooks/composer-state/full-creation-execution.test.ts
@@ -7,6 +7,64 @@ import {
type FullCreationExecutionInput
} from './full-creation-execution'
import type { PreparedFullSubmit } from './composer-submit-model'
+import type { CreateWorktreeResult } from '../../../../shared/worktree/create-types'
+import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan'
+import type * as FullCreationStructuredLaunchModule from './full-creation-structured-launch'
+
+const mocks = vi.hoisted(() => ({
+ activateAndRevealWorktree: vi.fn(),
+ planAgentSessionLaunch: vi.fn(),
+ queueStandaloneSetupTab: vi.fn(),
+ registerWorkspaceSurfaceProducer: vi.fn(),
+ settleFullCreationStructuredLaunch: vi.fn(),
+ store: {
+ settings: {},
+ reconcileWorktreeTabModel: vi.fn()
+ },
+ surfaceProducer: {
+ attempt: {
+ id: 'full-creation-structured-attempt',
+ workspaceKey: 'repo-1::/repo/worktree',
+ executionHostId: 'local',
+ result: Promise.resolve({ kind: 'failed' as const, reason: 'setup queue failed' })
+ },
+ materialized: vi.fn(),
+ declined: vi.fn(),
+ failed: vi.fn(),
+ unverifiable: vi.fn()
+ }
+}))
+
+vi.mock('@/store', () => ({
+ useAppStore: {
+ getState: () => mocks.store
+ }
+}))
+
+vi.mock('@/lib/agent-session-launch-plan', () => ({
+ planAgentSessionLaunch: mocks.planAgentSessionLaunch
+}))
+
+vi.mock('@/lib/worktree-activation', () => ({
+ activateAndRevealWorktree: mocks.activateAndRevealWorktree
+}))
+
+vi.mock('./full-creation-structured-launch', async (importOriginal) => ({
+ ...(await importOriginal()),
+ settleFullCreationStructuredLaunch: mocks.settleFullCreationStructuredLaunch
+}))
+
+vi.mock('@/lib/workspace-surface-production', () => ({
+ registerWorkspaceSurfaceProducer: mocks.registerWorkspaceSurfaceProducer
+}))
+
+vi.mock('@/lib/worktree-runtime-owner', () => ({
+ getExecutionHostIdForWorktree: () => 'local'
+}))
+
+vi.mock('@/lib/worktree-setup-issue-command-queue', () => ({
+ queueStandaloneSetupTab: mocks.queueStandaloneSetupTab
+}))
function deferred() {
let resolve!: (value: T) => void
@@ -102,4 +160,124 @@ describe('useFullCreationExecution cancellation', () => {
expect(createWorktree).not.toHaveBeenCalled()
})
+
+ it('settles structured surface ownership when post-registration setup throws', async () => {
+ const prepared = {
+ submitLinkedWorkItem: null,
+ submitLinkedIssueNumber: null,
+ submitLinkedPR: null,
+ submitTitleName: null,
+ nameIsAutoManaged: false,
+ smartGitHubCreateNames: {
+ workspaceName: 'workspace',
+ displayName: undefined
+ },
+ workspaceName: 'workspace',
+ nameWasGenerated: false,
+ submitBaseBranch: 'main',
+ submitCompareBaseRef: undefined,
+ submitPushTarget: undefined,
+ submitBranchNameOverride: undefined,
+ submitLinkedWorkItemProvider: null,
+ submitStartupPrompt: '',
+ submitShouldRunIssueAutomation: false,
+ effectiveSetupDecision: 'skip',
+ issueCommandTrustDecision: 'skip',
+ confirmedIssueCommandTemplate: '',
+ linkedLinearIssue: undefined,
+ linkedLinearIssueWorkspaceId: undefined,
+ linkedLinearIssueOrganizationUrlKey: undefined,
+ effectiveBranchNameOverride: undefined,
+ createDisplayName: undefined,
+ pendingFirstAgentMessageRename: false,
+ startupPlan: null,
+ shouldSeedInitialAgentStatus: false,
+ composerTelemetry: {
+ agent_kind: 'claude-code',
+ launch_source: 'new_workspace_composer',
+ request_kind: 'new'
+ },
+ backendStartup: undefined
+ } satisfies PreparedFullSubmit
+ const created = {
+ worktree: {
+ id: 'repo-1::/repo/worktree',
+ repoId: 'repo-1',
+ displayName: 'workspace',
+ comment: '',
+ linkedIssue: null,
+ linkedPR: null,
+ linkedLinearIssue: null,
+ isArchived: false,
+ isUnread: false,
+ isPinned: false,
+ sortOrder: 0,
+ lastActivityAt: 0,
+ path: '/repo/worktree',
+ head: 'abc123',
+ branch: 'feature',
+ isBare: false,
+ isMainWorktree: false
+ },
+ setup: {
+ runnerScriptPath: '/repo/.git/orca/setup-runner.sh',
+ envVars: {}
+ }
+ } satisfies CreateWorktreeResult
+ const structuredPlan = {
+ route: 'structured-native-chat',
+ agent: 'claude',
+ prompt: '',
+ launch: vi.fn()
+ } satisfies AgentSessionLaunchPlan
+ const createWorktree = vi
+ .fn()
+ .mockResolvedValue(created)
+ const state = {
+ applyWorktreeMeta: vi
+ .fn()
+ .mockResolvedValue(),
+ clearNewWorkspaceDraft: vi.fn(),
+ createWorktree,
+ effectivePresetId: null,
+ isSubmissionCancelled: () => false,
+ linkedGitLabIssue: null,
+ linkedGitLabMR: null,
+ normalizedSparseDirectories: [],
+ note: '',
+ onCreated: vi.fn>(),
+ parentWorktreeId: null,
+ persistDraft: false,
+ persistSetupAgentStartupPolicy: vi.fn(async () => true),
+ prepareFullSubmit: vi
+ .fn()
+ .mockResolvedValue(prepared),
+ resolvedInitialWorkspaceStatus: undefined,
+ selectedRepoExecutionHostId: 'local',
+ selectedRepoIsGit: true,
+ setSidebarOpen: vi.fn(),
+ sparseEnabled: false,
+ taskSourceContext: null,
+ telemetrySource: undefined,
+ tuiAgent: 'claude'
+ } satisfies FullCreationExecutionInput
+ mocks.planAgentSessionLaunch.mockReturnValueOnce(structuredPlan)
+ mocks.registerWorkspaceSurfaceProducer.mockReturnValueOnce(mocks.surfaceProducer)
+ mocks.queueStandaloneSetupTab.mockImplementationOnce(() => {
+ throw new Error('setup queue failed')
+ })
+ const hook = renderHook(() => useFullCreationExecution(state))
+
+ await expect(
+ hook.result.current.executeFullCreation({ kind: 'none' }, 'repo-1')
+ ).rejects.toThrow('setup queue failed')
+
+ expect(mocks.registerWorkspaceSurfaceProducer).toHaveBeenCalledWith({
+ workspaceKey: 'repo-1::/repo/worktree',
+ executionHostId: 'local'
+ })
+ expect(mocks.surfaceProducer.failed).toHaveBeenCalledWith(expect.any(Error))
+ expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled()
+ expect(mocks.settleFullCreationStructuredLaunch).not.toHaveBeenCalled()
+ })
})
diff --git a/src/renderer/src/hooks/composer-state/full-creation-execution.ts b/src/renderer/src/hooks/composer-state/full-creation-execution.ts
index 1ad5a92abab..66c1e28b64a 100644
--- a/src/renderer/src/hooks/composer-state/full-creation-execution.ts
+++ b/src/renderer/src/hooks/composer-state/full-creation-execution.ts
@@ -38,7 +38,11 @@ import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/na
import { queueWorkspaceActivationTerminalFocus } from '@/lib/workspace-activation-terminal-focus'
import { useAppStore } from '@/store'
import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan'
-import { settleFullCreationStructuredLaunch } from './full-creation-structured-launch'
+import {
+ beginFullCreationSurfaceProduction,
+ settleFullCreationStructuredLaunch,
+ settleFullCreationSurfaceProduction
+} from './full-creation-structured-launch'
import { finalizeFullCreation } from './full-creation-finalization'
import { buildFullCreationIssueCommand } from './full-creation-issue-command'
import { buildFullCreationStartup } from './full-creation-startup'
@@ -217,25 +221,43 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) {
telemetry: composerTelemetry
})
- const initialActivation = activateAndRevealWorktree(worktree.id, {
- sidebarRevealBehavior: 'auto',
- agent: tuiAgent,
- setup: result.setup,
- defaultTabs: result.defaultTabs,
- issueCommand,
- ...(backendSpawnedStartup ? { backendStartupTerminalSpawned: true } : {}),
- ...(!structuredLaunch && startup ? { startup } : {}),
- ...(structuredLaunch ? { providesInitialSurface: true } : {})
- })
+ const { producer: structuredProducer, setupRunsWithoutPrimary } =
+ beginFullCreationSurfaceProduction({
+ structuredLaunch,
+ worktreeId: worktree.id,
+ setup: result.setup,
+ issueCommand,
+ defaultTabs: result.defaultTabs
+ })
+ let initialActivation: ReturnType
+ let settlement: Awaited>
+ try {
+ initialActivation = activateAndRevealWorktree(worktree.id, {
+ sidebarRevealBehavior: 'auto',
+ agent: tuiAgent,
+ setup: setupRunsWithoutPrimary ? undefined : result.setup,
+ defaultTabs: result.defaultTabs,
+ issueCommand,
+ ...(backendSpawnedStartup ? { backendStartupTerminalSpawned: true } : {}),
+ ...(!structuredLaunch && startup ? { startup } : {})
+ })
+ if (initialActivation === false) {
+ structuredProducer?.failed('The workspace is no longer available.')
+ }
- const settlement = await settleFullCreationStructuredLaunch({
- plan: launchPlan,
- agent: tuiAgent,
- worktreeId: worktree.id,
- startup,
- pendingFirstAgentMessageRename,
- applyWorktreeMeta
- })
+ settlement = await settleFullCreationStructuredLaunch({
+ plan: launchPlan,
+ agent: tuiAgent,
+ worktreeId: worktree.id,
+ startup,
+ pendingFirstAgentMessageRename,
+ applyWorktreeMeta
+ })
+ settleFullCreationSurfaceProduction(structuredProducer, worktree.id, settlement)
+ } catch (error) {
+ structuredProducer?.failed(error)
+ throw error
+ }
// Why: both leave the workspace revealed and the composer text intact; the launch layer has
// already toasted a failure, and an unknown outcome reconciles on the next click.
diff --git a/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts b/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts
index 4293e97505a..a9fa21eaade 100644
--- a/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts
+++ b/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts
@@ -4,6 +4,45 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement'
import { activateStructuredAgentSessionById } from '@/lib/structured-agent-session-tab-activation'
import type { TuiAgent } from '../../../../shared/tui-agent'
+import { useAppStore } from '@/store'
+import {
+ registerWorkspaceSurfaceProducer,
+ type WorkspaceSurfaceProducer
+} from '@/lib/workspace-surface-production'
+import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
+import { queueStandaloneSetupTab } from '@/lib/worktree-setup-issue-command-queue'
+import { settleStructuredAgentSurfaceProducer } from '@/lib/structured-agent-surface-production'
+
+type StandaloneSetupArgs = Parameters[0]
+
+export function beginFullCreationSurfaceProduction(
+ args: Omit & { structuredLaunch: boolean }
+): { producer: WorkspaceSurfaceProducer | null; setupRunsWithoutPrimary: boolean } {
+ const store = useAppStore.getState()
+ const producer = args.structuredLaunch
+ ? registerWorkspaceSurfaceProducer({
+ workspaceKey: args.worktreeId,
+ executionHostId: getExecutionHostIdForWorktree(store, args.worktreeId)
+ })
+ : null
+ try {
+ const setupRunsWithoutPrimary = producer !== null && queueStandaloneSetupTab({ ...args, store })
+ return { producer, setupRunsWithoutPrimary }
+ } catch (error) {
+ producer?.failed(error)
+ throw error
+ }
+}
+
+export function settleFullCreationSurfaceProduction(
+ producer: WorkspaceSurfaceProducer | null,
+ worktreeId: string,
+ settlement: StructuredAgentLaunchSettlement | null
+): void {
+ if (producer) {
+ settleStructuredAgentSurfaceProducer(producer, worktreeId, settlement)
+ }
+}
/** Full-create dialog: the structured launch plus what this flow did before structured chat
* existed. Returns null when the plan's route is not structured. */
diff --git a/src/renderer/src/lib/direct-work-item-surface-production.test.ts b/src/renderer/src/lib/direct-work-item-surface-production.test.ts
new file mode 100644
index 00000000000..c17e8d1605a
--- /dev/null
+++ b/src/renderer/src/lib/direct-work-item-surface-production.test.ts
@@ -0,0 +1,105 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { useAppStore } from '@/store'
+import type { WorkspaceSurfaceProducer } from './workspace-surface-production'
+
+const mocks = vi.hoisted(() => ({
+ register: vi.fn(),
+ queueSetup: vi.fn(() => true)
+}))
+
+vi.mock('./workspace-surface-production', () => ({
+ registerWorkspaceSurfaceProducer: mocks.register
+}))
+vi.mock('./worktree-runtime-owner', () => ({
+ getExecutionHostIdForWorktree: () => 'local'
+}))
+vi.mock('./worktree-setup-issue-command-queue', () => ({
+ queueStandaloneSetupTab: mocks.queueSetup
+}))
+
+import {
+ beginDirectWorkItemSurfaceProduction,
+ settleDirectWorkItemSurfaceProduction
+} from './direct-work-item-surface-production'
+
+const producer: WorkspaceSurfaceProducer = {
+ attempt: {
+ id: 'attempt-1',
+ workspaceKey: 'worktree-1',
+ executionHostId: 'local',
+ result: Promise.resolve({ kind: 'failed', reason: 'not used' })
+ },
+ materialized: vi.fn(),
+ declined: vi.fn(),
+ failed: vi.fn(),
+ unverifiable: vi.fn()
+}
+
+describe('direct work item surface production', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.register.mockReturnValue(producer)
+ })
+
+ it('registers concrete ownership before queueing setup-only content', () => {
+ const result = beginDirectWorkItemSurfaceProduction({
+ store: useAppStore.getState(),
+ structuredLaunch: true,
+ worktreeId: 'worktree-1',
+ setup: { runnerScriptPath: '/tmp/setup.sh', envVars: {} },
+ issueCommand: undefined,
+ defaultTabs: undefined
+ })
+
+ expect(mocks.register).toHaveBeenCalledWith({
+ workspaceKey: 'worktree-1',
+ executionHostId: 'local'
+ })
+ expect(result).toEqual({ producer, setupRunsWithoutPrimary: true })
+ })
+
+ it('does not mint ownership from an unstructured selection', () => {
+ const result = beginDirectWorkItemSurfaceProduction({
+ store: useAppStore.getState(),
+ structuredLaunch: false,
+ worktreeId: 'worktree-1',
+ setup: undefined,
+ issueCommand: undefined,
+ defaultTabs: undefined
+ })
+
+ expect(mocks.register).not.toHaveBeenCalled()
+ expect(mocks.queueSetup).not.toHaveBeenCalled()
+ expect(result).toEqual({ producer: null, setupRunsWithoutPrimary: false })
+ })
+
+ it('settles a structured launch with its exact session tab identity', () => {
+ settleDirectWorkItemSurfaceProduction(producer, {
+ completed: true,
+ structuredLaunch: true,
+ visibilityUnknown: false,
+ failed: false,
+ primaryTabId: null,
+ structuredSessionId: 'session-12'
+ })
+
+ expect(producer.materialized).toHaveBeenCalledWith({
+ kind: 'tab',
+ id: 'agent-session:session-12'
+ })
+ })
+
+ it('retains producer ownership when the host result is unknown', () => {
+ settleDirectWorkItemSurfaceProduction(producer, {
+ completed: false,
+ structuredLaunch: true,
+ visibilityUnknown: true,
+ failed: false,
+ primaryTabId: null
+ })
+
+ expect(producer.unverifiable).toHaveBeenCalledWith(
+ 'The execution host may have accepted the agent launch.'
+ )
+ })
+})
diff --git a/src/renderer/src/lib/direct-work-item-surface-production.ts b/src/renderer/src/lib/direct-work-item-surface-production.ts
new file mode 100644
index 00000000000..9de816f337a
--- /dev/null
+++ b/src/renderer/src/lib/direct-work-item-surface-production.ts
@@ -0,0 +1,62 @@
+import type { AppState } from '@/store'
+import type { settleDirectWorkItemStructuredLaunch } from './launch-work-item-direct-agent-routing'
+import { getExecutionHostIdForWorktree } from './worktree-runtime-owner'
+import {
+ registerWorkspaceSurfaceProducer,
+ type WorkspaceSurfaceProducer
+} from './workspace-surface-production'
+import { queueStandaloneSetupTab } from './worktree-setup-issue-command-queue'
+
+type StandaloneSetupArgs = Parameters[0]
+type StructuredSettlement = Awaited>
+
+export function beginDirectWorkItemSurfaceProduction(
+ args: Omit & {
+ store: AppState
+ structuredLaunch: boolean
+ }
+): { producer: WorkspaceSurfaceProducer | null; setupRunsWithoutPrimary: boolean } {
+ const producer = args.structuredLaunch
+ ? registerWorkspaceSurfaceProducer({
+ workspaceKey: args.worktreeId,
+ executionHostId: getExecutionHostIdForWorktree(args.store, args.worktreeId)
+ })
+ : null
+ try {
+ const setupRunsWithoutPrimary =
+ producer !== null &&
+ queueStandaloneSetupTab({
+ store: args.store,
+ worktreeId: args.worktreeId,
+ setup: args.setup,
+ issueCommand: args.issueCommand,
+ defaultTabs: args.defaultTabs,
+ ...(args.opts ? { opts: args.opts } : {})
+ })
+ return { producer, setupRunsWithoutPrimary }
+ } catch (error) {
+ producer?.failed(error)
+ throw error
+ }
+}
+
+export function settleDirectWorkItemSurfaceProduction(
+ producer: WorkspaceSurfaceProducer,
+ result: StructuredSettlement
+): void {
+ if (result.visibilityUnknown) {
+ producer.unverifiable('The execution host may have accepted the agent launch.')
+ } else if (result.failed) {
+ producer.failed('The agent launch did not publish a surface.')
+ } else if (result.structuredSessionId) {
+ producer.materialized({ kind: 'tab', id: `agent-session:${result.structuredSessionId}` })
+ } else if (result.primaryTabId) {
+ producer.materialized({ kind: 'tab', id: result.primaryTabId })
+ } else if (result.completed) {
+ producer.unverifiable(
+ 'The agent launch succeeded, but its exact surface identity is unavailable.'
+ )
+ } else {
+ producer.failed('The agent launch fallback did not publish a surface.')
+ }
+}
diff --git a/src/renderer/src/lib/fix-checks-agent-launch.test.ts b/src/renderer/src/lib/fix-checks-agent-launch.test.ts
index 785244bbddf..bb3fdcd340c 100644
--- a/src/renderer/src/lib/fix-checks-agent-launch.test.ts
+++ b/src/renderer/src/lib/fix-checks-agent-launch.test.ts
@@ -156,7 +156,7 @@ describe('startFixChecksAgent', () => {
mocks.resolveSourceControlLaunchPlatform.mockReturnValue('darwin')
})
- it('activates the attached workspace as a surface-providing caller', async () => {
+ it('activates the attached workspace before its registered launch', async () => {
const { startFixChecksAgent } = await import('./fix-checks-agent-launch')
await expect(
@@ -168,11 +168,7 @@ describe('startFixChecksAgent', () => {
})
).resolves.toBe(true)
- // Why: launchAgentInNewTab creates the surface; without the opt-out, activation would
- // also re-seed a shell in a closed-last-terminal workspace.
- expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', {
- providesInitialSurface: true
- })
+ expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1')
})
it('fails without launching when the requested worktree is missing', async () => {
diff --git a/src/renderer/src/lib/fix-checks-agent-launch.ts b/src/renderer/src/lib/fix-checks-agent-launch.ts
index 1a052fc2782..241b785a177 100644
--- a/src/renderer/src/lib/fix-checks-agent-launch.ts
+++ b/src/renderer/src/lib/fix-checks-agent-launch.ts
@@ -25,6 +25,9 @@ import type { TuiAgent } from '../../../shared/tui-agent'
import type { WorkspaceSource as WorkspaceCreateTelemetrySource } from '../../../shared/workspace-source'
import type { LaunchSource } from '../../../shared/telemetry-events'
import { translate } from '@/i18n/i18n'
+import { registerWorkspaceSurfaceProducer } from '@/lib/workspace-surface-production'
+import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
+import { settleStructuredAgentSurfaceProducer } from '@/lib/structured-agent-surface-production'
type StartFixChecksAgentArgs = {
repoId: string
@@ -186,39 +189,58 @@ export async function startFixChecksAgent(args: StartFixChecksAgentArgs): Promis
toast.error(agentArgsPlan.error)
return false
}
- // launchAgentInNewTab below creates the surface; seeding here would add a stray shell.
- if (!activateAndRevealWorktree(targetWorktreeId, { providesInitialSurface: true })) {
- toast.error(
- translate(
- 'auto.lib.fix.checks.agent.launch.03c1d61f83',
- 'Unable to open the workspace attached to these checks.'
- )
- )
- return false
- }
- const result = launchAgentInNewTab({
- agent,
- worktreeId: targetWorktreeId,
- groupId: args.groupId ?? targetWorktreeId,
- prompt: commandInput,
- agentArgs: recipe.agentArgs,
- promptDelivery: 'submit-after-ready',
- launchPlatform,
- launchSource: args.launchSource
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey: targetWorktreeId,
+ executionHostId: getExecutionHostIdForWorktree(store, targetWorktreeId)
})
- if (!result) {
- toast.error(
- translate(
- 'auto.lib.fix.checks.agent.launch.fb6c294e85',
- 'Could not build the agent launch command.'
+ try {
+ if (activateAndRevealWorktree(targetWorktreeId) === false) {
+ producer.failed('The workspace is no longer available.')
+ toast.error(
+ translate(
+ 'auto.lib.fix.checks.agent.launch.03c1d61f83',
+ 'Unable to open the workspace attached to these checks.'
+ )
)
- )
- return false
+ return false
+ }
+ const result = launchAgentInNewTab({
+ agent,
+ worktreeId: targetWorktreeId,
+ groupId: args.groupId ?? targetWorktreeId,
+ prompt: commandInput,
+ agentArgs: recipe.agentArgs,
+ promptDelivery: 'submit-after-ready',
+ launchPlatform,
+ launchSource: args.launchSource
+ })
+ if (!result) {
+ producer.failed('Could not build the agent launch command.')
+ toast.error(
+ translate(
+ 'auto.lib.fix.checks.agent.launch.fb6c294e85',
+ 'Could not build the agent launch command.'
+ )
+ )
+ return false
+ }
+ if (result.tabId) {
+ producer.materialized({ kind: 'tab', id: result.tabId })
+ focusTerminalTabSurface(result.tabId)
+ } else if (result.structuredSettlement) {
+ void result.structuredSettlement.then(
+ (settlement) =>
+ settleStructuredAgentSurfaceProducer(producer, targetWorktreeId, settlement),
+ (error: unknown) => producer.failed(error)
+ )
+ } else {
+ producer.failed('The agent launch did not publish a surface.')
+ }
+ return true
+ } catch (error) {
+ producer.failed(error)
+ throw error
}
- if (result.tabId) {
- focusTerminalTabSurface(result.tabId)
- }
- return true
}
if (!args.item || !args.openModalFallback) {
diff --git a/src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts b/src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts
index 18c7ed5f88e..399709cd6d9 100644
--- a/src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts
+++ b/src/renderer/src/lib/launch-work-item-direct-agent-routing.test.ts
@@ -59,7 +59,8 @@ describe('settleDirectWorkItemStructuredLaunch', () => {
structuredLaunch: true,
visibilityUnknown: false,
failed: false,
- primaryTabId: null
+ primaryTabId: null,
+ structuredSessionId: 'draft-session'
})
expect(mocks.settleStructuredAgentLaunch).toHaveBeenCalledWith(
'worktree-1',
@@ -112,26 +113,41 @@ describe('settleDirectWorkItemStructuredLaunch', () => {
expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled()
})
- it.each([
- ['failed', { kind: 'failed', error: new Error('x') }],
- ['cancelled', { kind: 'cancelled', sessionId: 'session-1' }]
- ])(
- 'drops the pre-launch tab on a %s settlement so nothing is pasted into it',
- async (_kind, settlement) => {
- mocks.settleStructuredAgentLaunch.mockResolvedValue(settlement)
+ it('drops the pre-launch tab on a failed settlement so nothing is pasted into it', async () => {
+ mocks.settleStructuredAgentLaunch.mockResolvedValue({
+ kind: 'failed',
+ error: new Error('x')
+ })
- await expect(
- settleDirectWorkItemStructuredLaunch({ ...baseArgs, primaryTabId: 'setup-shell-tab' })
- ).resolves.toEqual({
- completed: false,
- structuredLaunch: true,
- visibilityUnknown: false,
- failed: true,
- primaryTabId: null
- })
- expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled()
- }
- )
+ await expect(
+ settleDirectWorkItemStructuredLaunch({ ...baseArgs, primaryTabId: 'setup-shell-tab' })
+ ).resolves.toEqual({
+ completed: false,
+ structuredLaunch: true,
+ visibilityUnknown: false,
+ failed: true,
+ primaryTabId: null
+ })
+ expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled()
+ })
+
+ it('retains producer ownership as unknown when a structured settlement is cancelled', async () => {
+ mocks.settleStructuredAgentLaunch.mockResolvedValue({
+ kind: 'cancelled',
+ sessionId: 'session-1'
+ })
+
+ await expect(
+ settleDirectWorkItemStructuredLaunch({ ...baseArgs, primaryTabId: 'setup-shell-tab' })
+ ).resolves.toEqual({
+ completed: false,
+ structuredLaunch: true,
+ visibilityUnknown: true,
+ failed: false,
+ primaryTabId: null
+ })
+ expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled()
+ })
it('skips the loop when the route is not structured', async () => {
await expect(
diff --git a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts
index 2189eed7236..dd309514e39 100644
--- a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts
+++ b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts
@@ -117,6 +117,7 @@ export async function settleDirectWorkItemStructuredLaunch(args: {
/** The structured launch ended without a surface; there is nothing for the legacy path to finish. */
failed: boolean
primaryTabId: string | null
+ structuredSessionId?: string
}> {
const { plan } = args
const notLaunched = (structuredLaunch: boolean) => ({
@@ -176,7 +177,8 @@ export async function settleDirectWorkItemStructuredLaunch(args: {
structuredLaunch: true,
visibilityUnknown: false,
failed: false,
- primaryTabId: args.primaryTabId
+ primaryTabId: args.primaryTabId,
+ structuredSessionId: settlement.sessionId
}
case 'refused-then-legacy':
return {
@@ -195,8 +197,9 @@ export async function settleDirectWorkItemStructuredLaunch(args: {
primaryTabId: args.primaryTabId
}
case 'failed':
- case 'cancelled':
// Why: the launch layer already toasted the failure.
return withoutAgentSurface
+ case 'cancelled':
+ return { ...withoutAgentSurface, visibilityUnknown: true, failed: false }
}
}
diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts
index 8e7734908ea..8141c8fe79a 100644
--- a/src/renderer/src/lib/launch-work-item-direct.ts
+++ b/src/renderer/src/lib/launch-work-item-direct.ts
@@ -40,6 +40,11 @@ import {
planAgentSessionLaunch,
type AgentSessionLaunchPlan
} from '@/lib/agent-session-launch-plan'
+import type { WorkspaceSurfaceProducer } from '@/lib/workspace-surface-production'
+import {
+ beginDirectWorkItemSurfaceProduction,
+ settleDirectWorkItemSurfaceProduction
+} from './direct-work-item-surface-production'
/**
* "Use" flow: create the workspace, activate it, launch the default agent,
@@ -167,6 +172,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
let plan: AgentSessionLaunchPlan | null = null
const draftContent = await getDirectWorkItemDraftContent(item, repoConnectionId)
let startupPlanFailed = false
+ let structuredProducer: WorkspaceSurfaceProducer | null = null
try {
const result = await store.createWorktree(
repoId,
@@ -229,12 +235,21 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
startupPlanFailed = launchPreparation.startupPlanFailed
plan = launchPreparation.plan
+ const surfaceProduction = beginDirectWorkItemSurfaceProduction({
+ store: latestStore,
+ structuredLaunch: launchPreparation.structuredLaunch,
+ worktreeId,
+ setup: result.setup,
+ issueCommand: undefined,
+ defaultTabs: result.defaultTabs
+ })
+ structuredProducer = surfaceProduction.producer
const activation = activateAndRevealWorktree(worktreeId, {
sidebarRevealBehavior: 'auto',
- setup: result.setup,
+ setup: surfaceProduction.setupRunsWithoutPrimary ? undefined : result.setup,
defaultTabs: result.defaultTabs,
...(launchPreparation.structuredLaunch
- ? { providesInitialSurface: true }
+ ? {}
: buildDirectWorkItemStartupOpts(
effectiveAgent,
startupPlan,
@@ -242,7 +257,8 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
promptDelivery === 'draft' ? draftContent : undefined
))
})
- if (!activation) {
+ if (activation === false) {
+ structuredProducer?.failed('The workspace is no longer available.')
// Worktree vanished between create and activate — extremely unlikely but
// worth handling explicitly rather than silently dropping the draft.
toast.error(workspaceActivationErrorMessage())
@@ -250,6 +266,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
}
primaryTabId = activation.primaryTabId
} catch (error) {
+ structuredProducer?.failed(error)
const message = error instanceof Error ? error.message : 'Failed to create workspace.'
toast.error(message)
return false
@@ -257,15 +274,25 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
store.setSidebarOpen(true)
- const structuredResult = await settleDirectWorkItemStructuredLaunch({
- plan,
- worktreeId,
- workspacePath: worktreePath,
- connectionId: repoConnectionId,
- primaryTabId,
- startupPlan,
- launchSource
- })
+ let structuredResult: Awaited>
+ try {
+ structuredResult = await settleDirectWorkItemStructuredLaunch({
+ plan,
+ worktreeId,
+ workspacePath: worktreePath,
+ connectionId: repoConnectionId,
+ primaryTabId,
+ startupPlan,
+ launchSource
+ })
+ } catch (error) {
+ structuredProducer?.failed(error)
+ toast.error(error instanceof Error ? error.message : 'The agent launch failed.')
+ return false
+ }
+ if (structuredProducer) {
+ settleDirectWorkItemSurfaceProduction(structuredProducer, structuredResult)
+ }
if (structuredResult.visibilityUnknown || structuredResult.failed) {
// Why: callers hang irreversible follow-up work off a `true` here, so a structured launch that
// opened no surface must not report the workspace as started.
diff --git a/src/renderer/src/lib/onboarding-folder-agent-launch.ts b/src/renderer/src/lib/onboarding-folder-agent-launch.ts
index 26d90f5147c..0a53f1eb2d8 100644
--- a/src/renderer/src/lib/onboarding-folder-agent-launch.ts
+++ b/src/renderer/src/lib/onboarding-folder-agent-launch.ts
@@ -11,6 +11,10 @@ import {
type OnboardingFolderAgentStartup
} from '@/lib/onboarding-folder-agent-startup'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
+import { registerWorkspaceSurfaceProducer } from '@/lib/workspace-surface-production'
+import { useAppStore } from '@/store'
+import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
+import { settleStructuredAgentSurfaceProducer } from '@/lib/structured-agent-surface-production'
export type OnboardingFolderAgentLaunch = {
agent: TuiAgent | null
@@ -59,30 +63,47 @@ export async function revealOnboardingFolderWithAgentLaunch(args: {
executionHostId: ExecutionHostId | undefined
launch: OnboardingFolderAgentLaunch
}): Promise {
- const reveal = (
- startup: OnboardingFolderAgentStartup | undefined,
- providesInitialSurface = false
- ) =>
+ const reveal = (startup: OnboardingFolderAgentStartup | undefined) =>
activateAndRevealWorktree(args.worktreeId, {
sidebarRevealBehavior: 'auto',
...(args.executionHostId ? { executionHostId: args.executionHostId } : {}),
- ...(startup ? { startup } : {}),
- ...(providesInitialSurface ? { providesInitialSurface: true } : {})
+ ...(startup ? { startup } : {})
})
const { plan } = args.launch
const structured = plan?.route === 'structured-native-chat'
- reveal(args.launch.startup, structured)
- if (!structured) {
- return
+ const producer = structured
+ ? registerWorkspaceSurfaceProducer({
+ workspaceKey: args.worktreeId,
+ executionHostId:
+ args.executionHostId ??
+ getExecutionHostIdForWorktree(useAppStore.getState(), args.worktreeId)
+ })
+ : null
+ try {
+ const initialActivation = reveal(args.launch.startup)
+ if (initialActivation === false) {
+ producer?.failed('The workspace is no longer available.')
+ }
+ if (!structured) {
+ return
+ }
+ const settlement = await plan.launch(
+ {
+ legacyFallback: async () => {
+ const activation = reveal(args.launch.fallbackStartup)
+ return {
+ activation,
+ primaryTabId: activation === false ? null : activation.primaryTabId
+ }
+ }
+ },
+ { worktreeId: args.worktreeId }
+ )
+ if (producer) {
+ settleStructuredAgentSurfaceProducer(producer, args.worktreeId, settlement)
+ }
+ } catch (error) {
+ producer?.failed(error)
+ throw error
}
- // Why: the outcome is not consumed; the workspace is already revealed and the launch layer toasts.
- await plan.launch(
- {
- legacyFallback: async () => {
- const activation = reveal(args.launch.fallbackStartup)
- return { activation, primaryTabId: activation === false ? null : activation.primaryTabId }
- }
- },
- { worktreeId: args.worktreeId }
- )
}
diff --git a/src/renderer/src/lib/structured-agent-surface-production.test.ts b/src/renderer/src/lib/structured-agent-surface-production.test.ts
new file mode 100644
index 00000000000..07d6a931a5a
--- /dev/null
+++ b/src/renderer/src/lib/structured-agent-surface-production.test.ts
@@ -0,0 +1,69 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import { settleStructuredAgentSurfaceProducer } from './structured-agent-surface-production'
+import {
+ consumeWorkspaceSurfaceProducerAttempt,
+ readWorkspaceSurfaceProducerEntries,
+ registerWorkspaceSurfaceProducer,
+ resetWorkspaceSurfaceProducersForTests
+} from './workspace-surface-production'
+
+const WORKSPACE_KEY = 'worktree-1'
+
+function producer(attemptId: string) {
+ return registerWorkspaceSurfaceProducer({
+ workspaceKey: WORKSPACE_KEY,
+ executionHostId: 'local',
+ attemptId
+ })
+}
+
+beforeEach(() => {
+ resetWorkspaceSurfaceProducersForTests()
+})
+
+describe('structured agent surface production', () => {
+ it.each([
+ ['visibility-unknown', { kind: 'visibility-unknown', sessionId: 'session-1' }],
+ ['cancelled', { kind: 'cancelled', sessionId: 'session-1' }]
+ ] as const)('retains %s execution ownership as unverifiable', (_label, settlement) => {
+ const attempt = producer(`attempt-${_label}`)
+
+ settleStructuredAgentSurfaceProducer(attempt, WORKSPACE_KEY, settlement)
+ consumeWorkspaceSurfaceProducerAttempt(attempt.attempt.id)
+
+ expect(readWorkspaceSurfaceProducerEntries(attempt.attempt)).toMatchObject([
+ { result: { kind: 'unverifiable' } }
+ ])
+ })
+
+ it('carries a definitive structured launch failure to recovery', () => {
+ const attempt = producer('failed-attempt')
+
+ settleStructuredAgentSurfaceProducer(attempt, WORKSPACE_KEY, {
+ kind: 'failed',
+ error: new Error('provider unavailable')
+ })
+
+ expect(readWorkspaceSurfaceProducerEntries(attempt.attempt)).toMatchObject([
+ { result: { kind: 'failed', reason: 'provider unavailable' } }
+ ])
+ })
+
+ it('carries the exact structured tab identity to inventory reconciliation', () => {
+ const attempt = producer('published-attempt')
+
+ settleStructuredAgentSurfaceProducer(attempt, WORKSPACE_KEY, {
+ kind: 'structured',
+ sessionId: 'session-1'
+ })
+
+ expect(readWorkspaceSurfaceProducerEntries(attempt.attempt)).toMatchObject([
+ {
+ result: {
+ kind: 'materialized',
+ surface: { kind: 'tab', id: 'agent-session:session-1' }
+ }
+ }
+ ])
+ })
+})
diff --git a/src/renderer/src/lib/structured-agent-surface-production.ts b/src/renderer/src/lib/structured-agent-surface-production.ts
new file mode 100644
index 00000000000..fbb2c719630
--- /dev/null
+++ b/src/renderer/src/lib/structured-agent-surface-production.ts
@@ -0,0 +1,44 @@
+import type { StructuredAgentLaunchSettlement } from './structured-agent-launch-settlement'
+import type { WorkspaceSurfaceProducer } from './workspace-surface-production'
+
+export function settleStructuredAgentSurfaceProducer(
+ producer: WorkspaceSurfaceProducer,
+ workspaceKey: string,
+ settlement: StructuredAgentLaunchSettlement | null
+): void {
+ if (producer.attempt.workspaceKey !== workspaceKey) {
+ producer.failed('The agent launch settled for a different workspace.')
+ return
+ }
+ if (!settlement) {
+ producer.failed('The agent launch did not start.')
+ return
+ }
+ if (settlement.kind === 'visibility-unknown') {
+ producer.unverifiable('The execution host may have accepted the agent launch.')
+ return
+ }
+ const fallbackTabId =
+ settlement.kind === 'refused-then-legacy'
+ ? settlement.primaryTabId
+ : settlement.kind === 'cancelled'
+ ? settlement.fallback?.primaryTabId
+ : null
+ if (fallbackTabId) {
+ producer.materialized({ kind: 'tab', id: fallbackTabId })
+ return
+ }
+ if (settlement.kind === 'failed') {
+ producer.failed(settlement.error)
+ return
+ }
+ if (settlement.kind === 'cancelled') {
+ producer.unverifiable('The cancelled agent launch could not be reconciled with its host.')
+ return
+ }
+ if (settlement.kind === 'refused-then-legacy') {
+ producer.failed('The agent launch did not publish a surface.')
+ return
+ }
+ producer.materialized({ kind: 'tab', id: `agent-session:${settlement.sessionId}` })
+}
diff --git a/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts b/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts
index bb3cf9f26a8..c6a856c458a 100644
--- a/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts
+++ b/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts
@@ -21,6 +21,8 @@ import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mod
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
import { getConnectionId } from '@/lib/connection-context'
import { toast } from 'sonner'
+import { registerWorkspaceSurfaceProducer } from '@/lib/workspace-surface-production'
+import { toRuntimeExecutionHostId } from '../../../shared/execution-host'
export function ensureWebRuntimeWorktreeTerminalAfterWake(
worktreeId: string,
@@ -78,6 +80,14 @@ export function ensureWebRuntimeWorktreeTerminalAfterWake(
if (!beginWebRuntimeWakeTerminalRespawn(worktreeId)) {
return
}
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey: worktreeId,
+ executionHostId: toRuntimeExecutionHostId(runtimeEnvironmentId)
+ })
+ state.reconcileWorktreeTabModel(worktreeId)
+ const existingSurfaceIds = new Set(
+ (useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)
+ )
const startup = opts?.startup
const viewModeProps = launchAgent
@@ -90,35 +100,52 @@ export function ensureWebRuntimeWorktreeTerminalAfterWake(
})
: {}
// Why: sleep keeps tab rows but terminal.stop clears host PTYs, while a failed create receipt leaves a selected agent with no host surface.
- void createWebRuntimeSessionTerminal({
- worktreeId,
- environmentId: runtimeEnvironmentId,
- ...viewModeProps,
- ...(startup
- ? {
- command: startup.command,
- ...(startup.env ? { env: startup.env } : {}),
- ...(startup.launchConfig ? { launchConfig: startup.launchConfig } : {}),
- ...(startup.launchToken ? { launchToken: startup.launchToken } : {}),
- ...(launchAgent ? { launchAgent, preparedAgentCommand: true } : {}),
- ...(startup.startupCommandDelivery
- ? { startupCommandDelivery: startup.startupCommandDelivery }
- : {})
- }
- : launchAgent
- ? { agent: launchAgent }
- : {}),
- activate: opts?.activate !== false,
- selectWorktree: false
- })
+ void Promise.resolve()
+ .then(() =>
+ createWebRuntimeSessionTerminal({
+ worktreeId,
+ environmentId: runtimeEnvironmentId,
+ ...viewModeProps,
+ ...(startup
+ ? {
+ command: startup.command,
+ ...(startup.env ? { env: startup.env } : {}),
+ ...(startup.launchConfig ? { launchConfig: startup.launchConfig } : {}),
+ ...(startup.launchToken ? { launchToken: startup.launchToken } : {}),
+ ...(launchAgent ? { launchAgent, preparedAgentCommand: true } : {}),
+ ...(startup.startupCommandDelivery
+ ? { startupCommandDelivery: startup.startupCommandDelivery }
+ : {})
+ }
+ : launchAgent
+ ? { agent: launchAgent }
+ : {}),
+ activate: opts?.activate !== false,
+ selectWorktree: false
+ })
+ )
.then((outcome) => {
if (outcome.status === 'failed') {
+ producer.failed(outcome.message)
toast.error(outcome.message, {
id: `web-runtime-worktree-terminal:${runtimeEnvironmentId}:${worktreeId}`
})
+ return
+ }
+ const settledState = useAppStore.getState()
+ settledState.reconcileWorktreeTabModel(worktreeId)
+ const surfaceId = (useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).find(
+ (tab) => !existingSurfaceIds.has(tab.id)
+ )?.id
+ if (surfaceId) {
+ producer.materialized({ kind: 'tab', id: surfaceId })
+ } else {
+ producer.unverifiable('The execution host created a terminal that is not visible yet.')
}
})
+ .catch(producer.failed)
.finally(() => {
endWebRuntimeWakeTerminalRespawn(worktreeId)
})
+ .catch(producer.failed)
}
diff --git a/src/renderer/src/lib/workspace-activation-recovery-coordinator.ts b/src/renderer/src/lib/workspace-activation-recovery-coordinator.ts
new file mode 100644
index 00000000000..cc817b2f4c6
--- /dev/null
+++ b/src/renderer/src/lib/workspace-activation-recovery-coordinator.ts
@@ -0,0 +1,212 @@
+import { useAppStore } from '@/store'
+import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal'
+import { clearWorkspaceActivationRecoveryPresentation } from './workspace-activation-recovery-presentation'
+import { readWorkspaceSurfaceProducerEntries } from './workspace-surface-production'
+import {
+ resolveWorkspaceExecutionEvidence,
+ type WorkspaceExecutionEvidence
+} from './workspace-execution-evidence'
+import type {
+ WorkspaceActivationContext,
+ WorkspaceActivationIdentity,
+ WorkspaceActivationRecoveryResult
+} from './worktree-activation-recovery'
+import {
+ canInspectAgentActivationInventory,
+ captureActivationRecoverySelectionRevision,
+ hasLiveActivationTerminalTombstone,
+ installActivationRecoverySelectionTracker,
+ isActivationRecoveryFresh,
+ markLatestActivationRecoveryAttempt,
+ readActivationRenderableInventory,
+ readActivationRenderableSurface,
+ WORKSPACE_ACTIVATION_RECOVERY_DEADLINE_MS,
+ WORKSPACE_ACTIVATION_RECOVERY_PROGRESS_DELAY_MS
+} from './workspace-activation-recovery-state'
+import {
+ activationRecoveryFailedResult,
+ activationRecoveryMaterializedResult,
+ assessActivationProducerAttempts,
+ clearActivationRecoveryPresentation,
+ publishActivationRecovery,
+ waitForActivationProducerAttempts
+} from './workspace-activation-recovery-settlement'
+import {
+ runActivationRecoveryGate,
+ waitForActivationProducedSurface
+} from './workspace-activation-recovery-gate'
+
+let seedingTargetKey: string | null = null
+
+function seedPrivateRecoverySurface(
+ identity: WorkspaceActivationIdentity,
+ context: WorkspaceActivationContext,
+ capturedSelectionRevision: number
+): WorkspaceActivationRecoveryResult {
+ const key = `${identity.executionHostId}|${identity.workspaceKey}`
+ if (
+ !isActivationRecoveryFresh(identity, capturedSelectionRevision, context) ||
+ seedingTargetKey === key
+ ) {
+ return { kind: 'stale' }
+ }
+ seedingTargetKey = key
+ try {
+ const { renderableTabCount, surface } = readActivationRenderableInventory(identity)
+ // Why: reconciliation can synchronously notify subscribers and start a newer activation.
+ if (!isActivationRecoveryFresh(identity, capturedSelectionRevision, context)) {
+ return { kind: 'stale' }
+ }
+ if (surface) {
+ return activationRecoveryMaterializedResult(identity, surface)
+ }
+ if (renderableTabCount > 0) {
+ publishActivationRecovery(
+ identity,
+ context,
+ 'unexpected',
+ 'Workspace content exists, but no renderable surface could be selected.'
+ )
+ return activationRecoveryFailedResult(identity, 'unexpected')
+ }
+ if (context.mode === 'startup' && hasLiveActivationTerminalTombstone(identity.workspaceKey)) {
+ clearActivationRecoveryPresentation(identity)
+ return { kind: 'intentional-empty' }
+ }
+ const pendingProducer = readWorkspaceSurfaceProducerEntries(identity).find(
+ (entry) => entry.result === null || entry.result?.kind === 'unverifiable'
+ )
+ if (pendingProducer) {
+ publishActivationRecovery(
+ identity,
+ context,
+ 'unverifiable',
+ 'A surface producer still owns this workspace.'
+ )
+ return {
+ kind: 'deferred',
+ reason: 'A surface producer still owns this workspace.',
+ ownerAttemptId: pendingProducer.attempt.id
+ }
+ }
+ const evidence: WorkspaceExecutionEvidence = resolveWorkspaceExecutionEvidence(
+ useAppStore.getState(),
+ identity.workspaceKey,
+ identity.executionHostId
+ )
+ if (evidence !== 'exited') {
+ const detail =
+ evidence === 'live'
+ ? 'The execution host owns this workspace surface. Wait for it to publish or reconnect.'
+ : 'Orca cannot verify the execution host. Reconnect before retrying recovery.'
+ publishActivationRecovery(identity, context, 'unverifiable', detail)
+ return { kind: 'deferred', reason: detail, ownerAttemptId: null }
+ }
+ if (!shouldAutoCreateInitialTerminal(renderableTabCount, false)) {
+ publishActivationRecovery(
+ identity,
+ context,
+ 'unexpected',
+ 'Workspace recovery could not select a surface.'
+ )
+ return activationRecoveryFailedResult(identity, 'unexpected')
+ }
+ const tab = useAppStore.getState().createTab(identity.workspaceKey, undefined, undefined, {
+ pendingActivationSpawn: true
+ })
+ clearActivationRecoveryPresentation(identity)
+ return { kind: 'materialized', surface: { id: tab.id, type: 'terminal' } }
+ } catch (error) {
+ const detail = error instanceof Error ? error.message : String(error)
+ publishActivationRecovery(identity, context, 'unexpected', detail)
+ return activationRecoveryFailedResult(identity, 'unexpected')
+ } finally {
+ seedingTargetKey = null
+ }
+}
+
+export async function recoverWorkspaceActivationOwned(
+ identity: WorkspaceActivationIdentity,
+ context: WorkspaceActivationContext
+): Promise {
+ installActivationRecoverySelectionTracker()
+ markLatestActivationRecoveryAttempt(identity)
+ clearWorkspaceActivationRecoveryPresentation({
+ workspaceKey: identity.workspaceKey,
+ executionHostId: identity.executionHostId
+ })
+ const capturedSelectionRevision = captureActivationRecoverySelectionRevision()
+ const deadlineAt = Date.now() + WORKSPACE_ACTIVATION_RECOVERY_DEADLINE_MS
+ const progressTimer = setTimeout(() => {
+ try {
+ if (
+ isActivationRecoveryFresh(identity, capturedSelectionRevision, context) &&
+ !readActivationRenderableSurface(identity)
+ ) {
+ publishActivationRecovery(identity, context, 'recovering')
+ }
+ } catch (error) {
+ const detail = error instanceof Error ? error.message : String(error)
+ publishActivationRecovery(identity, context, 'unexpected', detail)
+ }
+ }, WORKSPACE_ACTIVATION_RECOVERY_PROGRESS_DELAY_MS)
+ try {
+ if (!isActivationRecoveryFresh(identity, capturedSelectionRevision, context)) {
+ return { kind: 'stale' }
+ }
+ const producerAssessment = assessActivationProducerAttempts(identity, context)
+ const producerResult =
+ producerAssessment.kind === 'wait'
+ ? await waitForActivationProducerAttempts(identity, context, deadlineAt)
+ : producerAssessment.kind === 'complete'
+ ? producerAssessment.result
+ : null
+ if (producerResult) {
+ return producerResult
+ }
+ if (!isActivationRecoveryFresh(identity, capturedSelectionRevision, context)) {
+ return { kind: 'stale' }
+ }
+ const state = useAppStore.getState()
+ const shouldGate =
+ Object.values(state.sleepingAgentSessionsByPaneKey).some(
+ (record) => record.worktreeId === identity.workspaceKey
+ ) || canInspectAgentActivationInventory()
+ if (shouldGate) {
+ const gateResult = await runActivationRecoveryGate(identity, context, deadlineAt)
+ if (gateResult !== 'empty' && gateResult !== 'produced') {
+ return gateResult
+ }
+ if (!isActivationRecoveryFresh(identity, capturedSelectionRevision, context)) {
+ return { kind: 'stale' }
+ }
+ const gateSurface = readActivationRenderableSurface(identity)
+ if (gateSurface) {
+ return activationRecoveryMaterializedResult(identity, gateSurface)
+ }
+ if (gateResult === 'produced') {
+ return waitForActivationProducedSurface(identity, context, deadlineAt)
+ }
+ }
+ const seedResult = seedPrivateRecoverySurface(identity, context, capturedSelectionRevision)
+ if (
+ seedResult.kind !== 'stale' ||
+ !isActivationRecoveryFresh(identity, capturedSelectionRevision, context)
+ ) {
+ return seedResult
+ }
+ // Why: a reentrant subscriber can start the newer attempt while the superseded attempt still
+ // owns the synchronous seed guard; retry once after that critical section unwinds.
+ await Promise.resolve()
+ return seedPrivateRecoverySurface(identity, context, capturedSelectionRevision)
+ } catch (error) {
+ if (!isActivationRecoveryFresh(identity, capturedSelectionRevision, context)) {
+ return { kind: 'stale' }
+ }
+ const detail = error instanceof Error ? error.message : String(error)
+ publishActivationRecovery(identity, context, 'unexpected', detail)
+ return activationRecoveryFailedResult(identity, 'unexpected')
+ } finally {
+ clearTimeout(progressTimer)
+ }
+}
diff --git a/src/renderer/src/lib/workspace-activation-recovery-gate.ts b/src/renderer/src/lib/workspace-activation-recovery-gate.ts
new file mode 100644
index 00000000000..7e9df033c73
--- /dev/null
+++ b/src/renderer/src/lib/workspace-activation-recovery-gate.ts
@@ -0,0 +1,100 @@
+import { gateWorktreeAgentActivation } from './worktree-agent-activation-gate'
+import type {
+ WorkspaceActivationContext,
+ WorkspaceActivationIdentity,
+ WorkspaceActivationRecoveryResult
+} from './worktree-activation-recovery'
+import {
+ activationRecoveryRouteKey,
+ readActivationRecoveryGateRoute,
+ readActivationRenderableSurface,
+ recordActivationRecoveryGateRoute,
+ waitForActivationRecoveryChange,
+ waitForActivationRecoveryPromise
+} from './workspace-activation-recovery-state'
+import {
+ activationRecoveryFailedResult,
+ activationRecoveryMaterializedResult,
+ publishActivationRecovery
+} from './workspace-activation-recovery-settlement'
+
+export async function runActivationRecoveryGate(
+ identity: WorkspaceActivationIdentity,
+ context: WorkspaceActivationContext,
+ deadlineAt: number
+): Promise {
+ const gate = gateWorktreeAgentActivation(identity.workspaceKey)
+ const gateRoute = readActivationRecoveryGateRoute(gate)
+ if (gateRoute && gateRoute !== activationRecoveryRouteKey(identity)) {
+ publishActivationRecovery(
+ identity,
+ context,
+ 'unverifiable',
+ 'Another execution host owns the in-progress recovery assessment. Retry after it settles.'
+ )
+ return {
+ kind: 'deferred',
+ reason: 'A recovery gate from another execution host is still in progress.',
+ ownerAttemptId: null
+ }
+ }
+ recordActivationRecoveryGateRoute(gate, activationRecoveryRouteKey(identity))
+ const outcome = await waitForActivationRecoveryPromise(gate, deadlineAt, context.signal)
+ if (outcome === 'cancelled') {
+ return { kind: 'stale' }
+ }
+ if (outcome === 'timeout') {
+ publishActivationRecovery(
+ identity,
+ context,
+ 'unexpected',
+ 'Workspace inventory did not finish before the recovery deadline.'
+ )
+ return activationRecoveryFailedResult(identity, 'unexpected')
+ }
+ if (outcome.kind === 'rejected') {
+ const detail = outcome.error instanceof Error ? outcome.error.message : String(outcome.error)
+ publishActivationRecovery(identity, context, 'unexpected', detail)
+ return activationRecoveryFailedResult(identity, 'unexpected')
+ }
+ if (outcome.value === 'blocked') {
+ publishActivationRecovery(
+ identity,
+ context,
+ 'blocked',
+ 'Orca deliberately paused recovery because the execution host did not provide complete ownership evidence.'
+ )
+ return activationRecoveryFailedResult(identity, 'blocked')
+ }
+ return outcome.value === 'empty' ? 'empty' : 'produced'
+}
+
+export async function waitForActivationProducedSurface(
+ identity: WorkspaceActivationIdentity,
+ context: WorkspaceActivationContext,
+ deadlineAt: number
+): Promise {
+ while (true) {
+ const surface = readActivationRenderableSurface(identity)
+ if (surface) {
+ return activationRecoveryMaterializedResult(identity, surface)
+ }
+ const wait = await waitForActivationRecoveryChange(deadlineAt, context.signal)
+ if (wait === 'cancelled') {
+ return { kind: 'stale' }
+ }
+ if (wait === 'timeout') {
+ publishActivationRecovery(
+ identity,
+ context,
+ 'unverifiable',
+ 'The execution host reported work, but no renderable surface became visible.'
+ )
+ return {
+ kind: 'deferred',
+ reason: 'Host work did not publish a renderable surface before the recovery deadline.',
+ ownerAttemptId: null
+ }
+ }
+ }
+}
diff --git a/src/renderer/src/lib/workspace-activation-recovery-presentation.ts b/src/renderer/src/lib/workspace-activation-recovery-presentation.ts
new file mode 100644
index 00000000000..2b42aac5f4d
--- /dev/null
+++ b/src/renderer/src/lib/workspace-activation-recovery-presentation.ts
@@ -0,0 +1,64 @@
+import type { ExecutionHostId } from '../../../shared/execution-host'
+
+export type WorkspaceActivationRecoveryPresentation = {
+ workspaceKey: string
+ executionHostId: ExecutionHostId
+ attemptId: string
+ kind: 'recovering' | 'blocked' | 'unexpected' | 'unverifiable' | 'producer-failed'
+ detail?: string
+ retry: () => void
+}
+
+const presentationsByTarget = new Map()
+const listeners = new Set<() => void>()
+
+function targetKey(workspaceKey: string, executionHostId: ExecutionHostId): string {
+ return `${executionHostId}|${workspaceKey}`
+}
+
+function notifyListeners(): void {
+ for (const listener of listeners) {
+ listener()
+ }
+}
+
+export function publishWorkspaceActivationRecoveryPresentation(
+ presentation: WorkspaceActivationRecoveryPresentation
+): void {
+ presentationsByTarget.set(
+ targetKey(presentation.workspaceKey, presentation.executionHostId),
+ presentation
+ )
+ notifyListeners()
+}
+
+export function clearWorkspaceActivationRecoveryPresentation(args: {
+ workspaceKey: string
+ executionHostId: ExecutionHostId
+ attemptId?: string
+}): void {
+ const key = targetKey(args.workspaceKey, args.executionHostId)
+ const current = presentationsByTarget.get(key)
+ if (!current || (args.attemptId && current.attemptId !== args.attemptId)) {
+ return
+ }
+ presentationsByTarget.delete(key)
+ notifyListeners()
+}
+
+export function readWorkspaceActivationRecoveryPresentation(
+ workspaceKey: string,
+ executionHostId: ExecutionHostId
+): WorkspaceActivationRecoveryPresentation | null {
+ return presentationsByTarget.get(targetKey(workspaceKey, executionHostId)) ?? null
+}
+
+export function subscribeWorkspaceActivationRecoveryPresentation(listener: () => void): () => void {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+}
+
+export function resetWorkspaceActivationRecoveryPresentationsForTests(): void {
+ presentationsByTarget.clear()
+ notifyListeners()
+}
diff --git a/src/renderer/src/lib/workspace-activation-recovery-settlement.ts b/src/renderer/src/lib/workspace-activation-recovery-settlement.ts
new file mode 100644
index 00000000000..f4bf094baf1
--- /dev/null
+++ b/src/renderer/src/lib/workspace-activation-recovery-settlement.ts
@@ -0,0 +1,236 @@
+import type { WorkspaceVisibleTabType } from '../../../shared/tab-types'
+import { createBrowserUuid } from './browser-uuid'
+import {
+ consumeWorkspaceSurfaceProducerAttempt,
+ readWorkspaceSurfaceProducerEntries
+} from './workspace-surface-production'
+import {
+ clearWorkspaceActivationRecoveryPresentation,
+ publishWorkspaceActivationRecoveryPresentation,
+ type WorkspaceActivationRecoveryPresentation
+} from './workspace-activation-recovery-presentation'
+import {
+ isActivationRecoveryCurrent,
+ readActivationRenderableSurface,
+ readActivationRenderableSurfaceById,
+ readActivationRenderableSurfaceIds,
+ readStructuredActivationProducerStatus,
+ waitForActivationRecoveryChange
+} from './workspace-activation-recovery-state'
+import {
+ recoverWorkspaceActivation,
+ type WorkspaceActivationContext,
+ type WorkspaceActivationIdentity,
+ type WorkspaceActivationRecoveryResult
+} from './worktree-activation-recovery'
+
+const failureSurfaceIdsByProducerAttempt = new Map>()
+
+function retry(identity: WorkspaceActivationIdentity, context: WorkspaceActivationContext): void {
+ void recoverWorkspaceActivation(
+ { ...identity, attemptId: createBrowserUuid() },
+ { mode: context.mode }
+ )
+}
+
+export function publishActivationRecovery(
+ identity: WorkspaceActivationIdentity,
+ context: WorkspaceActivationContext,
+ kind: WorkspaceActivationRecoveryPresentation['kind'],
+ detail?: string
+): void {
+ if (!isActivationRecoveryCurrent(identity, context)) {
+ return
+ }
+ publishWorkspaceActivationRecoveryPresentation({
+ workspaceKey: identity.workspaceKey,
+ executionHostId: identity.executionHostId,
+ attemptId: identity.attemptId,
+ kind,
+ ...(detail ? { detail } : {}),
+ retry: () => retry(identity, context)
+ })
+}
+
+export function clearActivationRecoveryPresentation(identity: WorkspaceActivationIdentity): void {
+ clearWorkspaceActivationRecoveryPresentation({
+ workspaceKey: identity.workspaceKey,
+ executionHostId: identity.executionHostId,
+ attemptId: identity.attemptId
+ })
+}
+
+export function activationRecoveryMaterializedResult(
+ identity: WorkspaceActivationIdentity,
+ surface: { id: string; type: WorkspaceVisibleTabType }
+): WorkspaceActivationRecoveryResult {
+ for (const entry of readWorkspaceSurfaceProducerEntries(identity)) {
+ if (
+ entry.result?.kind === 'materialized' &&
+ entry.result.surface.kind === 'tab' &&
+ entry.result.surface.id === surface.id
+ ) {
+ consumeWorkspaceSurfaceProducerAttempt(entry.attempt.id)
+ }
+ }
+ clearActivationRecoveryPresentation(identity)
+ return { kind: 'materialized', surface }
+}
+
+export function activationRecoveryFailedResult(
+ identity: WorkspaceActivationIdentity,
+ reason: 'blocked' | 'unexpected' | 'producer-failed'
+): WorkspaceActivationRecoveryResult {
+ return { kind: 'failed', reason, diagnosticId: identity.attemptId }
+}
+
+export type ProducerAssessment =
+ | { kind: 'complete'; result: WorkspaceActivationRecoveryResult }
+ | { kind: 'idle' }
+ | { kind: 'wait' }
+
+function materializedAfterProducerFailure(
+ identity: WorkspaceActivationIdentity,
+ producerAttemptId: string
+): WorkspaceActivationRecoveryResult | null {
+ const currentSurfaceIds = readActivationRenderableSurfaceIds(identity)
+ const failureSurfaceIds = failureSurfaceIdsByProducerAttempt.get(producerAttemptId)
+ const laterSurfaceId = failureSurfaceIds
+ ? [...currentSurfaceIds].find((surfaceId) => !failureSurfaceIds.has(surfaceId))
+ : undefined
+ if (laterSurfaceId) {
+ const laterSurface = readActivationRenderableSurfaceById(identity, laterSurfaceId)
+ if (laterSurface) {
+ failureSurfaceIdsByProducerAttempt.delete(producerAttemptId)
+ consumeWorkspaceSurfaceProducerAttempt(producerAttemptId)
+ return activationRecoveryMaterializedResult(identity, laterSurface)
+ }
+ }
+ if (!failureSurfaceIds) {
+ failureSurfaceIdsByProducerAttempt.set(producerAttemptId, currentSurfaceIds)
+ }
+ return null
+}
+
+export function assessActivationProducerAttempts(
+ identity: WorkspaceActivationIdentity,
+ context: WorkspaceActivationContext
+): ProducerAssessment {
+ const entries = readWorkspaceSurfaceProducerEntries(identity)
+ const failed = entries.find((entry) => entry.result?.kind === 'failed')
+ if (failed?.result?.kind === 'failed') {
+ const laterSurface = materializedAfterProducerFailure(identity, failed.attempt.id)
+ if (laterSurface) {
+ return { kind: 'complete', result: laterSurface }
+ }
+ publishActivationRecovery(identity, context, 'producer-failed', failed.result.reason)
+ return {
+ kind: 'complete',
+ result: activationRecoveryFailedResult(identity, 'producer-failed')
+ }
+ }
+ const unverifiable = entries.find((entry) => entry.result?.kind === 'unverifiable')
+ if (unverifiable?.result?.kind === 'unverifiable') {
+ publishActivationRecovery(identity, context, 'unverifiable', unverifiable.result.reason)
+ return {
+ kind: 'complete',
+ result: {
+ kind: 'deferred',
+ reason: unverifiable.result.reason,
+ ownerAttemptId: unverifiable.attempt.id
+ }
+ }
+ }
+ const structuredStatus = readStructuredActivationProducerStatus(identity.workspaceKey)
+ if (structuredStatus === 'unknown') {
+ publishActivationRecovery(
+ identity,
+ context,
+ 'unverifiable',
+ 'The execution host may have accepted the surface request, but its result cannot be verified.'
+ )
+ return {
+ kind: 'complete',
+ result: {
+ kind: 'deferred',
+ reason: 'Structured surface ownership is unverifiable.',
+ ownerAttemptId: entries.find((entry) => entry.result === null)?.attempt.id ?? null
+ }
+ }
+ }
+ const declined = entries.find((entry) => entry.result?.kind === 'declined')
+ if (declined?.result?.kind === 'declined') {
+ const laterSurface = materializedAfterProducerFailure(identity, declined.attempt.id)
+ if (laterSurface) {
+ return { kind: 'complete', result: laterSurface }
+ }
+ publishActivationRecovery(identity, context, 'producer-failed', declined.result.reason)
+ return {
+ kind: 'complete',
+ result: activationRecoveryFailedResult(identity, 'producer-failed')
+ }
+ }
+ const pending = entries.find((entry) => entry.result === null)
+ if (pending || structuredStatus === 'pending') {
+ return { kind: 'wait' }
+ }
+ const materialized = entries.find((entry) => entry.result?.kind === 'materialized')
+ if (materialized?.result?.kind === 'materialized') {
+ const { surface } = materialized.result
+ if (surface.kind === 'workspace-content') {
+ clearActivationRecoveryPresentation(identity)
+ consumeWorkspaceSurfaceProducerAttempt(materialized.attempt.id)
+ return {
+ kind: 'complete',
+ result: { kind: 'materialized', surface: { id: surface.id, type: 'workspace-content' } }
+ }
+ }
+ const publishedSurface = readActivationRenderableSurfaceById(identity, surface.id)
+ return publishedSurface
+ ? {
+ kind: 'complete',
+ result: activationRecoveryMaterializedResult(identity, publishedSurface)
+ }
+ : { kind: 'wait' }
+ }
+ const surface = readActivationRenderableSurface(identity)
+ return surface
+ ? { kind: 'complete', result: activationRecoveryMaterializedResult(identity, surface) }
+ : { kind: 'idle' }
+}
+
+export async function waitForActivationProducerAttempts(
+ identity: WorkspaceActivationIdentity,
+ context: WorkspaceActivationContext,
+ deadlineAt: number
+): Promise {
+ while (true) {
+ const assessment = assessActivationProducerAttempts(identity, context)
+ if (assessment.kind === 'complete') {
+ return assessment.result
+ }
+ if (assessment.kind === 'idle') {
+ return null
+ }
+ const entries = readWorkspaceSurfaceProducerEntries(identity)
+ const pending = entries.find((entry) => entry.result === null)
+ const materialized = entries.find((entry) => entry.result?.kind === 'materialized')
+ const wait = await waitForActivationRecoveryChange(deadlineAt, context.signal)
+ if (wait === 'cancelled') {
+ return { kind: 'stale' }
+ }
+ if (wait === 'timeout') {
+ publishActivationRecovery(
+ identity,
+ context,
+ 'unverifiable',
+ 'The requested surface has not become visible. Its producer still owns the attempt.'
+ )
+ return {
+ kind: 'deferred',
+ reason: 'Surface publication did not settle before the recovery deadline.',
+ ownerAttemptId: pending?.attempt.id ?? materialized?.attempt.id ?? null
+ }
+ }
+ }
+}
diff --git a/src/renderer/src/lib/workspace-activation-recovery-state.ts b/src/renderer/src/lib/workspace-activation-recovery-state.ts
new file mode 100644
index 00000000000..e1b0f1d947c
--- /dev/null
+++ b/src/renderer/src/lib/workspace-activation-recovery-state.ts
@@ -0,0 +1,275 @@
+import { useAppStore } from '@/store'
+import { toVisibleTabType, type WorkspaceVisibleTabType } from '../../../shared/tab-types'
+import { subscribeWorkspaceSurfaceProducers } from './workspace-surface-production'
+import {
+ getStructuredAgentLaunchStatus,
+ subscribeStructuredAgentLaunchStatus
+} from './structured-agent-session-launch'
+import { AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS } from '../../../shared/agent-session-provider-handle'
+import {
+ getExecutionHostIdForWorktree,
+ getRuntimeEnvironmentIdForWorktree
+} from './worktree-runtime-owner'
+import type {
+ WorkspaceActivationContext,
+ WorkspaceActivationIdentity
+} from './worktree-activation-recovery'
+
+export type RecoveryWaitResult = 'changed' | 'cancelled' | 'timeout'
+
+export const WORKSPACE_ACTIVATION_RECOVERY_DEADLINE_MS = 30_000
+export const WORKSPACE_ACTIVATION_RECOVERY_PROGRESS_DELAY_MS = 200
+
+const latestAttemptIdByTarget = new Map()
+const gateIdentityByPromise = new WeakMap, string>()
+let selectionRevision = 0
+let previousSelectionKey: string | null = null
+let disposeSelectionTracker: (() => void) | null = null
+
+export function activationRecoveryTargetKey(identity: WorkspaceActivationIdentity): string {
+ return `${identity.executionHostId}|${identity.workspaceKey}`
+}
+
+export function activationRecoveryRouteKey(identity: WorkspaceActivationIdentity): string {
+ return `${identity.executionHostId}|${identity.runtimeEnvironmentId ?? ''}`
+}
+
+export function markLatestActivationRecoveryAttempt(identity: WorkspaceActivationIdentity): void {
+ latestAttemptIdByTarget.set(activationRecoveryTargetKey(identity), identity.attemptId)
+}
+
+export function readLatestActivationRecoveryAttempt(
+ identity: WorkspaceActivationIdentity
+): string | undefined {
+ return latestAttemptIdByTarget.get(activationRecoveryTargetKey(identity))
+}
+
+export function readActivationRecoveryGateRoute(gate: Promise): string | undefined {
+ return gateIdentityByPromise.get(gate)
+}
+
+export function recordActivationRecoveryGateRoute(gate: Promise, route: string): void {
+ gateIdentityByPromise.set(gate, route)
+}
+
+function currentSelectionKey(): string {
+ const state = useAppStore.getState()
+ const workspaceKey = state.activeWorktreeId
+ if (!workspaceKey) {
+ return 'none'
+ }
+ return `${getExecutionHostIdForWorktree(state, workspaceKey)}|${getRuntimeEnvironmentIdForWorktree(state, workspaceKey) ?? ''}|${workspaceKey}`
+}
+
+export function installActivationRecoverySelectionTracker(): void {
+ if (disposeSelectionTracker) {
+ return
+ }
+ previousSelectionKey = currentSelectionKey()
+ disposeSelectionTracker = useAppStore.subscribe(() => {
+ const next = currentSelectionKey()
+ if (next !== previousSelectionKey) {
+ previousSelectionKey = next
+ selectionRevision += 1
+ }
+ })
+}
+
+if (import.meta.hot) {
+ import.meta.hot.dispose(() => {
+ disposeSelectionTracker?.()
+ disposeSelectionTracker = null
+ })
+}
+
+export function captureActivationRecoverySelectionRevision(): number {
+ return selectionRevision
+}
+
+export function isActivationRecoveryCurrent(
+ identity: WorkspaceActivationIdentity,
+ context: WorkspaceActivationContext
+): boolean {
+ if (
+ context.signal?.aborted ||
+ readLatestActivationRecoveryAttempt(identity) !== identity.attemptId
+ ) {
+ return false
+ }
+ const state = useAppStore.getState()
+ return (
+ state.activeWorktreeId === identity.workspaceKey &&
+ getExecutionHostIdForWorktree(state, identity.workspaceKey) === identity.executionHostId &&
+ getRuntimeEnvironmentIdForWorktree(state, identity.workspaceKey) ===
+ identity.runtimeEnvironmentId
+ )
+}
+
+export function isActivationRecoveryFresh(
+ identity: WorkspaceActivationIdentity,
+ capturedSelectionRevision: number,
+ context: WorkspaceActivationContext
+): boolean {
+ return (
+ selectionRevision === capturedSelectionRevision &&
+ isActivationRecoveryCurrent(identity, context)
+ )
+}
+
+export function readActivationRenderableSurface(
+ identity: WorkspaceActivationIdentity
+): { id: string; type: WorkspaceVisibleTabType } | null {
+ const state = useAppStore.getState()
+ const reconciliation = state.reconcileWorktreeTabModel(identity.workspaceKey)
+ if (reconciliation.renderableTabCount === 0 || !reconciliation.activeRenderableTabId) {
+ return null
+ }
+ const tab = (useAppStore.getState().unifiedTabsByWorktree[identity.workspaceKey] ?? []).find(
+ (candidate) => candidate.id === reconciliation.activeRenderableTabId
+ )
+ return tab ? { id: tab.id, type: toVisibleTabType(tab.contentType) } : null
+}
+
+export function readActivationRenderableSurfaceById(
+ identity: WorkspaceActivationIdentity,
+ surfaceId: string
+): { id: string; type: WorkspaceVisibleTabType } | null {
+ const state = useAppStore.getState()
+ state.reconcileWorktreeTabModel(identity.workspaceKey)
+ const tab = (useAppStore.getState().unifiedTabsByWorktree[identity.workspaceKey] ?? []).find(
+ (candidate) => candidate.id === surfaceId
+ )
+ return tab ? { id: tab.id, type: toVisibleTabType(tab.contentType) } : null
+}
+
+export function readActivationRenderableSurfaceIds(
+ identity: WorkspaceActivationIdentity
+): ReadonlySet {
+ const state = useAppStore.getState()
+ state.reconcileWorktreeTabModel(identity.workspaceKey)
+ return new Set(
+ (useAppStore.getState().unifiedTabsByWorktree[identity.workspaceKey] ?? []).map((tab) => tab.id)
+ )
+}
+
+export function readActivationRenderableInventory(identity: WorkspaceActivationIdentity): {
+ renderableTabCount: number
+ surface: { id: string; type: WorkspaceVisibleTabType } | null
+} {
+ const state = useAppStore.getState()
+ const reconciliation = state.reconcileWorktreeTabModel(identity.workspaceKey)
+ const tab = reconciliation.activeRenderableTabId
+ ? (useAppStore.getState().unifiedTabsByWorktree[identity.workspaceKey] ?? []).find(
+ (candidate) => candidate.id === reconciliation.activeRenderableTabId
+ )
+ : null
+ return {
+ renderableTabCount: reconciliation.renderableTabCount,
+ surface: tab ? { id: tab.id, type: toVisibleTabType(tab.contentType) } : null
+ }
+}
+
+export function hasLiveActivationTerminalTombstone(workspaceKey: string): boolean {
+ return Object.hasOwn(useAppStore.getState().tabsByWorktree, workspaceKey)
+}
+
+export function readStructuredActivationProducerStatus(
+ workspaceKey: string
+): 'idle' | 'pending' | 'unknown' {
+ let status: 'idle' | 'pending' | 'unknown' = 'idle'
+ for (const agent of AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS) {
+ const candidate = getStructuredAgentLaunchStatus(workspaceKey, agent)
+ if (candidate === 'unknown') {
+ return 'unknown'
+ }
+ if (candidate === 'pending') {
+ status = 'pending'
+ }
+ }
+ return status
+}
+
+export function waitForActivationRecoveryChange(
+ deadlineAt: number,
+ signal: AbortSignal | undefined
+): Promise {
+ const remaining = Math.max(0, deadlineAt - Date.now())
+ if (remaining === 0) {
+ return Promise.resolve('timeout')
+ }
+ return new Promise((resolve) => {
+ let settled = false
+ const finish = (result: RecoveryWaitResult): void => {
+ if (settled) {
+ return
+ }
+ settled = true
+ clearTimeout(timeout)
+ unsubscribeStore()
+ unsubscribeProducers()
+ unsubscribeStructured()
+ signal?.removeEventListener('abort', onAbort)
+ resolve(result)
+ }
+ const onAbort = (): void => finish('cancelled')
+ const timeout = setTimeout(() => finish('timeout'), remaining)
+ const unsubscribeStore = useAppStore.subscribe(() => finish('changed'))
+ const unsubscribeProducers = subscribeWorkspaceSurfaceProducers(() => finish('changed'))
+ const unsubscribeStructured = subscribeStructuredAgentLaunchStatus(() => finish('changed'))
+ signal?.addEventListener('abort', onAbort, { once: true })
+ if (signal?.aborted) {
+ finish('cancelled')
+ }
+ })
+}
+
+export function waitForActivationRecoveryPromise(
+ promise: Promise,
+ deadlineAt: number,
+ signal: AbortSignal | undefined
+): Promise<
+ | { kind: 'settled'; value: T }
+ | { kind: 'rejected'; error: unknown }
+ | Exclude
+> {
+ const remaining = Math.max(0, deadlineAt - Date.now())
+ if (remaining === 0) {
+ return Promise.resolve('timeout')
+ }
+ return new Promise((resolve) => {
+ let settled = false
+ const finish = (
+ result:
+ | { kind: 'settled'; value: T }
+ | { kind: 'rejected'; error: unknown }
+ | Exclude
+ ): void => {
+ if (settled) {
+ return
+ }
+ settled = true
+ clearTimeout(timeout)
+ signal?.removeEventListener('abort', onAbort)
+ resolve(result)
+ }
+ const onAbort = (): void => finish('cancelled')
+ const timeout = setTimeout(() => finish('timeout'), remaining)
+ signal?.addEventListener('abort', onAbort, { once: true })
+ if (signal?.aborted) {
+ finish('cancelled')
+ return
+ }
+ void promise.then(
+ (value) => finish({ kind: 'settled', value }),
+ (error: unknown) => finish({ kind: 'rejected', error })
+ )
+ })
+}
+
+export function canInspectAgentActivationInventory(): boolean {
+ return (
+ typeof window !== 'undefined' &&
+ typeof window.api?.runtime?.call === 'function' &&
+ typeof window.api?.pty?.listSessions === 'function'
+ )
+}
diff --git a/src/renderer/src/lib/workspace-execution-evidence.ts b/src/renderer/src/lib/workspace-execution-evidence.ts
new file mode 100644
index 00000000000..9c15aa35a94
--- /dev/null
+++ b/src/renderer/src/lib/workspace-execution-evidence.ts
@@ -0,0 +1,31 @@
+import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
+import { parseWorkspaceKey } from '../../../shared/workspace-scope'
+import {
+ resolveWorkspaceTerminalHostAuthority,
+ type WorkspaceTerminalHostAuthorityState
+} from './workspace-terminal-host-authority'
+
+export type WorkspaceExecutionEvidence = 'live' | 'unverifiable' | 'exited'
+
+export function resolveWorkspaceExecutionEvidence(
+ state: WorkspaceTerminalHostAuthorityState,
+ workspaceKey: string,
+ executionHostId: ExecutionHostId
+): WorkspaceExecutionEvidence {
+ const authority = resolveWorkspaceTerminalHostAuthority(state, workspaceKey)
+ if (authority !== 'none') {
+ return authority
+ }
+ const host = parseExecutionHostId(executionHostId)
+ if (!host || host.kind === 'local') {
+ return 'exited'
+ }
+ if (host.kind === 'runtime' || parseWorkspaceKey(workspaceKey)?.type === 'folder') {
+ return 'unverifiable'
+ }
+ const syncStatus = state.remoteWorkspaceSyncStatusByTargetId?.[host.targetId]
+ return state.remoteWorkspaceHydratedTargetIds?.has(host.targetId) &&
+ syncStatus?.phase === 'synced'
+ ? 'exited'
+ : 'unverifiable'
+}
diff --git a/src/renderer/src/lib/workspace-port-actions.ts b/src/renderer/src/lib/workspace-port-actions.ts
index cf745d3e25f..aa9e617d70b 100644
--- a/src/renderer/src/lib/workspace-port-actions.ts
+++ b/src/renderer/src/lib/workspace-port-actions.ts
@@ -1,24 +1,19 @@
-import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import type { useAppStore } from '@/store'
+import { activateAndRevealWorktree } from './worktree-activation'
import {
- assertRuntimeEnvironmentCapability,
callRuntimeRpc,
RuntimeRpcCallError,
type RuntimeClientTarget
} from '@/runtime/runtime-rpc-client'
-import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
import type {
WorkspacePort,
WorkspacePortKillResult,
WorkspacePortScanResult
} from '../../../shared/workspace-ports'
-import type { LocalhostWorktreeLabelRoute } from '../../../shared/localhost-worktree-labels'
import { runWorkspacePortScanForTarget } from './workspace-port-scan-client'
-import { browserUrlForPort } from './workspace-port-urls'
-import { BROWSER_SCREENCAST_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
-import { RUNTIME_BROWSER_UNAVAILABLE_MESSAGE } from './client-creation-action-policy'
export { addressForPort } from './workspace-port-urls'
+export { openWorkspacePortInBrowser } from './workspace-port-browser-open'
const WORKSPACE_PORT_STOP_SETTLE_MS = 500
const WORKSPACE_PORT_TARGET_UNAVAILABLE_REASON =
@@ -33,10 +28,6 @@ export function canStopWorkspacePort(
return port.kind === 'workspace' && Boolean(port.pid) && port.processName !== 'Electron'
}
-type BrowserTabCreator = ReturnType['createBrowserTab']
-type RemoteBrowserPageHandleSetter = ReturnType<
- typeof useAppStore.getState
->['setRemoteBrowserPageHandle']
type WorkspacePortScanRefreshingSetter = ReturnType<
typeof useAppStore.getState
>['setWorkspacePortScanRefreshing']
@@ -99,84 +90,6 @@ export function goToWorkspacePortOwner(port: WorkspacePort): boolean {
return Boolean(worktreeId && activateAndRevealWorktree(worktreeId))
}
-export async function openWorkspacePortInBrowser(args: {
- port: WorkspacePort
- activeWorktreeId?: string | null
- runtimeTarget: RuntimeClientTarget | null
- createBrowserTab: BrowserTabCreator
- setRemoteBrowserPageHandle: RemoteBrowserPageHandleSetter
- openInOrcaBrowser?: boolean
- localhostLabelRoute?: LocalhostWorktreeLabelRoute | null
-}): Promise<{ ok: true } | { ok: false; reason: string }> {
- if (!args.runtimeTarget) {
- return { ok: false, reason: WORKSPACE_PORT_TARGET_UNAVAILABLE_REASON }
- }
- const rawUrl = browserUrlForPort(args.port)
- let url = rawUrl
- if (args.runtimeTarget.kind === 'local' && args.localhostLabelRoute) {
- try {
- url = (await window.api.localhostWorktreeLabels.register(args.localhostLabelRoute)).url
- } catch {
- url = rawUrl
- }
- }
- if (args.openInOrcaBrowser === false && args.runtimeTarget.kind === 'local') {
- try {
- await window.api.shell.openUrl(url)
- return { ok: true }
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error)
- return { ok: false, reason: message || 'Failed to open system browser.' }
- }
- }
-
- const worktreeId =
- args.port.kind === 'workspace' ? args.port.owner.worktreeId : args.activeWorktreeId
- if (!worktreeId) {
- return { ok: false, reason: 'No workspace selected for the browser.' }
- }
- // Why: the browser tab opened below is this jump's surface; seeding a shell would add a
- // PTY the user never asked for in a workspace whose last terminal they closed.
- activateAndRevealWorktree(worktreeId, { providesInitialSurface: true })
- if (args.runtimeTarget.kind === 'environment') {
- try {
- await assertRuntimeEnvironmentCapability(
- args.runtimeTarget.environmentId,
- BROWSER_SCREENCAST_RUNTIME_CAPABILITY,
- RUNTIME_BROWSER_UNAVAILABLE_MESSAGE
- )
- const remotePage = await callRuntimeRpc<{ browserPageId: string }>(
- args.runtimeTarget,
- 'browser.tabCreate',
- { worktree: toRuntimeWorktreeSelector(worktreeId), url },
- { timeoutMs: 30_000 }
- )
- const tab = args.createBrowserTab(worktreeId, url, {
- activate: true,
- browserRuntimeEnvironmentId: args.runtimeTarget.environmentId
- })
- if (!tab.activePageId) {
- return { ok: false, reason: 'Failed to create a browser page.' }
- }
- args.setRemoteBrowserPageHandle(tab.activePageId, {
- environmentId: args.runtimeTarget.environmentId,
- remotePageId: remotePage.browserPageId
- })
- return { ok: true }
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error)
- return { ok: false, reason: message || 'Failed to open remote browser.' }
- }
- }
- try {
- args.createBrowserTab(worktreeId, url, { activate: true })
- return { ok: true }
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error)
- return { ok: false, reason: message || 'Failed to open browser.' }
- }
-}
-
/**
* Stores one host's scan and republishes the aggregate the status bar reads.
* Why: a single-host publish used to overwrite that aggregate, so every other
diff --git a/src/renderer/src/lib/workspace-port-browser-open.ts b/src/renderer/src/lib/workspace-port-browser-open.ts
new file mode 100644
index 00000000000..55709706219
--- /dev/null
+++ b/src/renderer/src/lib/workspace-port-browser-open.ts
@@ -0,0 +1,121 @@
+import { activateAndRevealWorktree } from './worktree-activation'
+import { useAppStore } from '@/store'
+import {
+ assertRuntimeEnvironmentCapability,
+ callRuntimeRpc,
+ type RuntimeClientTarget
+} from '@/runtime/runtime-rpc-client'
+import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
+import type { WorkspacePort } from '../../../shared/workspace-ports'
+import type { LocalhostWorktreeLabelRoute } from '../../../shared/localhost-worktree-labels'
+import { browserUrlForPort } from './workspace-port-urls'
+import { BROWSER_SCREENCAST_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
+import { RUNTIME_BROWSER_UNAVAILABLE_MESSAGE } from './client-creation-action-policy'
+import { registerWorkspaceSurfaceProducer } from './workspace-surface-production'
+import { getExecutionHostIdForWorktree } from './worktree-runtime-owner'
+import { toRuntimeExecutionHostId } from '../../../shared/execution-host'
+
+type BrowserTabCreator = ReturnType['createBrowserTab']
+type RemoteBrowserPageHandleSetter = ReturnType<
+ typeof useAppStore.getState
+>['setRemoteBrowserPageHandle']
+
+const WORKSPACE_PORT_TARGET_UNAVAILABLE_REASON =
+ 'Workspace ports are unavailable for this execution host.'
+
+export async function openWorkspacePortInBrowser(args: {
+ port: WorkspacePort
+ activeWorktreeId?: string | null
+ runtimeTarget: RuntimeClientTarget | null
+ createBrowserTab: BrowserTabCreator
+ setRemoteBrowserPageHandle: RemoteBrowserPageHandleSetter
+ openInOrcaBrowser?: boolean
+ localhostLabelRoute?: LocalhostWorktreeLabelRoute | null
+}): Promise<{ ok: true } | { ok: false; reason: string }> {
+ if (!args.runtimeTarget) {
+ return { ok: false, reason: WORKSPACE_PORT_TARGET_UNAVAILABLE_REASON }
+ }
+ const rawUrl = browserUrlForPort(args.port)
+ let url = rawUrl
+ if (args.runtimeTarget.kind === 'local' && args.localhostLabelRoute) {
+ try {
+ url = (await window.api.localhostWorktreeLabels.register(args.localhostLabelRoute)).url
+ } catch {
+ url = rawUrl
+ }
+ }
+ if (args.openInOrcaBrowser === false && args.runtimeTarget.kind === 'local') {
+ try {
+ await window.api.shell.openUrl(url)
+ return { ok: true }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ return { ok: false, reason: message || 'Failed to open system browser.' }
+ }
+ }
+
+ const worktreeId =
+ args.port.kind === 'workspace' ? args.port.owner.worktreeId : args.activeWorktreeId
+ if (!worktreeId) {
+ return { ok: false, reason: 'No workspace selected for the browser.' }
+ }
+ const state = useAppStore.getState()
+ const executionHostId =
+ args.runtimeTarget.kind === 'environment'
+ ? toRuntimeExecutionHostId(args.runtimeTarget.environmentId)
+ : getExecutionHostIdForWorktree(state, worktreeId)
+ const producer = registerWorkspaceSurfaceProducer({ workspaceKey: worktreeId, executionHostId })
+ try {
+ const activation = activateAndRevealWorktree(worktreeId, { executionHostId })
+ if (activation === false) {
+ producer.failed('The workspace is no longer available.')
+ return { ok: false, reason: 'The workspace is no longer available.' }
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ producer.failed(message || 'Failed to activate the workspace.')
+ return { ok: false, reason: message || 'Failed to activate the workspace.' }
+ }
+ if (args.runtimeTarget.kind === 'environment') {
+ try {
+ await assertRuntimeEnvironmentCapability(
+ args.runtimeTarget.environmentId,
+ BROWSER_SCREENCAST_RUNTIME_CAPABILITY,
+ RUNTIME_BROWSER_UNAVAILABLE_MESSAGE
+ )
+ const remotePage = await callRuntimeRpc<{ browserPageId: string }>(
+ args.runtimeTarget,
+ 'browser.tabCreate',
+ { worktree: toRuntimeWorktreeSelector(worktreeId), url },
+ { timeoutMs: 30_000 }
+ )
+ const tab = args.createBrowserTab(worktreeId, url, {
+ activate: true,
+ browserRuntimeEnvironmentId: args.runtimeTarget.environmentId
+ })
+ if (!tab.activePageId) {
+ producer.failed('Failed to create a browser page.')
+ return { ok: false, reason: 'Failed to create a browser page.' }
+ }
+ args.setRemoteBrowserPageHandle(tab.activePageId, {
+ environmentId: args.runtimeTarget.environmentId,
+ remotePageId: remotePage.browserPageId
+ })
+ producer.materialized({ kind: 'tab', id: tab.id })
+ return { ok: true }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ producer.failed(message || 'Failed to open remote browser.')
+ return { ok: false, reason: message || 'Failed to open remote browser.' }
+ }
+ }
+ try {
+ const tab = args.createBrowserTab(worktreeId, url, { activate: true })
+ producer.materialized({ kind: 'tab', id: tab.id })
+ return { ok: true }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ producer.failed(message || 'Failed to open browser.')
+ return { ok: false, reason: message || 'Failed to open browser.' }
+ }
+}
diff --git a/src/renderer/src/lib/workspace-surface-production.ts b/src/renderer/src/lib/workspace-surface-production.ts
new file mode 100644
index 00000000000..ea329317039
--- /dev/null
+++ b/src/renderer/src/lib/workspace-surface-production.ts
@@ -0,0 +1,122 @@
+import type { ExecutionHostId } from '../../../shared/execution-host'
+import { createBrowserUuid } from './browser-uuid'
+
+export type WorkspaceSurfaceIdentity =
+ | { kind: 'tab'; id: string }
+ | { kind: 'workspace-content'; id: string }
+
+export type WorkspaceSurfaceProductionResult =
+ | { kind: 'materialized'; surface: WorkspaceSurfaceIdentity }
+ | { kind: 'declined'; reason: string }
+ | { kind: 'failed'; reason: string }
+ | { kind: 'unverifiable'; reason: string }
+
+export type WorkspaceSurfaceProducerAttempt = {
+ id: string
+ workspaceKey: string
+ executionHostId: ExecutionHostId
+ result: Promise
+}
+
+export type WorkspaceSurfaceProducer = {
+ attempt: WorkspaceSurfaceProducerAttempt
+ materialized: (surface: WorkspaceSurfaceIdentity) => void
+ declined: (reason: unknown) => void
+ failed: (reason: unknown) => void
+ unverifiable: (reason: unknown) => void
+}
+
+export type WorkspaceSurfaceProducerEntry = {
+ attempt: WorkspaceSurfaceProducerAttempt
+ result: WorkspaceSurfaceProductionResult | null
+ settle: (result: WorkspaceSurfaceProductionResult) => void
+}
+
+const entriesByAttemptId = new Map()
+const listeners = new Set<() => void>()
+
+function notifyListeners(): void {
+ for (const listener of listeners) {
+ listener()
+ }
+}
+
+function reasonText(reason: unknown): string {
+ if (reason instanceof Error) {
+ return reason.message
+ }
+ const text = String(reason)
+ return text === '[object Object]' ? 'The surface producer did not provide a reason.' : text
+}
+
+export function registerWorkspaceSurfaceProducer(args: {
+ workspaceKey: string
+ executionHostId: ExecutionHostId
+ attemptId?: string
+}): WorkspaceSurfaceProducer {
+ const id = args.attemptId?.trim() || createBrowserUuid()
+ if (entriesByAttemptId.has(id)) {
+ throw new Error(`A workspace surface producer already owns attempt ${id}.`)
+ }
+ let settlePromise: (result: WorkspaceSurfaceProductionResult) => void = () => undefined
+ const result = new Promise((resolve) => {
+ settlePromise = resolve
+ })
+ const attempt: WorkspaceSurfaceProducerAttempt = {
+ id,
+ workspaceKey: args.workspaceKey,
+ executionHostId: args.executionHostId,
+ result
+ }
+ const entry: WorkspaceSurfaceProducerEntry = {
+ attempt,
+ result: null,
+ settle: (settlement) => {
+ if (entry.result) {
+ return
+ }
+ entry.result = settlement
+ settlePromise(settlement)
+ notifyListeners()
+ }
+ }
+ entriesByAttemptId.set(id, entry)
+ notifyListeners()
+ return {
+ attempt,
+ materialized: (surface) => entry.settle({ kind: 'materialized', surface }),
+ declined: (reason) => entry.settle({ kind: 'declined', reason: reasonText(reason) }),
+ failed: (reason) => entry.settle({ kind: 'failed', reason: reasonText(reason) }),
+ unverifiable: (reason) => entry.settle({ kind: 'unverifiable', reason: reasonText(reason) })
+ }
+}
+
+export function readWorkspaceSurfaceProducerEntries(args: {
+ workspaceKey: string
+ executionHostId: ExecutionHostId
+}): readonly Readonly[] {
+ return [...entriesByAttemptId.values()].filter(
+ (entry) =>
+ entry.attempt.workspaceKey === args.workspaceKey &&
+ entry.attempt.executionHostId === args.executionHostId
+ )
+}
+
+export function consumeWorkspaceSurfaceProducerAttempt(attemptId: string): void {
+ const entry = entriesByAttemptId.get(attemptId)
+ if (!entry || entry.result?.kind === 'unverifiable' || entry.result === null) {
+ return
+ }
+ entriesByAttemptId.delete(attemptId)
+ notifyListeners()
+}
+
+export function subscribeWorkspaceSurfaceProducers(listener: () => void): () => void {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+}
+
+export function resetWorkspaceSurfaceProducersForTests(): void {
+ entriesByAttemptId.clear()
+ notifyListeners()
+}
diff --git a/src/renderer/src/lib/workspace-terminal-host-authority.test.ts b/src/renderer/src/lib/workspace-terminal-host-authority.test.ts
index c7c5bc60f7e..fdce0431d25 100644
--- a/src/renderer/src/lib/workspace-terminal-host-authority.test.ts
+++ b/src/renderer/src/lib/workspace-terminal-host-authority.test.ts
@@ -98,26 +98,22 @@ describe('workspace terminal seeding authority', () => {
// The host answers and holds nothing here. That is positive evidence, so the workspace seeds.
store.getState().markRemoteWorkspaceHydrated(TARGET_ID)
+ store.getState().setRemoteWorkspaceSyncStatus(TARGET_ID, { phase: 'synced', direction: 'pull' })
expect(resolveWorkspaceTerminalHostAuthority(store.getState(), SSH_WORKTREE_ID)).toBe('none')
expect(ensureWorktreeHasInitialTerminal(store.getState(), SSH_WORKTREE_ID)).toBeTruthy()
expect(terminalTabCount(store, SSH_WORKTREE_ID)).toBe(1)
})
it.each(['offline', 'error'] as const)(
- 'falls back to none when a sync terminates in %s without ever hydrating',
+ 'does not let the legacy %s-to-none floor authorize a writer',
(phase) => {
- // The regression this guards: remoteWorkspaceHydratedTargetIds is add-only in practice
- // (clearRemoteWorkspaceHydrated has no production caller), so without a floor one failed sync
- // leaves every git worktree on this target terminal-less and its sleeping agents unresumable
- // for the rest of the app session — strictly worse than the pre-gate behaviour, and escapable
- // only by creating a tab by hand.
const store = createTestStore()
seedDirectSsh(store)
store.getState().setRemoteWorkspaceSyncStatus(TARGET_ID, { phase, direction: 'pull' })
expect(resolveWorkspaceTerminalHostAuthority(store.getState(), SSH_WORKTREE_ID)).toBe('none')
- expect(ensureWorktreeHasInitialTerminal(store.getState(), SSH_WORKTREE_ID)).toBeTruthy()
- expect(terminalTabCount(store, SSH_WORKTREE_ID)).toBe(1)
+ expect(ensureWorktreeHasInitialTerminal(store.getState(), SSH_WORKTREE_ID)).toBeNull()
+ expect(terminalTabCount(store, SSH_WORKTREE_ID)).toBe(0)
}
)
diff --git a/src/renderer/src/lib/workspace-terminal-host-authority.ts b/src/renderer/src/lib/workspace-terminal-host-authority.ts
index ad535e94563..ed8077e4bfb 100644
--- a/src/renderer/src/lib/workspace-terminal-host-authority.ts
+++ b/src/renderer/src/lib/workspace-terminal-host-authority.ts
@@ -45,14 +45,9 @@ export type WorkspaceTerminalHostAuthorityState = WorktreeRuntimeOwnerState & {
* lift it. Four paths reach here: local-hydration timeout, a null `remoteWorkspace.get`, a falsy
* apply token, and never connecting at all.
*
- * Known gap: this floor was reasoned about when hydration was add-only, so "un-hydrated" implied
- * "the host never answered". A snapshot whose rows could not be placed now revokes hydration
- * (remote-workspace-snapshot-apply.ts), so a target that later lands on `offline`/`error` reaches
- * this floor having *demonstrably* answered with tabs. Seeding is then authorised over live host
- * terminals. That is not a regression — before the revocation existed the same target was marked
- * hydrated and `synced`, which reached `none` sooner — but the floor should learn to tell a
- * revoked target from one that never answered. Tracked for the SSH-v3 consolidation, where a
- * single authoritative liveness source replaces this pair. */
+ * This legacy ownership floor is not execution evidence. Writer authorization goes through
+ * workspace-execution-evidence, which keeps offline/error and stale hydration `unverifiable`.
+ * Tracked for SSH-v3 consolidation, where one authoritative liveness source replaces this pair. */
const TERMINATED_WITHOUT_ANSWER_PHASES = new Set(['offline', 'error'])
function resolveDirectSshAuthority(
@@ -67,10 +62,8 @@ function resolveDirectSshAuthority(
return phase === 'conflict' ? 'unverifiable' : 'none'
}
if (phase !== undefined && TERMINATED_WITHOUT_ANSWER_PHASES.has(phase)) {
- // The bounded floor. Without it a single failed sync leaves every git worktree on this target
- // terminal-less and its sleeping agents unresumable for the rest of the app session — strictly
- // worse than the pre-gate behaviour, and only escapable by creating a tab by hand. Declining to
- // seed is meant to be a wait, not a permanent refusal.
+ // Legacy readers retain the bounded floor; recovery and seeding separately require current
+ // `exited` evidence, so this value cannot authorize a writer while the host is unreachable.
return 'none'
}
// Not connected, still pulling, or not yet attempted — "we could not ask", never "nothing there".
diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts
index dd6eaa2ac31..f8a885fb1ed 100644
--- a/src/renderer/src/lib/worktree-activation-created-agent.test.ts
+++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts
@@ -217,7 +217,8 @@ describe('activateAndRevealWorktree', () => {
})
it('forwards an explicit sidebar reveal behavior', () => {
- const worktree = makeWorktree()
+ // A distinct identity prevents this synchronous contract from joining another test's gate.
+ const worktree = { ...makeWorktree(), id: 'wt-sidebar-reveal' }
const revealWorktreeInSidebar = vi.fn()
useAppStore.setState({
diff --git a/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts b/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts
index 4d2b43e26bd..73aa2674fcb 100644
--- a/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts
+++ b/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts
@@ -1,304 +1,145 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
-import {
- activateAndRevealFolderWorkspace,
- activateAndRevealWorkspace,
- activateAndRevealWorktree
-} from './worktree-activation'
+import { activateAndRevealFolderWorkspace, activateAndRevealWorktree } from './worktree-activation'
import * as activationGate from './worktree-agent-activation-gate'
-import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding'
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
import { toSshExecutionHostId } from '../../../shared/execution-host'
import {
makeCreatedAgentWorktree as makeWorktree,
seedEmptyActivatableWorktree
} from '@/lib/worktree-activation-created-agent-test-state'
-import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate'
+import {
+ registerWorkspaceSurfaceProducer,
+ resetWorkspaceSurfaceProducersForTests
+} from './workspace-surface-production'
+import { resetWorkspaceActivationRecoveryPresentationsForTests } from './workspace-activation-recovery-presentation'
+import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
const initialAppStoreState = useAppStore.getState()
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
+ resetWorkspaceSurfaceProducersForTests()
+ resetWorkspaceActivationRecoveryPresentationsForTests()
useAppStore.setState(initialAppStoreState, true)
})
-/** The state a workspace lands in once its last terminal is closed: the row survives as an
- * explicit empty list rather than disappearing. */
function seedClosedLastTerminal(worktreeId: string): void {
useAppStore.setState({ tabsByWorktree: { [worktreeId]: [] } })
- const { renderableTabCount } = useAppStore.getState().reconcileWorktreeTabModel(worktreeId)
- expect(renderableTabCount).toBe(0)
+ expect(useAppStore.getState().reconcileWorktreeTabModel(worktreeId).renderableTabCount).toBe(0)
}
-describe('activating a workspace whose last terminal was closed', () => {
+function sleepingRecord(worktreeId: string): SleepingAgentSessionRecord {
+ return {
+ paneKey: 'pane-1',
+ worktreeId,
+ agent: 'codex',
+ providerSession: { key: 'session_id', id: 'session-1' },
+ prompt: '',
+ state: 'done',
+ capturedAt: 1,
+ updatedAt: 1,
+ origin: 'worktree-sleep'
+ }
+}
+
+describe('selection-free empty-workspace recovery', () => {
it.each([
- ['Blank Terminal', null, 1],
- ['an agent', 'codex' as const, 0]
- ])('seeds a default shell for %s selection only', (_label, agent, expectedTabCount) => {
+ ['Blank Terminal', null],
+ ['an agent selection without a producer', 'codex' as const]
+ ])('seeds one plain shell for %s when inventory APIs are unavailable', async (_label, agent) => {
const worktree = makeWorktree()
seedEmptyActivatableWorktree(worktree)
+ seedClosedLastTerminal(worktree.id)
const result = activateAndRevealWorktree(worktree.id, {
agent,
notifyHostRuntime: false
})
- expect(result).not.toBe(false)
- expect(result === false ? null : result.primaryTabId === null).toBe(expectedTabCount === 0)
- expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(expectedTabCount)
+ expect(result).toEqual({ primaryTabId: expect.any(String) })
+ expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1)
+ expect(result === false ? null : result.primaryTabId).toBe(
+ useAppStore.getState().tabsByWorktree[worktree.id]?.[0]?.id
+ )
+ expect(useAppStore.getState().tabsByWorktree[worktree.id]?.[0]?.launchAgent).toBeUndefined()
})
- it.each([true, false])(
- 'forwards providesInitialSurface=%s through the async activation gate',
- async (providesInitialSurface) => {
+ it.each([null, 'codex' as const])(
+ 're-seeds after a gate reports empty regardless of picker selection (%s)',
+ async (agent) => {
const worktree = makeWorktree()
seedEmptyActivatableWorktree(worktree)
seedClosedLastTerminal(worktree.id)
useAppStore.setState({
sleepingAgentSessionsByPaneKey: {
- 'pane-1': { worktreeId: worktree.id }
- } as never
+ 'pane-1': sleepingRecord(worktree.id)
+ }
})
- const gate = vi.spyOn(activationGate, 'gateWorktreeAgentActivation')
- gate.mockResolvedValue('empty')
+ vi.spyOn(activationGate, 'gateWorktreeAgentActivation').mockResolvedValue('empty')
- activateAndRevealWorktree(worktree.id, {
- providesInitialSurface,
- notifyHostRuntime: false
- })
- await gate.mock.results[0]?.value
+ activateAndRevealWorktree(worktree.id, { agent, notifyHostRuntime: false })
- expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(
- providesInitialSurface ? 0 : 1
+ await vi.waitFor(() =>
+ expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1)
)
}
)
- // An empty gate invalidates agent-based suppression; only an explicit caller surface survives.
- it.each([
- ['an agent selection', { agent: 'codex' as const }, 1],
- [
- 'an agent selection whose caller promised its own surface',
- { agent: 'codex' as const, providesInitialSurface: true },
- 0
- ]
- ])('re-seeds a gate-reported empty workspace for %s', async (_label, selection, expectedTabs) => {
+ it('waits for a concrete surface producer instead of a promise boolean', async () => {
const worktree = makeWorktree()
seedEmptyActivatableWorktree(worktree)
- seedClosedLastTerminal(worktree.id)
- useAppStore.setState({
- sleepingAgentSessionsByPaneKey: {
- 'pane-1': { worktreeId: worktree.id }
- } as never
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey: worktree.id,
+ executionHostId: 'local'
})
- const gate = vi.spyOn(activationGate, 'gateWorktreeAgentActivation')
- gate.mockResolvedValue('empty')
-
- const result = activateAndRevealWorktree(worktree.id, {
- ...selection,
- notifyHostRuntime: false
- })
- await gate.mock.results[0]?.value
-
- // The gate owns seeding for this activation, so the synchronous call never returns a tab.
- expect(result === false ? null : result.primaryTabId).toBeNull()
- expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(expectedTabs)
- })
-
- it('lets the latest caller surface supersede an older callback on the shared gate', async () => {
- const worktree = makeWorktree()
- seedEmptyActivatableWorktree(worktree)
- seedClosedLastTerminal(worktree.id)
- useAppStore.setState({
- sleepingAgentSessionsByPaneKey: {
- 'pane-1': { worktreeId: worktree.id }
- } as never
- })
- let resolveGate!: (outcome: activationGate.WorktreeAgentActivationOutcome) => void
- const pendingGate = new Promise((resolve) => {
- resolveGate = resolve
- })
- vi.spyOn(activationGate, 'gateWorktreeAgentActivation').mockReturnValue(pendingGate)
activateAndRevealWorktree(worktree.id, { agent: 'codex', notifyHostRuntime: false })
- activateAndRevealWorktree(worktree.id, {
- providesInitialSurface: true,
- notifyHostRuntime: false
- })
- resolveGate('empty')
- await pendingGate
+ await Promise.resolve()
- expect(useAppStore.getState().tabsByWorktree[worktree.id]).toEqual([])
+ expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0)
+ producer.materialized({ kind: 'workspace-content', id: 'requested-content' })
+ await producer.attempt.result
+ expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0)
})
- it('does not double-seed when another recovery creates a tab before the gate settles', async () => {
+ it('does not double-seed when content appears before the gate settles', async () => {
const worktree = makeWorktree()
seedEmptyActivatableWorktree(worktree)
seedClosedLastTerminal(worktree.id)
useAppStore.setState({
- sleepingAgentSessionsByPaneKey: {
- 'pane-1': { worktreeId: worktree.id }
- } as never
+ sleepingAgentSessionsByPaneKey: { 'pane-1': sleepingRecord(worktree.id) }
})
let resolveGate!: (outcome: activationGate.WorktreeAgentActivationOutcome) => void
- const pendingGate = new Promise((resolve) => {
- resolveGate = resolve
- })
- vi.spyOn(activationGate, 'gateWorktreeAgentActivation').mockReturnValue(pendingGate)
-
- activateAndRevealWorktree(worktree.id, { agent: 'codex', notifyHostRuntime: false })
- const recoveredTabId = ensureWorktreeHasInitialTerminal(
- useAppStore.getState(),
- worktree.id,
- undefined,
- undefined,
- undefined,
- undefined,
- { reseedEmptiedWorkspace: true }
- )
- resolveGate('empty')
- await pendingGate
-
- expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1)
- expect(useAppStore.getState().tabsByWorktree[worktree.id]?.[0]?.id).toBe(recoveredTabId)
- })
-
- it('re-seeds a terminal when the workspace is opened from elsewhere', () => {
- const worktree = makeWorktree()
- seedEmptyActivatableWorktree(worktree)
- seedClosedLastTerminal(worktree.id)
-
- const result = activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false })
-
- expect(result).not.toBe(false)
- expect(result === false ? null : result.primaryTabId).toBeTruthy()
- expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1)
- })
-
- // Why: hydration restores an emptied workspace as active, so the user is already looking at the
- // blank pane when they click its row. Suppressing the re-seed there strands them on the bug.
- it('re-seeds when the restored active workspace is reopened on the same host', () => {
- const worktree = makeWorktree()
- seedEmptyActivatableWorktree(worktree)
- seedClosedLastTerminal(worktree.id)
- useAppStore.setState({
- activeWorktreeId: worktree.id,
- activeWorkspaceExecutionHostId: 'local',
- activeView: 'terminal'
- })
-
- activateAndRevealWorktree(worktree.id, {
- executionHostId: 'local',
- notifyHostRuntime: false
- })
-
- expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1)
- })
-
- // Why: entry points disagree about whether to pass a host for the same local workspace — the
- // sidebar derives 'local', the Cmd+J palette passes nothing. Re-seeding no longer reads the
- // host at all; this guards against reintroducing a host-sensitive carve-out.
- it('re-seeds identically whether or not the caller passes an execution host', () => {
- for (const opts of [{}, { executionHostId: 'local' as const }]) {
- const worktree = makeWorktree()
- seedEmptyActivatableWorktree(worktree)
- seedClosedLastTerminal(worktree.id)
- useAppStore.setState({
- activeWorktreeId: worktree.id,
- activeWorkspaceExecutionHostId: 'local',
- activeView: 'terminal'
+ vi.spyOn(activationGate, 'gateWorktreeAgentActivation').mockReturnValue(
+ new Promise((resolve) => {
+ resolveGate = resolve
})
-
- activateAndRevealWorktree(worktree.id, { ...opts, notifyHostRuntime: false })
-
- expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1)
- }
- })
-
- // Why: terminal file links and check annotations activate only to route history before they
- // open an editor tab. Seeding there hands the user a shell they never asked for and erases the
- // tombstone permanently. See terminal-file-open-routing.ts and check-annotation-open.ts.
- it('leaves the row empty when the caller opens its own surface', () => {
- const worktree = makeWorktree()
- seedEmptyActivatableWorktree(worktree)
- seedClosedLastTerminal(worktree.id)
-
- const result = activateAndRevealWorktree(worktree.id, {
- providesInitialSurface: true,
- notifyHostRuntime: false
- })
-
- expect(result).not.toBe(false)
- expect(result === false ? null : result.primaryTabId).toBeNull()
- expect(useAppStore.getState().tabsByWorktree[worktree.id]).toEqual([])
- })
-
- it('leaves the row empty for startup hydration, which never opts into re-seeding', () => {
- const worktree = makeWorktree()
- seedEmptyActivatableWorktree(worktree)
- seedClosedLastTerminal(worktree.id)
-
- const tabId = ensureWorktreeHasInitialTerminal(useAppStore.getState(), worktree.id)
-
- expect(tabId).toBeNull()
- expect(useAppStore.getState().tabsByWorktree[worktree.id]).toEqual([])
- })
-
- // Why: this passes ahead of the tombstone check — a renderable browser tab short-circuits
- // `shouldAutoCreateInitialTerminal` — so it guards `renderableTabCount`, not the re-seed flag.
- it('does not add a terminal to a workspace that still renders a browser tab', () => {
- const worktree = makeWorktree()
- seedEmptyActivatableWorktree(worktree)
- seedClosedLastTerminal(worktree.id)
- useAppStore.setState({
- browserTabsByWorktree: {
- [worktree.id]: [
- {
- id: 'browser-1',
- worktreeId: worktree.id,
- url: 'https://example.com',
- title: 'example',
- sortOrder: 0,
- createdAt: 1
- }
- ]
- },
- unifiedTabsByWorktree: {
- [worktree.id]: [
- {
- id: 'browser-1',
- entityId: 'browser-1',
- groupId: 'group-1',
- worktreeId: worktree.id,
- contentType: 'browser',
- label: 'example',
- customLabel: null,
- color: null,
- sortOrder: 0,
- createdAt: 1
- }
- ]
- },
- groupsByWorktree: {
- [worktree.id]: [
- {
- id: 'group-1',
- worktreeId: worktree.id,
- activeTabId: 'browser-1',
- tabOrder: ['browser-1']
- }
- ]
- },
- activeGroupIdByWorktree: { [worktree.id]: 'group-1' }
- } as unknown as Partial>)
- expect(
- useAppStore.getState().reconcileWorktreeTabModel(worktree.id).renderableTabCount
- ).toBeGreaterThan(0)
+ )
activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false })
+ useAppStore.getState().createTab(worktree.id)
+ resolveGate('empty')
+
+ await vi.waitFor(() =>
+ expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1)
+ )
+ })
+
+ it('keeps a surviving browser as the chosen surface', async () => {
+ const worktree = makeWorktree()
+ seedEmptyActivatableWorktree(worktree)
+ seedClosedLastTerminal(worktree.id)
+ useAppStore.getState().createBrowserTab(worktree.id, 'https://example.com', { activate: true })
+
+ activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false })
+ await Promise.resolve()
expect(useAppStore.getState().tabsByWorktree[worktree.id]).toEqual([])
+ expect(
+ useAppStore.getState().reconcileWorktreeTabModel(worktree.id).activeRenderableTabId
+ ).toBeTruthy()
})
})
@@ -306,203 +147,67 @@ const FOLDER_ID = 'folder-1'
const FOLDER_KEY = folderWorkspaceKey(FOLDER_ID)
const SSH_HOST_ID = toSshExecutionHostId('conn-1')
-/** One folder id resolves to a different `FolderWorkspace` per host while both share a single
- * `tabsByWorktree[FOLDER_KEY]` row. */
-function seedEmptiedFolderWorkspaceOnTwoHosts(): void {
- const base = {
- id: FOLDER_ID,
- projectGroupId: 'group-1',
- name: 'notes',
- linkedTask: null,
- comment: '',
- isArchived: false,
- isUnread: false,
- isPinned: false,
- sortOrder: 0
- }
+function seedEmptyFolderWorkspace(executionHostId: 'local' | `ssh:${string}`): void {
useAppStore.setState({
folderWorkspaces: [
- { ...base, folderPath: '/local/notes', executionHostId: 'local' },
- // Why: an SSH host, not a runtime one — runtime-owned workspaces return early from
- // `ensureWorktreeHasInitialTerminal` because the host owns terminal creation.
- { ...base, folderPath: '/remote/notes', executionHostId: SSH_HOST_ID, connectionId: 'conn-1' }
+ {
+ id: FOLDER_ID,
+ projectGroupId: 'group-1',
+ name: 'notes',
+ folderPath: executionHostId === 'local' ? '/local/notes' : '/remote/notes',
+ executionHostId,
+ ...(executionHostId === 'local' ? {} : { connectionId: 'conn-1' }),
+ linkedTask: null,
+ comment: '',
+ isArchived: false,
+ isUnread: false,
+ isPinned: false,
+ sortOrder: 0,
+ lastActivityAt: 1,
+ createdAt: 1,
+ updatedAt: 1
+ }
],
activeView: 'terminal',
tabsByWorktree: { [FOLDER_KEY]: [] },
unifiedTabsByWorktree: {},
groupsByWorktree: {},
- getFreshFolderWorkspacePathStatus: () => ({ exists: true }),
+ getFreshFolderWorkspacePathStatus: () => ({ exists: true, path: '/local/notes' }),
markWorktreeVisited: vi.fn(),
recordWorktreeVisit: vi.fn(),
revealWorktreeInSidebar: vi.fn()
- } as unknown as Partial>)
- expect(useAppStore.getState().reconcileWorktreeTabModel(FOLDER_KEY).renderableTabCount).toBe(0)
+ })
}
-describe('activating a folder workspace whose last terminal was closed', () => {
- it.each([
- ['Blank Terminal', null, 1],
- ['an agent', 'codex' as const, 0]
- ])('seeds a default shell for %s selection only', (_label, agent, expectedTabCount) => {
- seedEmptiedFolderWorkspaceOnTwoHosts()
+describe('folder activation recovery', () => {
+ it('recovers the folder-specific activation path without consulting agent selection', async () => {
+ seedEmptyFolderWorkspace('local')
- const result = activateAndRevealFolderWorkspace(FOLDER_ID, {
- agent,
+ activateAndRevealFolderWorkspace(FOLDER_ID, {
+ agent: 'codex',
executionHostId: 'local'
})
- expect(result).not.toBe(false)
- expect(useAppStore.getState().activeWorktreeId).toBe(FOLDER_KEY)
- expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toHaveLength(expectedTabCount)
+ await vi.waitFor(() =>
+ expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toHaveLength(1)
+ )
})
- it.each([true, false])(
- 'forwards providesInitialSurface=%s through the async activation gate',
- async (providesInitialSurface) => {
- seedEmptiedFolderWorkspaceOnTwoHosts()
- useAppStore.setState({
- sleepingAgentSessionsByPaneKey: {
- 'pane-1': { worktreeId: FOLDER_KEY }
- } as never
- })
- const gate = vi.spyOn(activationGate, 'gateWorktreeAgentActivation')
- gate.mockResolvedValue('empty')
+ it('keeps the direct general setter state-only', async () => {
+ seedEmptyFolderWorkspace('local')
- activateAndRevealFolderWorkspace(FOLDER_ID, {
- executionHostId: 'local',
- providesInitialSurface
- })
- await gate.mock.results[0]?.value
+ useAppStore.getState().setActiveWorktree(FOLDER_KEY, 'local')
+ await Promise.resolve()
- expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toHaveLength(
- providesInitialSurface ? 0 : 1
- )
- }
- )
-
- it.each(['local', SSH_HOST_ID] as const)(
- 'opens a notification on %s without revealing the folder',
- (executionHostId) => {
- seedEmptiedFolderWorkspaceOnTwoHosts()
- useAppStore.setState({ sidebarBody: 'agents' })
-
- const result = activateAndRevealWorkspace(FOLDER_KEY, {
- executionHostId,
- revealInSidebar: false,
- clearSidebarFilters: false
- })
-
- expect(result).not.toBe(false)
- expect(useAppStore.getState().activeWorktreeId).toBe(FOLDER_KEY)
- expect(useAppStore.getState().sidebarBody).toBe('agents')
- expect(useAppStore.getState().revealWorktreeInSidebar).not.toHaveBeenCalled()
- }
- )
-
- // This must use the folder entry point; setActiveWorktree(folderWorkspaceKey(...)) takes the git path.
- it.each([
- ['a local agent selection', { agent: 'codex' as const }, 'local' as const, 1],
- ['an SSH agent selection', { agent: 'codex' as const }, SSH_HOST_ID, 1],
- [
- 'an agent selection whose caller promised its own surface',
- { agent: 'codex' as const, providesInitialSurface: true },
- 'local' as const,
- 0
- ]
- ])(
- 're-seeds a gate-reported empty workspace for %s',
- async (_label, selection, executionHostId, expectedTabs) => {
- seedEmptiedFolderWorkspaceOnTwoHosts()
- useAppStore.setState({
- sleepingAgentSessionsByPaneKey: {
- 'pane-1': { worktreeId: FOLDER_KEY }
- } as never
- })
- const gate = vi.spyOn(activationGate, 'gateWorktreeAgentActivation')
- gate.mockResolvedValue('empty')
-
- activateAndRevealFolderWorkspace(FOLDER_ID, { ...selection, executionHostId })
- await gate.mock.results[0]?.value
-
- expect(useAppStore.getState().activeWorktreeId).toBe(FOLDER_KEY)
- expect(useAppStore.getState().activeWorkspaceExecutionHostId).toBe(executionHostId)
- expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY] ?? []).toHaveLength(expectedTabs)
- }
- )
-
- it('re-seeds a terminal when the workspace is opened', () => {
- seedEmptiedFolderWorkspaceOnTwoHosts()
-
- const result = activateAndRevealFolderWorkspace(FOLDER_ID, { executionHostId: 'local' })
-
- expect(result).not.toBe(false)
- expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toHaveLength(1)
- })
-
- it('re-seeds when the restored active folder workspace is reopened on the same host', () => {
- seedEmptiedFolderWorkspaceOnTwoHosts()
- useAppStore.setState({
- activeWorktreeId: FOLDER_KEY,
- activeWorkspaceExecutionHostId: 'local'
- })
-
- activateAndRevealFolderWorkspace(FOLDER_ID, { executionHostId: 'local' })
-
- expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toHaveLength(1)
- })
-
- // Why: the opt-out must mean the same thing on both workspace shapes, or routing a
- // file link through a folder workspace would silently regress to seeding a shell.
- it('leaves the row empty when the caller opens its own surface', async () => {
- seedEmptiedFolderWorkspaceOnTwoHosts()
- useAppStore.setState({
- workspaceSessionReady: true,
- terminalStartupRestorationReady: true
- })
- vi.stubGlobal('window', {
- api: {
- runtime: {
- // A `session.tabs.list` answer must name the scope it listed, or the gate refuses it and
- // blocks — which would leave the row empty for the wrong reason.
- call: vi.fn(async () => ({
- ok: true,
- result: {
- worktree: FOLDER_KEY,
- publicationEpoch: 'epoch-1',
- snapshotVersion: 1,
- activeGroupId: null,
- activeTabId: null,
- activeTabType: null,
- tabs: []
- }
- }))
- },
- pty: { listSessions: vi.fn(async () => []) }
- }
- })
-
- const result = activateAndRevealFolderWorkspace(FOLDER_ID, {
- executionHostId: 'local',
- providesInitialSurface: true
- })
- await waitForWorktreeAgentActivationGateForTests(FOLDER_KEY)
-
- expect(result).not.toBe(false)
- expect(result === false ? null : result.primaryTabId).toBeNull()
expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toEqual([])
})
- it('re-seeds the shared row when opening the same folder id on a different host', () => {
- seedEmptiedFolderWorkspaceOnTwoHosts()
- useAppStore.setState({
- activeWorktreeId: FOLDER_KEY,
- activeWorkspaceExecutionHostId: 'local'
- })
+ it('does not start a writer for an unverifiable SSH folder', async () => {
+ seedEmptyFolderWorkspace(SSH_HOST_ID)
- const result = activateAndRevealFolderWorkspace(FOLDER_ID, { executionHostId: SSH_HOST_ID })
+ activateAndRevealFolderWorkspace(FOLDER_ID, { executionHostId: SSH_HOST_ID })
+ await Promise.resolve()
- expect(result).not.toBe(false)
- expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toHaveLength(1)
- expect(useAppStore.getState().activeWorkspaceExecutionHostId).toBe(SSH_HOST_ID)
+ expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toEqual([])
})
})
diff --git a/src/renderer/src/lib/worktree-activation-empty-remote.test.ts b/src/renderer/src/lib/worktree-activation-empty-remote.test.ts
index c16a8d6a448..d3e3300d87c 100644
--- a/src/renderer/src/lib/worktree-activation-empty-remote.test.ts
+++ b/src/renderer/src/lib/worktree-activation-empty-remote.test.ts
@@ -7,6 +7,11 @@ import { resetWebSessionTabsSnapshotFreshnessForTests } from '@/runtime/web-sess
import { useAppStore } from '@/store'
import { ensureWebRuntimeWorktreeTerminalAfterWake } from './web-runtime-worktree-terminal-after-wake'
import { toast } from 'sonner'
+import {
+ readWorkspaceSurfaceProducerEntries,
+ resetWorkspaceSurfaceProducersForTests
+} from './workspace-surface-production'
+import { toRuntimeExecutionHostId } from '../../../shared/execution-host'
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
@@ -21,6 +26,7 @@ afterEach(() => {
vi.unstubAllGlobals()
resetWebSessionTabsSnapshotFreshnessForTests()
resetWebRuntimeWakeTerminalRespawnForTests()
+ resetWorkspaceSurfaceProducersForTests()
useAppStore.setState(initialAppStoreState, true)
})
@@ -120,6 +126,14 @@ describe('empty remote worktree activation', () => {
})
)
expect(toast.error).not.toHaveBeenCalled()
+ await vi.waitFor(() =>
+ expect(
+ readWorkspaceSurfaceProducerEntries({
+ workspaceKey: worktree.id,
+ executionHostId: toRuntimeExecutionHostId('web-runtime-1')
+ })
+ ).toMatchObject([{ result: { kind: 'unverifiable' } }])
+ )
})
it('surfaces a failed host terminal request without retrying ambiguously', async () => {
@@ -169,5 +183,11 @@ describe('empty remote worktree activation', () => {
})
)
expect(callRuntimeEnvironment).toHaveBeenCalledTimes(1)
+ expect(
+ readWorkspaceSurfaceProducerEntries({
+ workspaceKey: worktree.id,
+ executionHostId: toRuntimeExecutionHostId('web-runtime-1')
+ })
+ ).toMatchObject([{ result: { kind: 'failed', reason: 'Host refused the terminal' } }])
})
})
diff --git a/src/renderer/src/lib/worktree-activation-gated-empty-reseed.ts b/src/renderer/src/lib/worktree-activation-gated-empty-reseed.ts
deleted file mode 100644
index bb597d10e6e..00000000000
--- a/src/renderer/src/lib/worktree-activation-gated-empty-reseed.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import type { ExecutionHostId } from '../../../shared/execution-host'
-import {
- gateWorktreeAgentActivation,
- type WorktreeAgentActivationOutcome
-} from './worktree-agent-activation-gate'
-import { reseedGatedEmptyWorkspace } from './worktree-initial-terminal-seeding'
-
-type GatedEmptyWorkspaceReseedIntent = {
- callerProvidesSurface: boolean
- executionHostId?: ExecutionHostId
-}
-
-const latestReseedIntentByGate = new WeakMap<
- Promise,
- GatedEmptyWorkspaceReseedIntent
->()
-
-export function gateAndReseedEmptyWorkspace(
- workspaceKey: string,
- callerProvidesSurface: boolean,
- executionHostId?: ExecutionHostId
-): void {
- const gate = gateWorktreeAgentActivation(workspaceKey)
- const intent: GatedEmptyWorkspaceReseedIntent = {
- callerProvidesSurface,
- ...(executionHostId ? { executionHostId } : {})
- }
- latestReseedIntentByGate.set(gate, intent)
- void gate.then((outcome) => {
- if (latestReseedIntentByGate.get(gate) !== intent) {
- return
- }
- latestReseedIntentByGate.delete(gate)
- if (outcome === 'empty') {
- reseedGatedEmptyWorkspace(workspaceKey, intent.callerProvidesSurface, intent.executionHostId)
- }
- })
-}
diff --git a/src/renderer/src/lib/worktree-activation-recovery-boundary-census.test.ts b/src/renderer/src/lib/worktree-activation-recovery-boundary-census.test.ts
new file mode 100644
index 00000000000..892c5e46d79
--- /dev/null
+++ b/src/renderer/src/lib/worktree-activation-recovery-boundary-census.test.ts
@@ -0,0 +1,120 @@
+import { readFileSync, readdirSync, statSync } from 'node:fs'
+import { join, relative } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+const rendererRoot = join(process.cwd(), 'src/renderer/src')
+
+function productionSourceFiles(directory = rendererRoot): string[] {
+ return readdirSync(directory).flatMap((entry) => {
+ const path = join(directory, entry)
+ if (statSync(path).isDirectory()) {
+ return productionSourceFiles(path)
+ }
+ return /\.(?:ts|tsx)$/.test(path) && !/\.(?:test|spec)\.(?:ts|tsx)$/.test(path) ? [path] : []
+ })
+}
+
+function callerCounts(identifier: string): Record {
+ const counts: Record = {}
+ const call = new RegExp(`\\b${identifier}\\s*\\(`, 'g')
+ for (const path of productionSourceFiles()) {
+ const count = [...readFileSync(path, 'utf8').matchAll(call)].length
+ if (count > 0) {
+ counts[relative(process.cwd(), path)] = count
+ }
+ }
+ return counts
+}
+
+const stateOnlySetActiveWorktreeCallers = {
+ 'src/renderer/src/components/settings/McpConfigSection.tsx': 2,
+ 'src/renderer/src/components/sidebar/hovered-workspace-delete.ts': 1,
+ 'src/renderer/src/components/sidebar/sleep-worktree-flow.ts': 2,
+ 'src/renderer/src/components/sidebar/use-worktree-card-workspace-actions.ts': 1,
+ 'src/renderer/src/components/sidebar/worktree-context-menu-delete-intent.ts': 1,
+ 'src/renderer/src/components/tab-group/workspace-tab-close-commands.ts': 1,
+ 'src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx': 1,
+ 'src/renderer/src/components/terminal-pane/terminal-handle-links.ts': 1,
+ 'src/renderer/src/components/terminal/terminal-tab-actions.ts': 1,
+ 'src/renderer/src/components/use-terminal-editor-close-queue.ts': 1,
+ 'src/renderer/src/hooks/automation-dispatch-handler.ts': 1,
+ 'src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts': 2,
+ 'src/renderer/src/hooks/ipc-events/terminal-command-state.ts': 1,
+ 'src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts': 2,
+ 'src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts': 1,
+ 'src/renderer/src/lib/file-preview.ts': 2,
+ 'src/renderer/src/lib/http-link-routing.ts': 1,
+ 'src/renderer/src/runtime/web-runtime-session-workspace-selection.ts': 3,
+ 'src/renderer/src/store/slices/editor/actions/restored-editor-owner.ts': 1
+}
+
+function selectionCouplingViolations(source: string): string[] {
+ const violations: string[] = []
+ if (/providesInitialSurface|callerProvidesSurface|callerWillProvideSurface/.test(source)) {
+ violations.push('legacy surface promise')
+ }
+ if (/recoverWorkspaceActivation\s*\(\s*\{[\s\S]{0,160}\.\.\.(?!identity\b)/.test(source)) {
+ violations.push('options spread into recovery')
+ }
+ if (
+ /(?:agent|picker|selection)[^\n]{0,120}(?:recoverWorkspaceActivation|registerWorkspaceSurfaceProducer)/i.test(
+ source
+ )
+ ) {
+ violations.push('selection controls recovery ownership')
+ }
+ return violations
+}
+
+describe('activation recovery architecture census', () => {
+ it('classifies every general setter caller and keeps only the public activation path recovery-triggering', () => {
+ expect(callerCounts('setActiveWorktree')).toEqual({
+ ...stateOnlySetActiveWorktreeCallers,
+ 'src/renderer/src/lib/worktree-activation.ts': 1
+ })
+ })
+
+ it('keeps the folder setter distinct and owned only by the public activation service', () => {
+ expect(callerCounts('setActiveFolderWorkspace')).toEqual({
+ 'src/renderer/src/lib/worktree-activation.ts': 1
+ })
+ })
+
+ it('keeps the boundary API to four exports with no creation or general-seeder dependency', () => {
+ const source = readFileSync(join(rendererRoot, 'lib/worktree-activation-recovery.ts'), 'utf8')
+ const exports = [
+ ...source.matchAll(/^export (?:async )?(?:type |function )([A-Za-z0-9_]+)/gm)
+ ].map((match) => match[1])
+ expect(exports).toEqual([
+ 'WorkspaceActivationIdentity',
+ 'WorkspaceActivationContext',
+ 'WorkspaceActivationRecoveryResult',
+ 'recoverWorkspaceActivation'
+ ])
+ expect(source).not.toContain('worktree-creation')
+ expect(source).not.toContain('ensureWorktreeHasInitialTerminal')
+ expect(selectionCouplingViolations(source)).toEqual([])
+ })
+
+ it('detects aliases, option spreading, picker conditionals, and picker-minted fake claims', () => {
+ const fixtures = [
+ `const callerWillProvideSurface = options.agent != null\nrecoverWorkspaceActivation(identity, { mode: 'explicit', callerWillProvideSurface })`,
+ `recoverWorkspaceActivation({ ...options }, context)`,
+ `if (selection.agent) recoverWorkspaceActivation(identity, context)`,
+ `if (picker.agent) registerWorkspaceSurfaceProducer(identity)`
+ ]
+ for (const fixture of fixtures) {
+ expect(selectionCouplingViolations(fixture)).not.toEqual([])
+ }
+ })
+
+ it('routes all production recovery requests through the owner, watcher, or create failure adapter', () => {
+ expect(callerCounts('recoverWorkspaceActivation')).toEqual({
+ 'src/renderer/src/components/use-terminal-watcher-effects.ts': 1,
+ 'src/renderer/src/lib/workspace-activation-recovery-settlement.ts': 1,
+ 'src/renderer/src/lib/worktree-activation-recovery-routing.ts': 1,
+ 'src/renderer/src/lib/worktree-activation-recovery.ts': 1,
+ 'src/renderer/src/lib/worktree-creation-flow-execute.ts': 1
+ })
+ })
+})
diff --git a/src/renderer/src/lib/worktree-activation-recovery-routing.ts b/src/renderer/src/lib/worktree-activation-recovery-routing.ts
new file mode 100644
index 00000000000..5f4c3174907
--- /dev/null
+++ b/src/renderer/src/lib/worktree-activation-recovery-routing.ts
@@ -0,0 +1,125 @@
+import type { FolderWorkspace } from '../../../shared/folder-workspace-types'
+import type { ExecutionHostId } from '../../../shared/execution-host'
+import { folderWorkspaceKey } from '../../../shared/workspace-scope'
+import { useAppStore } from '@/store'
+import { createBrowserUuid } from './browser-uuid'
+import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding'
+import type { WorktreeStartupPayload } from './worktree-startup-payload'
+import type { WorktreeActivationOptions } from './worktree-activation-surface-selection'
+import {
+ recoverWorkspaceActivation,
+ type WorkspaceActivationIdentity
+} from './worktree-activation-recovery'
+import {
+ consumeWorkspaceSurfaceProducerAttempt,
+ readWorkspaceSurfaceProducerEntries,
+ type WorkspaceSurfaceProducer
+} from './workspace-surface-production'
+import {
+ getExecutionHostIdForWorktree,
+ getRuntimeEnvironmentIdForWorktree
+} from './worktree-runtime-owner'
+
+export function ensureFolderWorkspaceInitialTerminal(
+ folderWorkspace: FolderWorkspace,
+ startup?: WorktreeStartupPayload
+): string | null {
+ return ensureWorktreeHasInitialTerminal(
+ useAppStore.getState(),
+ folderWorkspaceKey(folderWorkspace.id),
+ startup,
+ undefined,
+ undefined,
+ undefined,
+ { reseedEmptiedWorkspace: true }
+ )
+}
+
+export function hasWorkspaceActivationWork(options?: WorktreeActivationOptions): boolean {
+ return Boolean(
+ options?.startup || options?.setup || options?.defaultTabs || options?.issueCommand
+ )
+}
+
+export function createWorkspaceActivationIdentity(
+ workspaceKey: string,
+ route?: { executionHostId?: ExecutionHostId; runtimeEnvironmentId?: string | null }
+): WorkspaceActivationIdentity {
+ const state = useAppStore.getState()
+ return {
+ workspaceKey,
+ executionHostId: route?.executionHostId ?? getExecutionHostIdForWorktree(state, workspaceKey),
+ runtimeEnvironmentId:
+ route && 'runtimeEnvironmentId' in route
+ ? (route.runtimeEnvironmentId ?? null)
+ : getRuntimeEnvironmentIdForWorktree(state, workspaceKey),
+ attemptId: createBrowserUuid()
+ }
+}
+
+export function settleActivationSeedProducer(
+ producer: WorkspaceSurfaceProducer,
+ workspaceKey: string,
+ primaryTabId: string | null,
+ existingSurfaceIds: ReadonlySet
+): void {
+ const state = useAppStore.getState()
+ state.reconcileWorktreeTabModel(workspaceKey)
+ const surfaceId =
+ primaryTabId ??
+ (useAppStore.getState().unifiedTabsByWorktree[workspaceKey] ?? []).find(
+ (tab) => !existingSurfaceIds.has(tab.id)
+ )?.id ??
+ null
+ if (surfaceId) {
+ producer.materialized({ kind: 'tab', id: surfaceId })
+ return
+ }
+ producer.declined('The initial surface producer did not publish a renderable surface.')
+}
+
+export function captureActivationRenderableSurfaceIds(workspaceKey: string): ReadonlySet {
+ const state = useAppStore.getState()
+ state.reconcileWorktreeTabModel(workspaceKey)
+ return new Set(
+ (useAppStore.getState().unifiedTabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id)
+ )
+}
+
+export function recoverActivatedWorkspace(identity: WorkspaceActivationIdentity): string | null {
+ const existingTabIds = new Set(
+ (useAppStore.getState().tabsByWorktree[identity.workspaceKey] ?? []).map((tab) => tab.id)
+ )
+ void recoverWorkspaceActivation(identity, { mode: 'explicit' })
+ return (
+ useAppStore
+ .getState()
+ .tabsByWorktree[identity.workspaceKey]?.find((tab) => !existingTabIds.has(tab.id))?.id ?? null
+ )
+}
+
+export function finalizeActivatedWorkspaceSurface(
+ identity: WorkspaceActivationIdentity,
+ primaryTabId: string | null,
+ initialCwd?: string
+): string | null {
+ const settledPrimaryTabId = primaryTabId ?? recoverActivatedWorkspace(identity)
+ if (settledPrimaryTabId && initialCwd) {
+ useAppStore.getState().queueTabInitialCwd(settledPrimaryTabId, initialCwd)
+ }
+ return settledPrimaryTabId
+}
+
+export function hasOutstandingActivationSurfaceProducer(
+ identity: WorkspaceActivationIdentity
+): boolean {
+ return readWorkspaceSurfaceProducerEntries(identity).some(
+ (entry) =>
+ entry.result?.kind !== 'materialized' || entry.result.surface.kind === 'workspace-content'
+ )
+}
+
+export function consumeTransferredActivationProducer(producer: WorkspaceSurfaceProducer): void {
+ producer.declined('Surface production transferred to the paired execution host.')
+ consumeWorkspaceSurfaceProducerAttempt(producer.attempt.id)
+}
diff --git a/src/renderer/src/lib/worktree-activation-recovery.test.ts b/src/renderer/src/lib/worktree-activation-recovery.test.ts
new file mode 100644
index 00000000000..2f19ad40069
--- /dev/null
+++ b/src/renderer/src/lib/worktree-activation-recovery.test.ts
@@ -0,0 +1,635 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { toSshExecutionHostId } from '../../../shared/execution-host'
+import { folderWorkspaceKey } from '../../../shared/workspace-scope'
+import {
+ recoverWorkspaceActivation,
+ type WorkspaceActivationIdentity
+} from './worktree-activation-recovery'
+import {
+ readWorkspaceActivationRecoveryPresentation,
+ resetWorkspaceActivationRecoveryPresentationsForTests
+} from './workspace-activation-recovery-presentation'
+import {
+ readWorkspaceSurfaceProducerEntries,
+ registerWorkspaceSurfaceProducer,
+ resetWorkspaceSurfaceProducersForTests
+} from './workspace-surface-production'
+
+type FakeUnifiedTab = {
+ id: string
+ contentType:
+ | 'terminal'
+ | 'editor'
+ | 'diff'
+ | 'conflict-review'
+ | 'check-details'
+ | 'agent-session'
+ | 'browser'
+ | 'simulator'
+}
+
+const mocks = vi.hoisted(() => {
+ const storeListeners = new Set<() => void>()
+ const structuredListeners = new Set<() => void>()
+ let tabSequence = 0
+ let uuidSequence = 0
+ let reconcileHook: (() => void) | null = null
+ let currentState: {
+ activeWorktreeId: string
+ activeWorkspaceExecutionHostId: string
+ executionHostId: string
+ runtimeEnvironmentId: string | null
+ sleepingAgentSessionsByPaneKey: Record
+ tabsByWorktree: Record
+ unifiedTabsByWorktree: Record
+ remoteWorkspaceHydratedTargetIds: Set
+ remoteWorkspaceSyncStatusByTargetId: Record
+ reconcileWorktreeTabModel: (workspaceKey: string) => {
+ renderableTabCount: number
+ activeRenderableTabId: string | null
+ }
+ createTab: ReturnType
+ }
+ const reconcileWorktreeTabModel = (workspaceKey: string) => {
+ const hook = reconcileHook
+ reconcileHook = null
+ hook?.()
+ const tabs = currentState.unifiedTabsByWorktree[workspaceKey] ?? []
+ return {
+ renderableTabCount: tabs.length,
+ activeRenderableTabId: tabs[0]?.id ?? null
+ }
+ }
+ const createTab = vi.fn((workspaceKey: string) => {
+ const id = `recovery-tab-${++tabSequence}`
+ const tab = { id, contentType: 'terminal' as const }
+ currentState.tabsByWorktree[workspaceKey] = [tab]
+ currentState.unifiedTabsByWorktree[workspaceKey] = [tab]
+ for (const listener of storeListeners) {
+ listener()
+ }
+ return tab
+ })
+ const freshState = () => ({
+ activeWorktreeId: 'worktree-1',
+ activeWorkspaceExecutionHostId: 'local',
+ executionHostId: 'local',
+ runtimeEnvironmentId: null,
+ sleepingAgentSessionsByPaneKey: {},
+ tabsByWorktree: {},
+ unifiedTabsByWorktree: {},
+ remoteWorkspaceHydratedTargetIds: new Set(),
+ remoteWorkspaceSyncStatusByTargetId: {},
+ reconcileWorktreeTabModel,
+ createTab
+ })
+ currentState = freshState()
+ return {
+ state: () => currentState,
+ reset: () => {
+ tabSequence = 0
+ uuidSequence = 0
+ reconcileHook = null
+ createTab.mockReset()
+ createTab.mockImplementation((workspaceKey: string) => {
+ const id = `recovery-tab-${++tabSequence}`
+ const tab = { id, contentType: 'terminal' as const }
+ currentState.tabsByWorktree[workspaceKey] = [tab]
+ currentState.unifiedTabsByWorktree[workspaceKey] = [tab]
+ for (const listener of storeListeners) {
+ listener()
+ }
+ return tab
+ })
+ currentState = freshState()
+ },
+ notifyStore: () => {
+ for (const listener of storeListeners) {
+ listener()
+ }
+ },
+ runOnNextReconcile: (hook: () => void) => {
+ reconcileHook = hook
+ },
+ subscribeStore: (listener: () => void) => {
+ storeListeners.add(listener)
+ return () => storeListeners.delete(listener)
+ },
+ gate: vi.fn(),
+ authority: vi.fn(() => 'none'),
+ structuredStatus: vi.fn(() => 'idle'),
+ subscribeStructured: (listener: () => void) => {
+ structuredListeners.add(listener)
+ return () => structuredListeners.delete(listener)
+ },
+ nextUuid: () => `recovery-attempt-${++uuidSequence}`
+ }
+})
+
+vi.mock('@/store', () => ({
+ useAppStore: {
+ getState: mocks.state,
+ subscribe: mocks.subscribeStore
+ }
+}))
+vi.mock('@/components/terminal/initial-terminal', () => ({
+ shouldAutoCreateInitialTerminal: (count: number) => count === 0
+}))
+vi.mock('./worktree-agent-activation-gate', () => ({
+ gateWorktreeAgentActivation: mocks.gate
+}))
+vi.mock('./workspace-terminal-host-authority', () => ({
+ resolveWorkspaceTerminalHostAuthority: mocks.authority
+}))
+vi.mock('./worktree-runtime-owner', () => ({
+ getExecutionHostIdForWorktree: () => mocks.state().executionHostId,
+ getRuntimeEnvironmentIdForWorktree: () => mocks.state().runtimeEnvironmentId
+}))
+vi.mock('./structured-agent-session-launch', () => ({
+ getStructuredAgentLaunchStatus: mocks.structuredStatus,
+ subscribeStructuredAgentLaunchStatus: mocks.subscribeStructured
+}))
+vi.mock('./browser-uuid', () => ({ createBrowserUuid: mocks.nextUuid }))
+
+const WORKSPACE_KEY = 'worktree-1'
+
+function identity(
+ attemptId: string,
+ overrides: Partial = {}
+): WorkspaceActivationIdentity {
+ return {
+ workspaceKey: WORKSPACE_KEY,
+ executionHostId: 'local',
+ runtimeEnvironmentId: null,
+ attemptId,
+ ...overrides
+ }
+}
+
+function forceGate(): void {
+ mocks.state().sleepingAgentSessionsByPaneKey = {
+ pane: { worktreeId: mocks.state().activeWorktreeId }
+ }
+}
+
+function showSurface(contentType: FakeUnifiedTab['contentType'], id = 'surface-1'): void {
+ mocks.state().unifiedTabsByWorktree[mocks.state().activeWorktreeId] = [{ id, contentType }]
+ mocks.notifyStore()
+}
+
+beforeEach(() => {
+ mocks.reset()
+ mocks.gate.mockReset()
+ mocks.gate.mockResolvedValue('empty')
+ mocks.authority.mockReset()
+ mocks.authority.mockReturnValue('none')
+ mocks.structuredStatus.mockReset()
+ mocks.structuredStatus.mockReturnValue('idle')
+ resetWorkspaceSurfaceProducersForTests()
+ resetWorkspaceActivationRecoveryPresentationsForTests()
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+describe('activation recovery failures', () => {
+ it('publishes a blocked error and starts no writer', async () => {
+ forceGate()
+ mocks.gate.mockResolvedValue('blocked')
+
+ const result = await recoverWorkspaceActivation(identity('blocked-attempt'), {
+ mode: 'explicit'
+ })
+
+ expect(result).toMatchObject({ kind: 'failed', reason: 'blocked' })
+ expect(mocks.state().createTab).not.toHaveBeenCalled()
+ expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')?.kind).toBe(
+ 'blocked'
+ )
+ })
+
+ it.each(['resume', 'adoption'])(
+ 'contains an escaped %s rejection as an unexpected error',
+ async () => {
+ forceGate()
+ mocks.gate.mockRejectedValue(new Error('inventory mutation failed'))
+
+ const result = await recoverWorkspaceActivation(identity('rejected-attempt'), {
+ mode: 'explicit'
+ })
+
+ expect(result).toMatchObject({ kind: 'failed', reason: 'unexpected' })
+ expect(mocks.state().createTab).not.toHaveBeenCalled()
+ expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')).toMatchObject({
+ kind: 'unexpected',
+ detail: 'inventory mutation failed'
+ })
+ }
+ )
+
+ it('turns a private seeder throw into an actionable error', async () => {
+ mocks.state().createTab.mockImplementationOnce(() => {
+ throw new Error('tab commit failed')
+ })
+
+ const result = await recoverWorkspaceActivation(identity('seeder-attempt'), {
+ mode: 'explicit'
+ })
+
+ expect(result).toMatchObject({ kind: 'failed', reason: 'unexpected' })
+ expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')).toMatchObject({
+ kind: 'unexpected',
+ detail: 'tab commit failed'
+ })
+ })
+
+ it('keeps a failed concrete producer visible without substituting a shell', async () => {
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey: WORKSPACE_KEY,
+ executionHostId: 'local',
+ attemptId: 'producer-failure'
+ })
+ producer.failed('agent executable was not found')
+
+ const result = await recoverWorkspaceActivation(identity('failed-producer-attempt'), {
+ mode: 'explicit'
+ })
+
+ expect(result).toMatchObject({ kind: 'failed', reason: 'producer-failed' })
+ expect(mocks.state().createTab).not.toHaveBeenCalled()
+ expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')).toMatchObject({
+ kind: 'producer-failed',
+ detail: 'agent executable was not found'
+ })
+ })
+
+ it('does not let a surviving unrelated surface hide a producer failure', async () => {
+ showSurface('terminal', 'setup-tab')
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey: WORKSPACE_KEY,
+ executionHostId: 'local',
+ attemptId: 'failed-agent-producer'
+ })
+ producer.failed('The requested agent did not start.')
+
+ const result = await recoverWorkspaceActivation(identity('failed-agent-attempt'), {
+ mode: 'explicit'
+ })
+
+ expect(result).toMatchObject({ kind: 'failed', reason: 'producer-failed' })
+ expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')).toMatchObject({
+ kind: 'producer-failed',
+ detail: 'The requested agent did not start.'
+ })
+ expect(mocks.state().createTab).not.toHaveBeenCalled()
+ })
+
+ it('clears a producer failure when Retry observes the requested surface', async () => {
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey: WORKSPACE_KEY,
+ executionHostId: 'local',
+ attemptId: 'retry-producer'
+ })
+ producer.failed('agent executable was not found')
+ await recoverWorkspaceActivation(identity('retry-failure'), { mode: 'explicit' })
+ const failure = readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')
+ expect(failure?.kind).toBe('producer-failed')
+
+ showSurface('agent-session', 'published-after-retry')
+ failure?.retry()
+
+ await vi.waitFor(() =>
+ expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')).toBeNull()
+ )
+ expect(mocks.state().createTab).not.toHaveBeenCalled()
+ })
+
+ it('retains unverifiable producer ownership after cancellation', async () => {
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey: WORKSPACE_KEY,
+ executionHostId: 'local',
+ attemptId: 'unknown-producer'
+ })
+ producer.unverifiable('dispatch acceptance is unknown')
+
+ const result = await recoverWorkspaceActivation(identity('unknown-attempt'), {
+ mode: 'explicit'
+ })
+
+ expect(result).toMatchObject({
+ kind: 'deferred',
+ ownerAttemptId: 'unknown-producer'
+ })
+ expect(readWorkspaceSurfaceProducerEntries(identity('unused'))).toHaveLength(1)
+ expect(mocks.state().createTab).not.toHaveBeenCalled()
+ })
+
+ it('presents a producer refusal reason without substituting a shell', async () => {
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey: WORKSPACE_KEY,
+ executionHostId: 'local',
+ attemptId: 'declined-producer'
+ })
+ producer.declined('Browser publication was refused by the host.')
+
+ await expect(
+ recoverWorkspaceActivation(identity('declined-attempt'), { mode: 'explicit' })
+ ).resolves.toMatchObject({ kind: 'failed', reason: 'producer-failed' })
+ expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')).toMatchObject({
+ kind: 'producer-failed',
+ detail: 'Browser publication was refused by the host.'
+ })
+ expect(mocks.state().createTab).not.toHaveBeenCalled()
+
+ readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')?.retry()
+ await vi.waitFor(() =>
+ expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')?.kind).toBe(
+ 'producer-failed'
+ )
+ )
+ expect(mocks.state().createTab).not.toHaveBeenCalled()
+ })
+
+ it('bounds an inventory assessment and publishes an actionable timeout', async () => {
+ vi.useFakeTimers()
+ forceGate()
+ mocks.gate.mockReturnValue(new Promise(() => undefined))
+
+ const recovery = recoverWorkspaceActivation(identity('deadline-attempt'), {
+ mode: 'explicit'
+ })
+ await vi.advanceTimersByTimeAsync(30_000)
+
+ await expect(recovery).resolves.toMatchObject({ kind: 'failed', reason: 'unexpected' })
+ expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')?.kind).toBe(
+ 'unexpected'
+ )
+ expect(mocks.state().createTab).not.toHaveBeenCalled()
+ })
+
+ it.each(['adopted', 'structured', 'resumed'] as const)(
+ 'requires real publication after a %s gate outcome',
+ async (outcome) => {
+ vi.useFakeTimers()
+ forceGate()
+ mocks.gate.mockResolvedValue(outcome)
+
+ const recovery = recoverWorkspaceActivation(identity(`gate-${outcome}`), {
+ mode: 'explicit'
+ })
+ await vi.advanceTimersByTimeAsync(30_000)
+
+ await expect(recovery).resolves.toMatchObject({ kind: 'deferred' })
+ expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')?.kind).toBe(
+ 'unverifiable'
+ )
+ expect(mocks.state().createTab).not.toHaveBeenCalled()
+ }
+ )
+})
+
+describe('activation recovery settlement', () => {
+ it('uses the live startup tombstone at final settlement', async () => {
+ forceGate()
+ let settleGate!: (outcome: string) => void
+ mocks.gate.mockReturnValue(
+ new Promise((resolve) => {
+ settleGate = resolve
+ })
+ )
+ const recovery = recoverWorkspaceActivation(identity('startup-tombstone'), {
+ mode: 'startup'
+ })
+ await vi.waitFor(() => expect(mocks.gate).toHaveBeenCalled())
+ mocks.state().tabsByWorktree[WORKSPACE_KEY] = []
+ mocks.notifyStore()
+ settleGate('empty')
+
+ await expect(recovery).resolves.toEqual({ kind: 'intentional-empty' })
+ expect(mocks.state().createTab).not.toHaveBeenCalled()
+ })
+
+ it('lets an explicit reopen override a live tombstone', async () => {
+ mocks.state().tabsByWorktree[WORKSPACE_KEY] = []
+
+ await expect(
+ recoverWorkspaceActivation(identity('explicit-tombstone'), { mode: 'explicit' })
+ ).resolves.toMatchObject({ kind: 'materialized', surface: { type: 'terminal' } })
+ expect(mocks.state().createTab).toHaveBeenCalledOnce()
+ })
+
+ it.each([
+ ['terminal', 'terminal'],
+ ['diff', 'editor'],
+ ['agent-session', 'agent-session'],
+ ['browser', 'browser'],
+ ['simulator', 'simulator']
+ ] as const)(
+ 'accepts a surviving %s surface without a fallback',
+ async (contentType, visibleType) => {
+ showSurface(contentType)
+
+ const result = await recoverWorkspaceActivation(identity(`surface-${contentType}`), {
+ mode: 'explicit'
+ })
+
+ expect(result).toMatchObject({
+ kind: 'materialized',
+ surface: { id: 'surface-1', type: visibleType }
+ })
+ expect(mocks.state().createTab).not.toHaveBeenCalled()
+ }
+ )
+
+ it('cancels one startup request without consuming a later assessment', async () => {
+ forceGate()
+ mocks.gate.mockReturnValueOnce(new Promise(() => undefined)).mockResolvedValueOnce('empty')
+ const abort = new AbortController()
+ const first = recoverWorkspaceActivation(identity('cancelled-startup'), {
+ mode: 'startup',
+ signal: abort.signal
+ })
+ await vi.waitFor(() => expect(mocks.gate).toHaveBeenCalledOnce())
+ abort.abort()
+ await expect(first).resolves.toEqual({ kind: 'stale' })
+
+ await expect(
+ recoverWorkspaceActivation(identity('replacement-startup'), { mode: 'startup' })
+ ).resolves.toMatchObject({ kind: 'materialized' })
+ expect(mocks.state().createTab).toHaveBeenCalledOnce()
+ })
+
+ it('invalidates an old request across an away-and-back selection cycle', async () => {
+ forceGate()
+ let settleGate!: (outcome: string) => void
+ const sharedGate = new Promise((resolve) => {
+ settleGate = resolve
+ })
+ mocks.gate.mockReturnValue(sharedGate)
+ const first = recoverWorkspaceActivation(identity('before-away-and-back'), {
+ mode: 'explicit'
+ })
+ await vi.waitFor(() => expect(mocks.gate).toHaveBeenCalledOnce())
+
+ mocks.state().activeWorktreeId = 'worktree-2'
+ mocks.notifyStore()
+ mocks.state().activeWorktreeId = WORKSPACE_KEY
+ mocks.notifyStore()
+ settleGate('empty')
+
+ await expect(first).resolves.toEqual({ kind: 'stale' })
+ expect(mocks.state().createTab).not.toHaveBeenCalled()
+
+ await expect(
+ recoverWorkspaceActivation(identity('after-away-and-back'), { mode: 'explicit' })
+ ).resolves.toMatchObject({ kind: 'materialized' })
+ expect(mocks.state().createTab).toHaveBeenCalledOnce()
+ })
+
+ it('rejects a joined gate whose host tuple conflicts', async () => {
+ forceGate()
+ let settleGate!: (outcome: string) => void
+ const sharedGate = new Promise((resolve) => {
+ settleGate = resolve
+ })
+ mocks.gate.mockReturnValue(sharedGate)
+ const first = recoverWorkspaceActivation(identity('host-a'), { mode: 'explicit' })
+ await vi.waitFor(() => expect(mocks.gate).toHaveBeenCalledOnce())
+
+ const sshHost = toSshExecutionHostId('box')
+ mocks.state().executionHostId = sshHost
+ mocks.state().activeWorkspaceExecutionHostId = sshHost
+ mocks.notifyStore()
+ const second = recoverWorkspaceActivation(identity('host-b', { executionHostId: sshHost }), {
+ mode: 'explicit'
+ })
+
+ await expect(second).resolves.toMatchObject({
+ kind: 'deferred',
+ reason: expect.stringContaining('another execution host')
+ })
+ expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, sshHost)?.kind).toBe(
+ 'unverifiable'
+ )
+ expect(mocks.state().createTab).not.toHaveBeenCalled()
+ settleGate('empty')
+ await expect(first).resolves.toEqual({ kind: 'stale' })
+ })
+
+ it.each([
+ ['worktree over SSH', WORKSPACE_KEY],
+ ['folder over SSH', folderWorkspaceKey('folder-1')]
+ ])('does not start a writer for unverifiable %s', async (_label, workspaceKey) => {
+ const sshHost = toSshExecutionHostId('box')
+ mocks.state().activeWorktreeId = workspaceKey
+ mocks.state().executionHostId = sshHost
+ mocks.state().activeWorkspaceExecutionHostId = sshHost
+ mocks.notifyStore()
+
+ const result = await recoverWorkspaceActivation(
+ identity(`ssh-${workspaceKey}`, { workspaceKey, executionHostId: sshHost }),
+ { mode: 'explicit' }
+ )
+
+ expect(result).toMatchObject({ kind: 'deferred' })
+ expect(readWorkspaceActivationRecoveryPresentation(workspaceKey, sshHost)?.kind).toBe(
+ 'unverifiable'
+ )
+ expect(mocks.state().createTab).not.toHaveBeenCalled()
+ })
+
+ it('does not treat a previously hydrated but offline SSH host as exited', async () => {
+ const sshHost = toSshExecutionHostId('box')
+ mocks.state().executionHostId = sshHost
+ mocks.state().activeWorkspaceExecutionHostId = sshHost
+ mocks.state().remoteWorkspaceHydratedTargetIds.add('box')
+ mocks.state().remoteWorkspaceSyncStatusByTargetId.box = { phase: 'offline' }
+ mocks.notifyStore()
+
+ await expect(
+ recoverWorkspaceActivation(identity('ssh-offline', { executionHostId: sshHost }), {
+ mode: 'explicit'
+ })
+ ).resolves.toMatchObject({ kind: 'deferred' })
+ expect(mocks.state().createTab).not.toHaveBeenCalled()
+ })
+
+ it('allows SSH recovery only after a current synced inventory proves emptiness', async () => {
+ const sshHost = toSshExecutionHostId('box')
+ mocks.state().executionHostId = sshHost
+ mocks.state().activeWorkspaceExecutionHostId = sshHost
+ mocks.state().remoteWorkspaceHydratedTargetIds.add('box')
+ mocks.state().remoteWorkspaceSyncStatusByTargetId.box = { phase: 'synced' }
+ mocks.notifyStore()
+
+ await expect(
+ recoverWorkspaceActivation(identity('ssh-synced', { executionHostId: sshHost }), {
+ mode: 'explicit'
+ })
+ ).resolves.toMatchObject({ kind: 'materialized', surface: { type: 'terminal' } })
+ expect(mocks.state().createTab).toHaveBeenCalledOnce()
+ })
+
+ it('keeps a producer-owned tab pending until real publication reaches inventory', async () => {
+ vi.useFakeTimers()
+ showSurface('terminal', 'setup-tab')
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey: WORKSPACE_KEY,
+ executionHostId: 'local',
+ attemptId: 'publication-owner'
+ })
+ producer.materialized({ kind: 'tab', id: 'not-published' })
+ const recovery = recoverWorkspaceActivation(identity('publication-attempt'), {
+ mode: 'explicit'
+ })
+ await vi.advanceTimersByTimeAsync(30_000)
+
+ await expect(recovery).resolves.toMatchObject({
+ kind: 'deferred',
+ ownerAttemptId: 'publication-owner'
+ })
+ expect(mocks.state().createTab).not.toHaveBeenCalled()
+ })
+
+ it('settles a producer only from its exact published surface identity', async () => {
+ showSurface('terminal', 'setup-tab')
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey: WORKSPACE_KEY,
+ executionHostId: 'local',
+ attemptId: 'exact-publication-owner'
+ })
+ producer.materialized({ kind: 'tab', id: 'agent-session:session-1' })
+ const recovery = recoverWorkspaceActivation(identity('exact-publication-attempt'), {
+ mode: 'explicit'
+ })
+ await Promise.resolve()
+
+ mocks.state().unifiedTabsByWorktree[WORKSPACE_KEY] = [
+ { id: 'setup-tab', contentType: 'terminal' },
+ { id: 'agent-session:session-1', contentType: 'agent-session' }
+ ]
+ mocks.notifyStore()
+
+ await expect(recovery).resolves.toEqual({
+ kind: 'materialized',
+ surface: { id: 'agent-session:session-1', type: 'agent-session' }
+ })
+ expect(mocks.state().createTab).not.toHaveBeenCalled()
+ })
+
+ it('lets a reentrant newer activation own the final seed critical section', async () => {
+ let newerRecovery: Promise | null = null
+ mocks.runOnNextReconcile(() => {
+ newerRecovery = recoverWorkspaceActivation(identity('reentrant-newer'), {
+ mode: 'explicit'
+ })
+ })
+
+ await expect(
+ recoverWorkspaceActivation(identity('reentrant-older'), { mode: 'explicit' })
+ ).resolves.toMatchObject({ kind: 'materialized' })
+ await expect(newerRecovery).resolves.toMatchObject({ kind: 'materialized' })
+ expect(mocks.state().createTab).toHaveBeenCalledOnce()
+ })
+})
diff --git a/src/renderer/src/lib/worktree-activation-recovery.ts b/src/renderer/src/lib/worktree-activation-recovery.ts
new file mode 100644
index 00000000000..d88c7ec69a2
--- /dev/null
+++ b/src/renderer/src/lib/worktree-activation-recovery.ts
@@ -0,0 +1,55 @@
+import type { ExecutionHostId } from '../../../shared/execution-host'
+import type { WorkspaceVisibleTabType } from '../../../shared/tab-types'
+import { recoverWorkspaceActivationOwned } from './workspace-activation-recovery-coordinator'
+import {
+ markLatestActivationRecoveryAttempt,
+ readLatestActivationRecoveryAttempt
+} from './workspace-activation-recovery-state'
+import {
+ activationRecoveryFailedResult,
+ publishActivationRecovery
+} from './workspace-activation-recovery-settlement'
+
+export type WorkspaceActivationIdentity = {
+ workspaceKey: string
+ executionHostId: ExecutionHostId
+ runtimeEnvironmentId: string | null
+ attemptId: string
+}
+
+export type WorkspaceActivationContext = {
+ mode: 'explicit' | 'startup'
+ signal?: AbortSignal
+}
+
+export type WorkspaceActivationRecoveryResult =
+ | {
+ kind: 'materialized'
+ surface: { id: string; type: WorkspaceVisibleTabType | 'workspace-content' }
+ }
+ | { kind: 'intentional-empty' }
+ | { kind: 'deferred'; reason: string; ownerAttemptId: string | null }
+ | { kind: 'failed'; reason: 'blocked' | 'unexpected' | 'producer-failed'; diagnosticId: string }
+ | { kind: 'stale' }
+
+export async function recoverWorkspaceActivation(
+ identity: WorkspaceActivationIdentity,
+ context: WorkspaceActivationContext
+): Promise {
+ try {
+ return await recoverWorkspaceActivationOwned(identity, context)
+ } catch (error) {
+ const currentAttemptId = readLatestActivationRecoveryAttempt(identity)
+ if (currentAttemptId && currentAttemptId !== identity.attemptId) {
+ return { kind: 'stale' }
+ }
+ markLatestActivationRecoveryAttempt(identity)
+ const detail = error instanceof Error ? error.message : String(error)
+ try {
+ publishActivationRecovery(identity, context, 'unexpected', detail)
+ } catch (presentationError) {
+ console.error('workspace activation recovery presentation failed', presentationError)
+ }
+ return activationRecoveryFailedResult(identity, 'unexpected')
+ }
+}
diff --git a/src/renderer/src/lib/worktree-activation-reveal.test.ts b/src/renderer/src/lib/worktree-activation-reveal.test.ts
index 585c86d8a30..906576cd53a 100644
--- a/src/renderer/src/lib/worktree-activation-reveal.test.ts
+++ b/src/renderer/src/lib/worktree-activation-reveal.test.ts
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { activateAndRevealWorktree } from './worktree-activation'
import { registerWorktreeActivationReset } from './worktree-activation-test-harness'
import { useAppStore } from '@/store'
+import { makeTab, makeWorktree, TEST_REPO } from '@/store/slices/store-test-helpers'
registerWorktreeActivationReset()
@@ -19,47 +20,45 @@ describe('activateAndRevealWorktree', () => {
it('queues a one-shot initial cwd for the primary activation-created tab', () => {
const queueTabInitialCwd = vi.fn()
const revealWorktreeInSidebar = vi.fn()
+ const worktree = makeWorktree({
+ id: 'wt-1',
+ repoId: 'repo-1',
+ path: '/repo',
+ displayName: 'main',
+ branch: 'main',
+ isMainWorktree: true
+ })
useAppStore.setState({
activeRepoId: null,
activeWorktreeId: null,
activeView: 'settings',
filterRepoIds: [],
isNavigatingHistory: false,
- repos: [{ id: 'repo-1', connectionId: null }],
+ repos: [{ ...TEST_REPO, id: 'repo-1', connectionId: null }],
worktreesByRepo: {
- 'repo-1': [
- {
- id: 'wt-1',
- repoId: 'repo-1',
- path: '/repo',
- displayName: 'main',
- branch: 'main',
- head: 'abc',
- isBare: false,
- isMainWorktree: true
- }
- ]
+ 'repo-1': [worktree]
},
- getKnownWorktreeById: (worktreeId: string) =>
- worktreeId === 'wt-1'
- ? ({
- id: 'wt-1',
- repoId: 'repo-1',
- path: '/repo',
- displayName: 'main',
- branch: 'main',
- head: 'abc',
- isBare: false,
- isMainWorktree: true
- } as never)
- : null,
+ getKnownWorktreeById: (worktreeId: string) => (worktreeId === 'wt-1' ? worktree : undefined),
setActiveRepo: vi.fn(),
setActiveView: vi.fn(),
- setActiveWorktree: vi.fn(),
+ setActiveWorktree: vi.fn((worktreeId: string | null) => {
+ useAppStore.setState({ activeWorktreeId: worktreeId })
+ return true
+ }),
markWorktreeVisited: vi.fn(),
recordWorktreeVisit: vi.fn(),
- reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 0 })),
- createTab: vi.fn(() => ({ id: 'tab-1' })),
+ reconcileWorktreeTabModel: vi.fn(() => ({
+ renderableTabCount: 0,
+ activeRenderableTabId: null
+ })),
+ createTab: vi.fn(() => {
+ const tab = makeTab({ id: 'tab-1', worktreeId: 'wt-1' })
+ const current = useAppStore.getState()
+ useAppStore.setState({
+ tabsByWorktree: { ...current.tabsByWorktree, 'wt-1': [tab] }
+ })
+ return tab
+ }),
setActiveTab: vi.fn(),
setTabCustomTitle: vi.fn(),
setTabColor: vi.fn(),
@@ -69,17 +68,14 @@ describe('activateAndRevealWorktree', () => {
queueTabSetupSplit: vi.fn(),
queueTabIssueCommandSplit: vi.fn(),
revealWorktreeInSidebar
- } as never)
+ })
const result = activateAndRevealWorktree('wt-1', {
- initialCwd: '/repo/packages/web',
- executionHostId: 'ssh:box'
+ initialCwd: '/repo/packages/web'
})
expect(result).toEqual({ primaryTabId: 'tab-1' })
expect(queueTabInitialCwd).toHaveBeenCalledWith('tab-1', '/repo/packages/web')
- expect(revealWorktreeInSidebar).toHaveBeenCalledWith('wt-1', {
- executionHostId: 'ssh:box'
- })
+ expect(revealWorktreeInSidebar).toHaveBeenCalledWith('wt-1')
})
})
diff --git a/src/renderer/src/lib/worktree-activation-sidebar-filters.ts b/src/renderer/src/lib/worktree-activation-sidebar-filters.ts
new file mode 100644
index 00000000000..830b94281f6
--- /dev/null
+++ b/src/renderer/src/lib/worktree-activation-sidebar-filters.ts
@@ -0,0 +1,41 @@
+import type { AppState } from '@/store'
+import type { Worktree } from '../../../shared/worktree/types'
+import type { ExecutionHostId } from '../../../shared/execution-host'
+import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui'
+import { isDetachedHeadWorkspace } from '@/components/sidebar/visible-worktrees'
+
+export function clearWorktreeActivationSidebarFilters(state: AppState, worktree: Worktree): void {
+ if (state.filterRepoIds.length > 0 && !state.filterRepoIds.includes(worktree.repoId)) {
+ state.setFilterRepoIds([])
+ }
+ if (
+ state.hideAutomationGeneratedWorkspaces &&
+ worktree.automationProvenance?.kind === 'created-by-automation'
+ ) {
+ state.setHideAutomationGeneratedWorkspaces(false)
+ }
+ if (state.hideCliCreatedWorkspaces && worktree.cliProvenance?.kind === 'created-by-cli') {
+ state.setHideCliCreatedWorkspaces(false)
+ }
+ if (state.hideDetachedHeadWorkspaces && isDetachedHeadWorkspace(worktree)) {
+ state.setHideDetachedHeadWorkspaces(false)
+ }
+}
+
+export function revealActivatedWorktree(
+ state: AppState,
+ worktreeId: string,
+ options: {
+ behavior?: PendingSidebarWorktreeReveal['behavior']
+ executionHostId?: ExecutionHostId
+ }
+): void {
+ if (options.behavior || options.executionHostId) {
+ state.revealWorktreeInSidebar(worktreeId, {
+ ...(options.behavior ? { behavior: options.behavior } : {}),
+ ...(options.executionHostId ? { executionHostId: options.executionHostId } : {})
+ })
+ } else {
+ state.revealWorktreeInSidebar(worktreeId)
+ }
+}
diff --git a/src/renderer/src/lib/worktree-activation-store-contract.ts b/src/renderer/src/lib/worktree-activation-store-contract.ts
index 63ce180ffed..bcfa915dca9 100644
--- a/src/renderer/src/lib/worktree-activation-store-contract.ts
+++ b/src/renderer/src/lib/worktree-activation-store-contract.ts
@@ -68,11 +68,7 @@ export type InitialTerminalOptions = {
/** Why: an explicit empty terminal row is a "user closed the last tab" tombstone. Startup
* hydration honours it through Terminal.tsx's passive auto-create (which never calls this
* function), but opening the workspace on purpose (sidebar, palette, automation "Resume
- * workspace", wake) has to hand back a usable surface. Activation sets this unless the
- * caller says it provides its own surface; background worktree creation leaves it unset. */
+ * workspace", wake) has to hand back a usable surface. Explicit activation sets this;
+ * background worktree creation leaves it unset. */
reseedEmptiedWorkspace?: boolean
- /** Set by callers that open their own primary surface (a structured native chat session).
- * Setup/issue work still runs, but work that needs no host terminal must not seed a shell
- * beside the chat the caller is about to create. */
- callerProvidesSurface?: boolean
}
diff --git a/src/renderer/src/lib/worktree-activation-structured-chat-surface.test.ts b/src/renderer/src/lib/worktree-activation-structured-chat-surface.test.ts
index 33be3888a24..62ac39b95fc 100644
--- a/src/renderer/src/lib/worktree-activation-structured-chat-surface.test.ts
+++ b/src/renderer/src/lib/worktree-activation-structured-chat-surface.test.ts
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { activateAndRevealWorktree } from './worktree-activation'
import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding'
+import { queueStandaloneSetupTab } from './worktree-setup-issue-command-queue'
import {
makeCreatedAgentWorktree as makeWorktree,
seedEmptyActivatableWorktree
@@ -26,25 +27,22 @@ const setup = {
envVars: { ORCA_WORKTREE_PATH: '/tmp/worktrees/wt-1' }
}
-// Why: a native-chat create used to land the user on a bare "Terminal 1" beside the chat,
-// because the returned setup script counted as work needing a shell to attach to.
-describe('seeding beside a caller-provided chat surface', () => {
+describe('terminal seeding for explicit setup work', () => {
it('runs a new-tab setup script without seeding a shell', () => {
let createdIndex = 0
const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` }))
const store = createMockStore({ createTab })
- const primaryTabId = ensureWorktreeHasInitialTerminal(
+ const queued = queueStandaloneSetupTab({
store,
- 'wt-1',
- undefined,
+ worktreeId: 'wt-1',
setup,
- undefined,
- undefined,
- { callerProvidesSurface: true }
- )
+ issueCommand: undefined,
+ defaultTabs: undefined,
+ opts: { activateCreatedTabs: false }
+ })
- expect(primaryTabId).toBeNull()
+ expect(queued).toBe(true)
expect(createTab).toHaveBeenCalledTimes(1)
expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-1', 'Setup', {
recordInteraction: false
@@ -61,9 +59,7 @@ describe('seeding beside a caller-provided chat surface', () => {
const store = createMockStore({ createTab })
setSetupScriptLaunchMode('split-vertical')
- ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, setup, undefined, undefined, {
- callerProvidesSurface: true
- })
+ ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, setup)
expect(createTab).toHaveBeenCalledTimes(1)
expect(store.queueTabSetupSplit).toHaveBeenCalledWith('tab-1', expect.anything())
@@ -80,8 +76,7 @@ describe('seeding beside a caller-provided chat surface', () => {
undefined,
undefined,
{ command: 'orca issue run' },
- undefined,
- { callerProvidesSurface: true }
+ undefined
)
expect(createTab).toHaveBeenCalledTimes(1)
@@ -104,18 +99,17 @@ describe('seeding beside a caller-provided chat surface', () => {
})
})
- it('activation forwards providesInitialSurface so setup alone adds one tab', () => {
+ it('activation creates a primary shell plus a new-tab setup surface', () => {
const worktree = makeWorktree()
seedEmptyActivatableWorktree(worktree)
const result = activateAndRevealWorktree(worktree.id, {
- providesInitialSurface: true,
notifyHostRuntime: false,
setup
})
expect(result).not.toBe(false)
- expect(result === false ? 'unused' : result.primaryTabId).toBeNull()
- expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1)
+ expect(result === false ? null : result.primaryTabId).toBeTruthy()
+ expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(2)
})
})
diff --git a/src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts b/src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts
deleted file mode 100644
index 4e7e02336ca..00000000000
--- a/src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { readdirSync, readFileSync } from 'node:fs'
-import { join, relative, sep } from 'node:path'
-import { describe, expect, it } from 'vitest'
-
-// Why: `providesInitialSurface: true` is invisible to behavior tests — every opted-out caller
-// still works with the flag deleted, it just re-seeds a shell the user never asked for in a
-// closed-last-terminal workspace. Three review rounds each found a missed caller, so this is
-// the census: activation callers that open their own surface (editor, browser, diff, agent tab)
-// must appear here, and adding or removing an opt-out anywhere must update this list.
-const SURFACE_PROVIDING_CALLERS = [
- 'src/renderer/src/components/editor/check-annotation-open.ts',
- 'src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx',
- 'src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts',
- 'src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts',
- 'src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts',
- 'src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts',
- 'src/renderer/src/hooks/composer-state/full-creation-execution.ts',
- 'src/renderer/src/lib/fix-checks-agent-launch.ts',
- 'src/renderer/src/lib/launch-work-item-direct.ts',
- 'src/renderer/src/lib/worktree-creation-structured-session.ts',
- 'src/renderer/src/lib/workspace-port-actions.ts',
- 'src/renderer/src/lib/onboarding-folder-agent-launch.ts'
-]
-
-// The activation seam itself: declares the option and forwards it into the tombstone gate.
-const SEAM_FILES = [
- 'src/renderer/src/lib/worktree-activation-surface-selection.ts',
- 'src/renderer/src/lib/worktree-activation.ts'
-]
-
-function listSourceFiles(dir: string): string[] {
- return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
- const fullPath = join(dir, entry.name)
- if (entry.isDirectory()) {
- return listSourceFiles(fullPath)
- }
- if (!/\.(ts|tsx)$/.test(entry.name) || /\.test\.(ts|tsx)$/.test(entry.name)) {
- return []
- }
- return [fullPath]
- })
-}
-
-// Why: bound to an actual activation call so a comment or dead code containing the flag
-// text cannot satisfy the census, and a variable-valued flag cannot hide in it. Comments
-// are stripped first — commenting the flag out in place must fail this test.
-const ACTIVATION_CALL_WITH_OPT_OUT =
- /activateAndReveal(?:Worktree|FolderWorkspace|Workspace)\([\s\S]*?providesInitialSurface: true/
-
-function stripComments(source: string): string {
- return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
-}
-
-describe('providesInitialSurface caller wiring', () => {
- it.each(SURFACE_PROVIDING_CALLERS)('%s opts out of tombstone re-seeding', (relativePath) => {
- const source = stripComments(readFileSync(join(process.cwd(), relativePath), 'utf8'))
- expect(source).toMatch(ACTIVATION_CALL_WITH_OPT_OUT)
- })
-
- it('the census matches every mention under src/', () => {
- const root = join(process.cwd(), 'src')
- const mentions = listSourceFiles(root)
- .filter((filePath) => readFileSync(filePath, 'utf8').includes('providesInitialSurface'))
- .map((filePath) => relative(process.cwd(), filePath).split(sep).join('/'))
- .sort()
- expect(mentions).toEqual([...SURFACE_PROVIDING_CALLERS, ...SEAM_FILES].sort())
- })
-})
diff --git a/src/renderer/src/lib/worktree-activation-surface-selection.ts b/src/renderer/src/lib/worktree-activation-surface-selection.ts
index 4385994ff94..caa8dedd42a 100644
--- a/src/renderer/src/lib/worktree-activation-surface-selection.ts
+++ b/src/renderer/src/lib/worktree-activation-surface-selection.ts
@@ -11,8 +11,6 @@ import type { IssueCommandLaunch } from '@/lib/worktree-setup-issue-command-queu
export type WorktreeActivationSurfaceSelection = {
/** The create picker's selection; null means Blank Terminal. */
agent?: TuiAgent | null
- /** A navigation caller is about to open its own editor, diff, or other non-terminal surface. */
- providesInitialSurface?: boolean
}
export type WorktreeActivationOptions = WorktreeActivationSurfaceSelection & {
@@ -31,10 +29,3 @@ export type WorktreeActivationOptions = WorktreeActivationSurfaceSelection & {
/** Keep sidebar filters intact when navigating to a hidden target. */
clearSidebarFilters?: boolean
}
-
-/** Create-time only: an agent selection suppresses the shell its own surface will replace. */
-export function activationProvidesInitialSurface(
- selection?: WorktreeActivationSurfaceSelection
-): boolean {
- return selection?.providesInitialSurface === true || selection?.agent != null
-}
diff --git a/src/renderer/src/lib/worktree-activation-web-runtime.test.ts b/src/renderer/src/lib/worktree-activation-web-runtime.test.ts
index 2daee618eba..0c06b4434a7 100644
--- a/src/renderer/src/lib/worktree-activation-web-runtime.test.ts
+++ b/src/renderer/src/lib/worktree-activation-web-runtime.test.ts
@@ -15,12 +15,16 @@ import {
resetWebRuntimeWakeTerminalRespawnForTests,
shouldSkipWebRuntimeWakeTerminalRespawn
} from '@/runtime/web-runtime-wake-terminal-respawn'
+import { resetWebSessionTabsSnapshotFreshnessForTests } from '@/runtime/web-session-tabs-sync'
+import { resetWorkspaceSurfaceProducersForTests } from './workspace-surface-production'
registerWorktreeActivationReset()
afterEach(() => {
vi.unstubAllGlobals()
resetWebRuntimeWakeTerminalRespawnForTests()
+ resetWebSessionTabsSnapshotFreshnessForTests()
+ resetWorkspaceSurfaceProducersForTests()
})
describe('activateAndRevealWorktree', () => {
diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts
index bf4bbce1123..aa4963c900f 100644
--- a/src/renderer/src/lib/worktree-activation.ts
+++ b/src/renderer/src/lib/worktree-activation.ts
@@ -1,4 +1,3 @@
-import type { FolderWorkspace } from '../../../shared/folder-workspace-types'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui'
@@ -7,9 +6,7 @@ import {
isWebRuntimeSessionActive
} from '@/runtime/web-runtime-session'
import { registerWorktreeActivation } from '@/lib/worktree-activation-nav-registration'
-import { workspaceHasSleepingAgentSessions } from '@/lib/worktree-agent-activation-gate'
import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session'
-import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../shared/workspace-scope'
import {
@@ -18,19 +15,33 @@ import {
getFolderWorkspacePathStatusTitle
} from './folder-workspace-path-status'
import { toast } from 'sonner'
-import { isDetachedHeadWorkspace } from '@/components/sidebar/visible-worktrees'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { findFolderWorkspaceOwner } from './folder-workspace-runtime-owner'
import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload'
import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding'
import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake'
import { applyWorktreeNavViewEntry } from '@/lib/worktree-nav-view-history-replay'
-import {
- activationProvidesInitialSurface,
- type WorktreeActivationOptions,
- type WorktreeActivationSurfaceSelection
+import type {
+ WorktreeActivationOptions,
+ WorktreeActivationSurfaceSelection
} from './worktree-activation-surface-selection'
-import { gateAndReseedEmptyWorkspace } from './worktree-activation-gated-empty-reseed'
+import { registerWorkspaceSurfaceProducer } from './workspace-surface-production'
+import { resolveWorkspaceExecutionEvidence } from './workspace-execution-evidence'
+import {
+ captureActivationRenderableSurfaceIds,
+ consumeTransferredActivationProducer,
+ createWorkspaceActivationIdentity,
+ ensureFolderWorkspaceInitialTerminal,
+ finalizeActivatedWorkspaceSurface,
+ hasOutstandingActivationSurfaceProducer,
+ hasWorkspaceActivationWork,
+ recoverActivatedWorkspace,
+ settleActivationSeedProducer
+} from './worktree-activation-recovery-routing'
+import {
+ clearWorktreeActivationSidebarFilters,
+ revealActivatedWorktree
+} from './worktree-activation-sidebar-filters'
/**
* Shared activation sequence used by the worktree palette and add-repo/worktree dialogs.
@@ -43,36 +54,6 @@ export type ActivateAndRevealResult = {
primaryTabId: string | null
}
-function ensureFolderWorkspaceInitialTerminal(
- folderWorkspace: FolderWorkspace,
- startup?: WorktreeStartupPayload,
- providesInitialSurface?: boolean
-): string | null {
- if (providesInitialSurface === true && startup === undefined) {
- return null
- }
- const state = useAppStore.getState()
- const workspaceKey = folderWorkspaceKey(folderWorkspace.id)
- const primaryTabId = ensureWorktreeHasInitialTerminal(
- state,
- workspaceKey,
- startup,
- undefined,
- undefined,
- undefined,
- { reseedEmptiedWorkspace: providesInitialSurface !== true }
- )
- return primaryTabId
-}
-
-function canInspectAgentActivationInventory(): boolean {
- return (
- typeof window !== 'undefined' &&
- typeof window.api?.runtime?.call === 'function' &&
- typeof window.api?.pty?.listSessions === 'function'
- )
-}
-
export function activateAndRevealFolderWorkspace(
folderWorkspaceId: string,
opts?: WorktreeActivationSurfaceSelection & {
@@ -126,33 +107,37 @@ export function activateAndRevealFolderWorkspace(
state.setActiveFolderWorkspace(folderWorkspaceId, opts?.executionHostId)
const workspaceKey = folderWorkspaceKey(folderWorkspaceId)
- const providesInitialSurface = activationProvidesInitialSurface(opts)
state.markWorktreeVisited(workspaceKey)
if (!state.isNavigatingHistory) {
state.recordWorktreeVisit(workspaceKey)
}
- // Why: same ordering as the worktree path — gate first, then resume only when not deferring.
- const shouldGateAgentActivation =
- !opts?.startup &&
- (workspaceHasSleepingAgentSessions(state, workspaceKey) ||
- (canInspectAgentActivationInventory() &&
- shouldAutoCreateInitialTerminal(
- state.reconcileWorktreeTabModel(workspaceKey).renderableTabCount
- )))
- if (!shouldGateAgentActivation) {
- resumeSleepingAgentSessionsForWorktree(workspaceKey)
+ const identity = createWorkspaceActivationIdentity(workspaceKey, {
+ ...(opts?.executionHostId ? { executionHostId: opts.executionHostId } : {}),
+ runtimeEnvironmentId
+ })
+ const executionEvidence = resolveWorkspaceExecutionEvidence(
+ useAppStore.getState(),
+ workspaceKey,
+ identity.executionHostId
+ )
+ const delegatesToRuntime =
+ executionEvidence === 'live' && isWebRuntimeSessionActive(runtimeEnvironmentId)
+ let primaryTabId: string | null = null
+ if (opts?.startup && !delegatesToRuntime) {
+ const producer = registerWorkspaceSurfaceProducer(identity)
+ if (executionEvidence !== 'exited') {
+ producer.unverifiable('Orca cannot verify the execution host for this requested surface.')
+ } else {
+ try {
+ const existingSurfaceIds = captureActivationRenderableSurfaceIds(workspaceKey)
+ resumeSleepingAgentSessionsForWorktree(workspaceKey)
+ primaryTabId = ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts.startup)
+ settleActivationSeedProducer(producer, workspaceKey, primaryTabId, existingSurfaceIds)
+ } catch (error) {
+ producer.failed(error)
+ }
+ }
}
- if (shouldGateAgentActivation) {
- gateAndReseedEmptyWorkspace(
- workspaceKey,
- opts?.providesInitialSurface === true,
- opts?.executionHostId
- )
- }
- const primaryTabId = shouldGateAgentActivation
- ? null
- : ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts?.startup, providesInitialSurface)
-
if (opts?.revealInSidebar !== false) {
state.revealWorktreeInSidebar(
workspaceKey,
@@ -160,15 +145,14 @@ export function activateAndRevealFolderWorkspace(
)
}
- if (opts?.providesInitialSurface !== true) {
+ if (!hasOutstandingActivationSurfaceProducer(identity)) {
ensureWebRuntimeWorktreeTerminalAfterWake(workspaceKey, {
runtimeEnvironmentId,
startup: opts?.startup,
agent: opts?.agent
})
}
-
- return { primaryTabId }
+ return { primaryTabId: primaryTabId ?? recoverActivatedWorkspace(identity) }
}
export function activateAndRevealWorktree(
@@ -180,10 +164,7 @@ export function activateAndRevealWorktree(
if (!wt) {
return false
}
- const hasActivationWork = Boolean(
- opts?.startup || opts?.setup || opts?.defaultTabs || opts?.issueCommand
- )
- const providesInitialSurface = activationProvidesInitialSurface(opts)
+ const hasActivationWork = hasWorkspaceActivationWork(opts)
// Why: a plain reselect should still reveal the sidebar row but must not restamp focus recency or wake persistence.
const isPlainAlreadyActiveTerminal =
!hasActivationWork &&
@@ -224,37 +205,28 @@ export function activateAndRevealWorktree(
state.recordWorktreeVisit(worktreeId)
}
- // Why: the gate is decided BEFORE resuming. A sleeping session must defer seeding until startup
- // restoration is ready (STA-1111) — resuming first would leave nothing to gate on. Structured
- // agent inventory hydrates asynchronously too, so an empty tab model can otherwise authorize a
- // fallback terminal beside a chat that is about to appear.
- const shouldGateAgentActivation =
- !hasActivationWork &&
- (workspaceHasSleepingAgentSessions(postActivationState, worktreeId) ||
- (canInspectAgentActivationInventory() &&
- shouldAutoCreateInitialTerminal(
- postActivationState.reconcileWorktreeTabModel(worktreeId).renderableTabCount
- )))
- if (!shouldGateAgentActivation) {
+ // Concrete launch work keeps its existing synchronous producer; empty activation is assessed by
+ // the recovery owner, which reconciles sleeping, structured, and live host inventory first.
+ const identity = createWorkspaceActivationIdentity(
+ worktreeId,
+ opts?.executionHostId ? { executionHostId: opts.executionHostId } : undefined
+ )
+ const executionEvidence = resolveWorkspaceExecutionEvidence(
+ postActivationState,
+ worktreeId,
+ identity.executionHostId
+ )
+ const delegatesToRuntime =
+ executionEvidence === 'live' && isWebRuntimeSessionActive(ownerRuntimeEnvironmentId)
+ let primaryTabId: string | null = null
+ if (hasActivationWork) {
// Why: sleeping destroys the local PTY but preserves the provider session id, so waking should
// restore those CLI sessions. Ordering is load-bearing: resuming synchronously creates the
// session's tab first, so the seeding below doesn't add a bare shell next to it.
- resumeSleepingAgentSessionsForWorktree(worktreeId)
- }
- if (shouldGateAgentActivation) {
- gateAndReseedEmptyWorkspace(
- worktreeId,
- opts?.providesInitialSurface === true,
- opts?.executionHostId
- )
- }
-
- // 4. Ensure a focusable surface exists for externally-created worktrees
- const primaryTabId = shouldGateAgentActivation
- ? null
- : providesInitialSurface && !hasActivationWork
- ? null
- : ensureWorktreeHasInitialTerminal(
+ const producer = registerWorkspaceSurfaceProducer(identity)
+ if (delegatesToRuntime) {
+ try {
+ ensureWorktreeHasInitialTerminal(
useAppStore.getState(),
worktreeId,
opts?.startup,
@@ -264,57 +236,88 @@ export function activateAndRevealWorktree(
{
...(opts?.backendStartupTerminalSpawned ? { backendStartupTerminalSpawned: true } : {}),
...(opts?.createNewTerminalForStartup ? { createNewTerminalForStartup: true } : {}),
- ...(providesInitialSurface ? { callerProvidesSurface: true } : {}),
- reseedEmptiedWorkspace: !providesInitialSurface
+ reseedEmptiedWorkspace: true
}
)
- if (primaryTabId && opts?.initialCwd) {
- useAppStore.getState().queueTabInitialCwd(primaryTabId, opts.initialCwd)
+ consumeTransferredActivationProducer(producer)
+ } catch (error) {
+ producer.failed(error)
+ }
+ } else if (opts?.backendStartupTerminalSpawned) {
+ try {
+ primaryTabId = ensureWorktreeHasInitialTerminal(
+ useAppStore.getState(),
+ worktreeId,
+ opts.startup,
+ opts.setup,
+ opts.issueCommand,
+ opts.defaultTabs,
+ {
+ backendStartupTerminalSpawned: true,
+ ...(opts.createNewTerminalForStartup ? { createNewTerminalForStartup: true } : {}),
+ reseedEmptiedWorkspace: true
+ }
+ )
+ if (primaryTabId) {
+ producer.materialized({ kind: 'tab', id: primaryTabId })
+ } else {
+ producer.unverifiable(
+ 'The execution host accepted the startup, but its surface is not visible yet.'
+ )
+ }
+ } catch (error) {
+ producer.failed(error)
+ }
+ } else if (executionEvidence !== 'exited') {
+ producer.unverifiable('Orca cannot verify the execution host for this requested surface.')
+ } else {
+ try {
+ const existingSurfaceIds = captureActivationRenderableSurfaceIds(worktreeId)
+ resumeSleepingAgentSessionsForWorktree(worktreeId)
+ primaryTabId = ensureWorktreeHasInitialTerminal(
+ useAppStore.getState(),
+ worktreeId,
+ opts?.startup,
+ opts?.setup,
+ opts?.issueCommand,
+ opts?.defaultTabs,
+ {
+ ...(opts?.createNewTerminalForStartup ? { createNewTerminalForStartup: true } : {}),
+ reseedEmptiedWorkspace: true
+ }
+ )
+ settleActivationSeedProducer(producer, worktreeId, primaryTabId, existingSurfaceIds)
+ } catch (error) {
+ producer.failed(error)
+ }
+ }
}
-
// 5. Clear sidebar filters hiding the target — reveal needs the card rendered, else it silently no-ops.
if (opts?.clearSidebarFilters !== false) {
- if (state.filterRepoIds.length > 0 && !state.filterRepoIds.includes(wt.repoId)) {
- state.setFilterRepoIds([])
- }
- if (
- state.hideAutomationGeneratedWorkspaces &&
- wt.automationProvenance?.kind === 'created-by-automation'
- ) {
- state.setHideAutomationGeneratedWorkspaces(false)
- }
- if (state.hideCliCreatedWorkspaces && wt.cliProvenance?.kind === 'created-by-cli') {
- state.setHideCliCreatedWorkspaces(false)
- }
- if (state.hideDetachedHeadWorkspaces && isDetachedHeadWorkspace(wt)) {
- state.setHideDetachedHeadWorkspaces(false)
- }
+ clearWorktreeActivationSidebarFilters(state, wt)
}
// 6. Reveal in sidebar
if (opts?.revealInSidebar !== false) {
- if (opts?.sidebarRevealBehavior || opts?.executionHostId) {
- state.revealWorktreeInSidebar(worktreeId, {
- ...(opts.sidebarRevealBehavior ? { behavior: opts.sidebarRevealBehavior } : {}),
- ...(opts.executionHostId ? { executionHostId: opts.executionHostId } : {})
- })
- } else {
- state.revealWorktreeInSidebar(worktreeId)
- }
+ revealActivatedWorktree(state, worktreeId, {
+ ...(opts?.sidebarRevealBehavior ? { behavior: opts.sidebarRevealBehavior } : {}),
+ ...(opts?.executionHostId ? { executionHostId: opts.executionHostId } : {})
+ })
}
if (
opts?.notifyHostRuntime !== false &&
!opts?.backendStartupTerminalSpawned &&
- opts?.providesInitialSurface !== true
+ !hasOutstandingActivationSurfaceProducer(identity)
) {
ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId, {
startup: opts?.startup,
agent: opts?.agent
})
}
-
- return { primaryTabId }
+ return {
+ primaryTabId: finalizeActivatedWorkspaceSurface(identity, primaryTabId, opts?.initialCwd)
+ }
}
/**
diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts
index f80c1e50337..d25e1f48baa 100644
--- a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts
+++ b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts
@@ -590,9 +590,7 @@ describe('worktree agent activation gate', () => {
})
})
- // Failing closed must not also fail silent: an unreadable census leaves the workspace with
- // no surface, so the gate has to hand the caller its seed instead of claiming 'adopted'.
- it('declines to mint but still asks for a seed when the host cannot answer', async () => {
+ it('blocks when the host cannot safely identify the live PTY surface', async () => {
const livePtyId = `${WORKTREE_ID}@@live-agent`
const { deps, createTab } = testDeps({
sessions: [listed(livePtyId)],
@@ -600,12 +598,12 @@ describe('worktree agent activation gate', () => {
resumeCount: 0
})
- await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('empty')
+ await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('blocked')
expect(createTab).not.toHaveBeenCalled()
})
- it('declines to mint but still asks for a seed when two host surfaces claim one live PTY', async () => {
+ it('blocks when two host surfaces claim one live PTY', async () => {
const livePtyId = `${WORKTREE_ID}@@live-agent`
const { deps, createTab } = testDeps({
sessions: [listed(livePtyId)],
@@ -613,7 +611,7 @@ describe('worktree agent activation gate', () => {
resumeCount: 0
})
- await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('empty')
+ await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('blocked')
expect(createTab).not.toHaveBeenCalled()
})
@@ -630,8 +628,7 @@ describe('worktree agent activation gate', () => {
})
seedExistingSurface(deps.getState(), { tabId: 'tab-live', leafId: LIVE_LEAF_ID })
- // The seam re-checks its own guard, so an existing tab is not re-seeded by 'empty'.
- await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('empty')
+ await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('blocked')
expect(createTab).not.toHaveBeenCalled()
expect(warn).toHaveBeenCalledWith(
diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.ts b/src/renderer/src/lib/worktree-agent-activation-gate.ts
index 6ae016512ec..1fd6fa752ae 100644
--- a/src/renderer/src/lib/worktree-agent-activation-gate.ts
+++ b/src/renderer/src/lib/worktree-agent-activation-gate.ts
@@ -218,9 +218,8 @@ export async function runWorktreeAgentActivationGate(
}
let liveSurfaceAdopted = false
if (liveWorkspaceSessions.length > 0) {
- // Why: an unreadable census adopts nothing and mints nothing, so reporting 'adopted'
- // would suppress the caller's seed and leave the workspace with no surface at all —
- // fail-closed must still leave the user a usable pane (STA-5701).
+ // Why: an unreadable census adopts nothing and mints nothing, so reporting 'adopted' would
+ // falsely settle the request; recovery presents the blocked result without starting a writer.
const adoption = await adoptLiveWorkspacePtySurfaces(
deps.getState,
worktreeId,
@@ -234,6 +233,9 @@ export async function runWorktreeAgentActivationGate(
worktreeId,
declinedPtyIds: adoption.declinedPtyIds
})
+ if (!liveSurfaceAdopted) {
+ return 'blocked'
+ }
}
if (liveSurfaceAdopted && !workspaceHasSleepingAgentSessions(deps.getState(), worktreeId)) {
return 'adopted'
diff --git a/src/renderer/src/lib/worktree-agent-activation-seam.test.ts b/src/renderer/src/lib/worktree-agent-activation-seam.test.ts
index 60644670b53..6a3205b04b6 100644
--- a/src/renderer/src/lib/worktree-agent-activation-seam.test.ts
+++ b/src/renderer/src/lib/worktree-agent-activation-seam.test.ts
@@ -9,6 +9,14 @@ import type {
import { activateAndRevealWorktree } from './worktree-activation'
import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate'
import { makeCreatedAgentWorktree as makeWorktree } from './worktree-activation-created-agent-test-state'
+import {
+ registerWorkspaceSurfaceProducer,
+ resetWorkspaceSurfaceProducersForTests
+} from './workspace-surface-production'
+import {
+ readWorkspaceActivationRecoveryPresentation,
+ resetWorkspaceActivationRecoveryPresentationsForTests
+} from './workspace-activation-recovery-presentation'
const initialState = useAppStore.getState()
@@ -159,6 +167,8 @@ function stubInventory(args?: {
afterEach(() => {
vi.unstubAllGlobals()
+ resetWorkspaceSurfaceProducersForTests()
+ resetWorkspaceActivationRecoveryPresentationsForTests()
useAppStore.setState(initialState, true)
})
@@ -213,8 +223,9 @@ describe('worktree agent activation seam', () => {
stubInventory({ livePtyId })
expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null })
- await waitForWorktreeAgentActivationGateForTests(worktree.id)
-
+ await vi.waitFor(() =>
+ expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(1)
+ )
const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? []
expect(tabs).toHaveLength(1)
expect(tabs[0]?.ptyId).toBe(livePtyId)
@@ -226,8 +237,9 @@ describe('worktree agent activation seam', () => {
stubInventory()
expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null })
- await waitForWorktreeAgentActivationGateForTests(worktree.id)
-
+ await vi.waitFor(() =>
+ expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(1)
+ )
const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? []
expect(tabs).toHaveLength(1)
expect(tabs[0]?.ptyId).toBeNull()
@@ -243,42 +255,66 @@ describe('worktree agent activation seam', () => {
stubInventory()
expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null })
- await waitForWorktreeAgentActivationGateForTests(worktree.id)
-
+ await vi.waitFor(() =>
+ expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(1)
+ )
const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? []
expect(tabs).toHaveLength(1)
// A fresh shell, never a second surface forked onto the live agent's PTY.
expect(tabs[0]?.ptyId).toBeNull()
})
- it('does not race an explicitly promised surface with a fallback terminal', async () => {
+ it('does not race a registered surface producer with a fallback terminal', async () => {
const worktree = makeWorktree()
useAppStore.setState(baseState())
stubInventory()
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey: worktree.id,
+ executionHostId: 'local'
+ })
- expect(activateAndRevealWorktree(worktree.id, { providesInitialSurface: true })).toEqual({
+ expect(activateAndRevealWorktree(worktree.id)).toEqual({
primaryTabId: null
})
- await waitForWorktreeAgentActivationGateForTests(worktree.id)
+ await Promise.resolve()
expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0)
+ producer.materialized({ kind: 'workspace-content', id: 'requested-content' })
+ await producer.attempt.result
+ })
+
+ it('does not treat an unrelated surviving browser as completion of a requested launch', async () => {
+ const worktree = makeWorktree()
+ useAppStore.setState(baseState())
+ useAppStore.getState().createBrowserTab(worktree.id, 'https://example.com', { activate: true })
+
+ expect(
+ activateAndRevealWorktree(worktree.id, {
+ startup: { command: 'codex', launchAgent: 'codex' }
+ })
+ ).toEqual({ primaryTabId: null })
+
+ await vi.waitFor(() =>
+ expect(readWorkspaceActivationRecoveryPresentation(worktree.id, 'local')?.kind).toBe(
+ 'producer-failed'
+ )
+ )
+ expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0)
})
- // A paired-runtime owner is always omitted from its own scoped census, and an SSH relay that
- // never answered omits everything. Declining to mint is right; leaving the workspace with no
- // surface at all is not — the user asked for a pane and must get one.
- it('still seeds a usable pane when the census cannot prove who owns a live PTY', async () => {
+ it('shows a blocked recovery without seeding when a live PTY cannot be safely surfaced', async () => {
const worktree = makeWorktree()
const livePtyId = `${worktree.id}@@live-codex`
useAppStore.setState(baseState())
stubInventory({ livePtyId, unverifiableCensus: true })
expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null })
- await waitForWorktreeAgentActivationGateForTests(worktree.id)
-
- const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? []
- expect(tabs).toHaveLength(1)
- expect(tabs[0]?.ptyId).toBeNull()
+ await vi.waitFor(() =>
+ expect(readWorkspaceActivationRecoveryPresentation(worktree.id, 'local')?.kind).toBe(
+ 'blocked'
+ )
+ )
+ expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0)
})
it('does not spawn before a structured chat tab hydrates', async () => {
@@ -287,7 +323,7 @@ describe('worktree agent activation seam', () => {
const { runtimeCall, listSessions } = stubInventory({ structured: true })
expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null })
- await waitForWorktreeAgentActivationGateForTests(worktree.id)
+ await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalled())
expect(useAppStore.getState().unifiedTabsByWorktree[worktree.id] ?? []).toHaveLength(0)
expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0)
@@ -302,9 +338,7 @@ describe('worktree agent activation seam', () => {
})
})
- // A peer owns its own PTYs, so this client can never scope an inventory at it. Scoping must not
- // turn that into a refusal: 'blocked' would also skip the sleeping-agent resume below.
- it('still reaches a verdict for a paired-runtime-owned workspace', async () => {
+ it('keeps a paired-runtime launch producer authoritative when host creation fails', async () => {
const worktree = makeWorktree()
useAppStore.setState({
...baseState(),
@@ -313,14 +347,16 @@ describe('worktree agent activation seam', () => {
const { listSessions } = stubInventory()
expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null })
- await expect(waitForWorktreeAgentActivationGateForTests(worktree.id)).resolves.toBe('empty')
- expect(listSessions).toHaveBeenCalledExactlyOnceWith()
+ await vi.waitFor(() =>
+ expect(readWorkspaceActivationRecoveryPresentation(worktree.id, 'runtime:env-1')?.kind).toBe(
+ 'producer-failed'
+ )
+ )
+ expect(listSessions).not.toHaveBeenCalled()
+ expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0)
})
- // Loss of contact with the relay is not evidence about the host, and once the sync has stopped
- // without an answer the bounded floor in workspace-terminal-host-authority.ts hands seeding back
- // to this client — a detached provider must not turn that into a permanently empty workspace.
- it('still seeds a pane when the selected SSH relay is detached', async () => {
+ it('shows reconnect recovery without starting a requested writer when SSH is detached', async () => {
const worktree = makeWorktree()
useAppStore.setState({
...baseState(),
@@ -335,12 +371,17 @@ describe('worktree agent activation seam', () => {
return []
})
- expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null })
- await expect(waitForWorktreeAgentActivationGateForTests(worktree.id)).resolves.toBe('empty')
- expect(listSessions.mock.calls).toEqual([[{ connectionId: 'box' }], []])
+ expect(
+ activateAndRevealWorktree(worktree.id, {
+ startup: { command: 'codex', launchAgent: 'codex' }
+ })
+ ).toEqual({ primaryTabId: null })
await vi.waitFor(() =>
- expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(1)
+ expect(readWorkspaceActivationRecoveryPresentation(worktree.id, 'ssh:box')?.kind).toBe(
+ 'unverifiable'
+ )
)
- expect(useAppStore.getState().tabsByWorktree[worktree.id]?.[0]?.ptyId).toBeNull()
+ expect(listSessions).not.toHaveBeenCalled()
+ expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0)
})
})
diff --git a/src/renderer/src/lib/worktree-creation-flow-execute.ts b/src/renderer/src/lib/worktree-creation-flow-execute.ts
index a270a7a417b..b05fed7d959 100644
--- a/src/renderer/src/lib/worktree-creation-flow-execute.ts
+++ b/src/renderer/src/lib/worktree-creation-flow-execute.ts
@@ -26,6 +26,13 @@ import {
import { completeWorktreeCreation } from '@/lib/worktree-creation-completion'
import { markStructuredWorktreeLaunchUnconfirmed } from '@/lib/worktree-creation-structured-recovery'
import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake'
+import { registerWorkspaceSurfaceProducer } from '@/lib/workspace-surface-production'
+import { recoverWorkspaceActivation } from '@/lib/worktree-activation-recovery'
+import {
+ getExecutionHostIdForWorktree,
+ getRuntimeEnvironmentIdForWorktree
+} from '@/lib/worktree-runtime-owner'
+import { queueStandaloneSetupTab } from '@/lib/worktree-setup-issue-command-queue'
// Why: activePendingCreationId can outlive the terminal route when the user
// switches app views; only the terminal route renders the creation panel.
@@ -199,39 +206,17 @@ export async function executeWorktreeCreation(
// Startup terminal ids and stamped agent tabs are the only safe primary
// ids when activation returned no result.
primaryTabId = verifiedLaunchTabId
- } else if (existingTabs.length === 0) {
- try {
- primaryTabId = ensureWorktreeHasInitialTerminal(
- useAppStore.getState(),
- worktree.id,
- startupOpt,
- result.setup,
- preparedRequest.issueCommand,
- result.defaultTabs,
- // Activation failed before providing its promised surface, so recovery must seed one.
- backendSpawned ? { backendStartupTerminalSpawned: true } : undefined
- )
- } catch (recoveryError) {
- console.error(
- 'worktree create: activation recovery seeding failed',
- worktree.id,
- recoveryError
- )
- }
- }
- if (!backendSpawned) {
- try {
- ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id, {
- startup: startupOpt,
- agent: preparedRequest.agent
- })
- } catch (recoveryError) {
- console.error(
- 'worktree create: activation recovery after-wake seeding failed',
- worktree.id,
- recoveryError
- )
+ } else {
+ const recoveryState = useAppStore.getState()
+ const identity = {
+ workspaceKey: worktree.id,
+ executionHostId: getExecutionHostIdForWorktree(recoveryState, worktree.id),
+ runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(recoveryState, worktree.id),
+ attemptId: createBrowserUuid()
}
+ const producer = registerWorkspaceSurfaceProducer(identity)
+ producer.failed(error)
+ void recoverWorkspaceActivation(identity, { mode: 'explicit' })
}
}
} else {
@@ -241,19 +226,31 @@ export async function executeWorktreeCreation(
)
if (preparedRequest.agent === null || hasExplicitTerminalWork) {
try {
- primaryTabId = ensureWorktreeHasInitialTerminal(
- useAppStore.getState(),
- worktree.id,
- startupOpt,
- result.setup,
- preparedRequest.issueCommand,
- result.defaultTabs,
- {
- activateCreatedTabs: false,
- ...(preparedRequest.agent !== null ? { callerProvidesSurface: true } : {}),
- ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {})
- }
- )
+ const seedingState = useAppStore.getState()
+ const setupRunsWithoutPrimary =
+ structuredLaunch &&
+ queueStandaloneSetupTab({
+ store: seedingState,
+ worktreeId: worktree.id,
+ setup: result.setup,
+ issueCommand: preparedRequest.issueCommand,
+ defaultTabs: result.defaultTabs,
+ opts: { activateCreatedTabs: false }
+ })
+ if (!setupRunsWithoutPrimary) {
+ primaryTabId = ensureWorktreeHasInitialTerminal(
+ seedingState,
+ worktree.id,
+ startupOpt,
+ result.setup,
+ preparedRequest.issueCommand,
+ result.defaultTabs,
+ {
+ activateCreatedTabs: false,
+ ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {})
+ }
+ )
+ }
} catch (error) {
console.error('worktree create: initial terminal seeding failed', worktree.id, error)
}
diff --git a/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts b/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts
index 158a744413e..36d974fe8d0 100644
--- a/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts
+++ b/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts
@@ -4,6 +4,7 @@ import type {
WorktreeCreationRequest
} from '@/lib/pending-worktree-creation'
import { shouldShowWorktreeCreationSurface } from '@/lib/worktree-creation-surface'
+import type { WorkspaceSurfaceProducer } from '@/lib/workspace-surface-production'
// Guards executeWorktreeCreation's post-create tail: callers fire and forget,
// so a throw after createWorktree succeeds must be contained per-step and the
@@ -56,6 +57,19 @@ const store = {
unifiedTabsByWorktree: {}
}
+const surfaceProducer: WorkspaceSurfaceProducer = {
+ attempt: {
+ id: 'create-recovery-producer',
+ workspaceKey: 'wt-1',
+ executionHostId: 'local',
+ result: Promise.resolve({ kind: 'failed', reason: 'activation exploded' })
+ },
+ materialized: vi.fn(),
+ declined: vi.fn(),
+ failed: vi.fn(),
+ unverifiable: vi.fn()
+}
+
vi.mock('@/store', () => ({
useAppStore: {
getState: () => store
@@ -107,6 +121,25 @@ vi.mock('@/lib/worktree-creation-structured-recovery', () => ({
retryStructuredWorktreeLaunch: vi.fn()
}))
+vi.mock('@/lib/workspace-surface-production', () => ({
+ registerWorkspaceSurfaceProducer: vi.fn(() => surfaceProducer)
+}))
+
+vi.mock('@/lib/worktree-activation-recovery', () => ({
+ recoverWorkspaceActivation: vi.fn(async () => ({
+ kind: 'failed',
+ reason: 'producer-failed',
+ diagnosticId: 'create-recovery-attempt'
+ }))
+}))
+
+vi.mock('@/lib/worktree-runtime-owner', () => ({
+ getExecutionHostIdForWorktree: () => 'local',
+ getRuntimeEnvironmentIdForWorktree: () => null
+}))
+
+vi.mock('@/lib/browser-uuid', () => ({ createBrowserUuid: () => 'create-recovery-attempt' }))
+
import { toast } from 'sonner'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding'
@@ -115,6 +148,8 @@ import { ensureAgentStartupInTerminal } from '@/lib/new-workspace'
import { prepareRequestForCreate } from '@/lib/ephemeral-vm-worktree-creation'
import { executeWorktreeCreation } from './worktree-creation-flow-execute'
import { runBackgroundWorktreeCreation } from './worktree-creation-flow'
+import { registerWorkspaceSurfaceProducer } from '@/lib/workspace-surface-production'
+import { recoverWorkspaceActivation } from '@/lib/worktree-activation-recovery'
function makeRequest(overrides: Partial = {}): WorktreeCreationRequest {
return {
@@ -172,13 +207,18 @@ beforeEach(() => {
store.createWorktree.mockResolvedValue({
worktree: { id: 'wt-1', repoId: 'repo-1' }
})
+ vi.mocked(registerWorkspaceSurfaceProducer).mockReturnValue(surfaceProducer)
+ vi.mocked(recoverWorkspaceActivation).mockResolvedValue({
+ kind: 'failed',
+ reason: 'producer-failed',
+ diagnosticId: 'create-recovery-attempt'
+ })
})
describe('a throw after createWorktree succeeds no longer strands the creation surface', () => {
- it('activating branch: a planless agent throw recovers a terminal and completes', async () => {
+ it('activating branch: a planless agent throw reports recovery failure without a shell', async () => {
const request = makeRequest({ agent: 'claude' })
seedPendingCreation(request)
- vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('recovered-tab')
vi.mocked(activateAndRevealWorktree).mockImplementation(() => {
throw new Error('activation exploded')
})
@@ -190,14 +230,23 @@ describe('a throw after createWorktree succeeds no longer strands the creation s
'wt-1',
expect.any(Error)
)
- expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith(
- store,
- 'wt-1',
- undefined,
- undefined,
- undefined,
- undefined,
- undefined
+ expect(Object.hasOwn(store.tabsByWorktree, 'wt-1')).toBe(false)
+ expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled()
+ expect(registerWorkspaceSurfaceProducer).toHaveBeenCalledWith({
+ workspaceKey: 'wt-1',
+ executionHostId: 'local',
+ runtimeEnvironmentId: null,
+ attemptId: 'create-recovery-attempt'
+ })
+ expect(surfaceProducer.failed).toHaveBeenCalledWith(expect.any(Error))
+ expect(recoverWorkspaceActivation).toHaveBeenCalledWith(
+ {
+ workspaceKey: 'wt-1',
+ executionHostId: 'local',
+ runtimeEnvironmentId: null,
+ attemptId: 'create-recovery-attempt'
+ },
+ { mode: 'explicit' }
)
// Contained: completion still tears the surface down.
expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', {
diff --git a/src/renderer/src/lib/worktree-creation-structured-session.test.ts b/src/renderer/src/lib/worktree-creation-structured-session.test.ts
index f072c17d417..6d26bbb93fb 100644
--- a/src/renderer/src/lib/worktree-creation-structured-session.test.ts
+++ b/src/renderer/src/lib/worktree-creation-structured-session.test.ts
@@ -1,32 +1,61 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const mocks = vi.hoisted(() => ({
- state: {
- pendingWorktreeCreations: { 'creation-1': {} } as Record
- },
- listener: null as ((state: { pendingWorktreeCreations: Record }) => void) | null,
- unsubscribe: vi.fn(),
- startStructuredAgentLaunch: vi.fn(),
- cancelStructuredAgentLaunch: vi.fn(),
- closeStructuredAgentSession: vi.fn(),
- callRuntimeRpc: vi.fn(),
- activateStructuredAgentSessionById: vi.fn(),
- activateAndRevealWorktree: vi.fn(),
- ensureWorktreeHasInitialTerminal: vi.fn(),
- ensureWebRuntimeWorktreeTerminalAfterWake: vi.fn(),
- preflightAgentTrust: vi.fn(),
- updateWorktreeMeta: vi.fn()
-}))
+type MockState = {
+ pendingWorktreeCreations: Record
+ reconcileWorktreeTabModel: () => {
+ renderableTabCount: number
+ activeRenderableTabId: string
+ }
+ allWorktrees?: () => { id: string; path: string }[]
+ repos?: { id: string; connectionId: string }[]
+ updateWorktreeMeta?: ReturnType
+}
+
+const mocks = vi.hoisted(() => {
+ let listener: ((state: MockState) => void) | null = null
+ const reconcileWorktreeTabModel = vi.fn(() => ({
+ renderableTabCount: 1,
+ activeRenderableTabId: 'agent-session:session-1'
+ }))
+ let state: MockState = {
+ pendingWorktreeCreations: { 'creation-1': {} },
+ reconcileWorktreeTabModel
+ }
+ return {
+ get state(): MockState {
+ return state
+ },
+ set state(value: MockState) {
+ state = value
+ },
+ get listener(): ((state: MockState) => void) | null {
+ return listener
+ },
+ set listener(value: ((state: MockState) => void) | null) {
+ listener = value
+ },
+ unsubscribe: vi.fn(),
+ startStructuredAgentLaunch: vi.fn(),
+ cancelStructuredAgentLaunch: vi.fn(),
+ closeStructuredAgentSession: vi.fn(),
+ callRuntimeRpc: vi.fn(),
+ activateStructuredAgentSessionById: vi.fn(),
+ activateAndRevealWorktree: vi.fn(),
+ ensureWorktreeHasInitialTerminal: vi.fn(),
+ ensureWebRuntimeWorktreeTerminalAfterWake: vi.fn(),
+ preflightAgentTrust: vi.fn(),
+ updateWorktreeMeta: vi.fn(),
+ reconcileWorktreeTabModel
+ }
+})
vi.mock('@/store', () => ({
useAppStore: Object.assign(vi.fn(), {
getState: () => mocks.state,
- subscribe: vi.fn(
- (listener: (state: { pendingWorktreeCreations: Record }) => void) => {
- mocks.listener = listener
- return mocks.unsubscribe
- }
- )
+ subscribe: vi.fn((listener: (state: MockState) => void) => {
+ mocks.listener = listener
+ return mocks.unsubscribe
+ })
})
}))
@@ -112,14 +141,18 @@ function storeWithWorktree() {
pendingWorktreeCreations: { 'creation-1': {} },
allWorktrees: () => [{ id: 'worktree-1', path: '/tmp/worktree-1' }],
repos: [{ id: 'repo-1', connectionId: 'ssh-1' }],
- updateWorktreeMeta: mocks.updateWorktreeMeta
- } as unknown as typeof mocks.state
+ updateWorktreeMeta: mocks.updateWorktreeMeta,
+ reconcileWorktreeTabModel: mocks.reconcileWorktreeTabModel
+ }
}
describe('launchStructuredWorktreeSession', () => {
beforeEach(() => {
vi.clearAllMocks()
- mocks.state = { pendingWorktreeCreations: { 'creation-1': {} } }
+ mocks.state = {
+ pendingWorktreeCreations: { 'creation-1': {} },
+ reconcileWorktreeTabModel: mocks.reconcileWorktreeTabModel
+ }
mocks.listener = null
mocks.closeStructuredAgentSession.mockResolvedValue('closed')
mocks.callRuntimeRpc.mockResolvedValue(undefined)
@@ -232,7 +265,10 @@ describe('launchStructuredWorktreeSession', () => {
})
it('returns cancelled without starting a launch when the creation is already gone', async () => {
- mocks.state = { pendingWorktreeCreations: {} }
+ mocks.state = {
+ pendingWorktreeCreations: {},
+ reconcileWorktreeTabModel: mocks.reconcileWorktreeTabModel
+ }
await expect(
launchStructuredWorktreeSession({
@@ -273,7 +309,10 @@ describe('launchStructuredWorktreeSession', () => {
await Promise.resolve()
expect(mocks.cancelStructuredAgentLaunch).not.toHaveBeenCalled()
- mocks.state = { pendingWorktreeCreations: {} }
+ mocks.state = {
+ pendingWorktreeCreations: {},
+ reconcileWorktreeTabModel: mocks.reconcileWorktreeTabModel
+ }
mocks.listener?.(mocks.state)
mocks.listener?.(mocks.state)
expect(mocks.cancelStructuredAgentLaunch).toHaveBeenCalledExactlyOnceWith(
@@ -398,7 +437,10 @@ describe('launchStructuredWorktreeSession', () => {
// Why: the module trusts its callers for the route, so the agent check is the last local
// eligibility gate. Without it a dismissed creation reports itself cancelled for an agent that
// was never going to open a session here.
- mocks.state = { pendingWorktreeCreations: {} }
+ mocks.state = {
+ pendingWorktreeCreations: {},
+ reconcileWorktreeTabModel: mocks.reconcileWorktreeTabModel
+ }
await expect(
launchStructuredWorktreeSession({
@@ -542,7 +584,10 @@ describe('launchStructuredWorktreeSession', () => {
primaryTabId: null
})
- mocks.state = { pendingWorktreeCreations: {} }
+ mocks.state = {
+ pendingWorktreeCreations: {},
+ reconcileWorktreeTabModel: mocks.reconcileWorktreeTabModel
+ }
mocks.listener?.(mocks.state)
resolveLaunch({ sessionId: 'session-1', fence: 1 })
@@ -638,9 +683,7 @@ describe('launchStructuredWorktreeSession', () => {
primaryTabId: null
})
- expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('worktree-1', {
- providesInitialSurface: true
- })
+ expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('worktree-1')
expect(mocks.activateAndRevealWorktree.mock.invocationCallOrder[0]).toBeLessThan(
mocks.activateStructuredAgentSessionById.mock.invocationCallOrder[0]
)
diff --git a/src/renderer/src/lib/worktree-creation-structured-session.ts b/src/renderer/src/lib/worktree-creation-structured-session.ts
index df86f56efed..dc32770b0a3 100644
--- a/src/renderer/src/lib/worktree-creation-structured-session.ts
+++ b/src/renderer/src/lib/worktree-creation-structured-session.ts
@@ -13,6 +13,9 @@ import { closeStructuredAgentSession } from '@/runtime/structured-agent-session-
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake'
+import { registerWorkspaceSurfaceProducer } from '@/lib/workspace-surface-production'
+import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
+import { settleStructuredAgentSurfaceProducer } from '@/lib/structured-agent-surface-production'
export type WorktreeCreationStructuredSessionResult = {
accepted: boolean
@@ -138,6 +141,10 @@ export async function launchStructuredWorktreeSession(
...(args.request.promptDelivery ? { promptDelivery: args.request.promptDelivery } : {})
})
})
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey: args.worktreeId,
+ executionHostId: getExecutionHostIdForWorktree(useAppStore.getState(), args.worktreeId)
+ })
const abandoned = new AbortController()
const unsubscribe = useAppStore.subscribe((state) => {
if (!state.pendingWorktreeCreations[args.creationId]) {
@@ -158,10 +165,8 @@ export async function launchStructuredWorktreeSession(
return
}
// Why: chat selection requires its workspace to be active.
- if (!activation) {
- activation = activateAndRevealWorktree(args.worktreeId, {
- providesInitialSurface: true
- })
+ if (activation === false) {
+ activation = activateAndRevealWorktree(args.worktreeId)
primaryTabId = activation === false ? null : activation.primaryTabId
}
activateStructuredAgentSessionById({ worktreeId: args.worktreeId, sessionId })
@@ -169,16 +174,28 @@ export async function launchStructuredWorktreeSession(
},
{ worktreeId: args.worktreeId }
)
- } catch {
+ } catch (error) {
// Why: nothing awaits this creation's caller, so an escaped throw would strand the panel
// mid-create. Report it the way a failed launch already does; the launch layer toasts it.
+ producer.failed(error)
+ if (args.shouldActivateOnCompletion && activation === false) {
+ activation = activateAndRevealWorktree(args.worktreeId)
+ }
return { ...settled, activation, primaryTabId }
} finally {
unsubscribe()
}
if (!settlement) {
+ producer.failed('The agent launch did not start.')
+ if (args.shouldActivateOnCompletion && activation === false) {
+ activation = activateAndRevealWorktree(args.worktreeId)
+ }
return { ...settled, activation, primaryTabId }
}
+ if (settlement.kind === 'failed' && args.shouldActivateOnCompletion && activation === false) {
+ activation = activateAndRevealWorktree(args.worktreeId)
+ }
+ settleStructuredAgentSurfaceProducer(producer, args.worktreeId, settlement)
switch (settlement.kind) {
case 'cancelled': {
// Why: a refusal means no session exists on the host, so there is nothing to retire.
diff --git a/src/renderer/src/lib/worktree-creation-structured-unknown-outcome.test.ts b/src/renderer/src/lib/worktree-creation-structured-unknown-outcome.test.ts
index 46e132132f1..a6ddf890cc6 100644
--- a/src/renderer/src/lib/worktree-creation-structured-unknown-outcome.test.ts
+++ b/src/renderer/src/lib/worktree-creation-structured-unknown-outcome.test.ts
@@ -94,6 +94,7 @@ vi.mock('sonner', () => ({
}))
vi.mock('@/i18n/i18n', () => ({
+ i18n: { language: 'en' },
translate: (_key: string, fallback: string) => fallback
}))
diff --git a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts
index f9c2844d472..2eb75d2e58f 100644
--- a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts
+++ b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts
@@ -2,7 +2,6 @@ import type {
WorktreeDefaultTabsLaunch,
WorktreeSetupLaunch
} from '../../../shared/worktree/launch-types'
-import type { ExecutionHostId } from '../../../shared/execution-host'
import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal'
import { createSequencedSetupAgentCommands } from '../../../shared/setup-agent-sequencing'
import { getSetupRunnerCommandPlatformForPath } from '../../../shared/setup-runner-command'
@@ -28,6 +27,8 @@ import {
type IssueCommandLaunch
} from '@/lib/worktree-setup-issue-command-queue'
import { applyDefaultTerminalTabs } from '@/lib/worktree-default-terminal-tabs'
+import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
+import { resolveWorkspaceExecutionEvidence } from '@/lib/workspace-execution-evidence'
function getSetupRunnerCommandPlatformForLaunch(setup: WorktreeSetupLaunch): 'windows' | 'posix' {
return getSetupRunnerCommandPlatformForPath(
@@ -36,33 +37,6 @@ function getSetupRunnerCommandPlatformForLaunch(setup: WorktreeSetupLaunch): 'wi
)
}
-/** Re-seed after an empty gate unless its activation owns the surface or no longer owns the host. */
-export function reseedGatedEmptyWorkspace(
- workspaceKey: string,
- callerProvidesSurface: boolean,
- executionHostId?: ExecutionHostId
-): void {
- const state = useAppStore.getState()
- if (
- callerProvidesSurface === true ||
- state.activeWorktreeId !== workspaceKey ||
- (executionHostId !== undefined && state.activeWorkspaceExecutionHostId !== executionHostId)
- ) {
- return
- }
- ensureWorktreeHasInitialTerminal(
- state,
- workspaceKey,
- undefined,
- undefined,
- undefined,
- undefined,
- {
- reseedEmptiedWorkspace: true
- }
- )
-}
-
export function ensureWorktreeHasInitialTerminal(
store: WorktreeActivationStore,
worktreeId: string,
@@ -99,6 +73,11 @@ export function ensureWorktreeHasInitialTerminal(
const backendStartupTerminalSpawned = opts?.backendStartupTerminalSpawned === true
const hostAuthority = resolveWorkspaceTerminalHostAuthority(ownerState, worktreeId)
+ const executionEvidence = resolveWorkspaceExecutionEvidence(
+ ownerState,
+ worktreeId,
+ getExecutionHostIdForWorktree(ownerState, worktreeId)
+ )
// Why: explicit spawn evidence survives the new-worktree ownership race; a host that owns terminal creation provides the same authority for later activations.
if (backendStartupTerminalSpawned || hostAuthority === 'live') {
const existingTerminalTabId = store.tabsByWorktree[worktreeId]?.[0]?.id
@@ -137,32 +116,6 @@ export function ensureWorktreeHasInitialTerminal(
}
const hasExplicitLaunchWork = Boolean(sequencedStartup || setup || issueCommand)
- // Why: a caller opening its own primary surface (a structured native chat) asked for that surface
- // alone. Setup launched in its own tab needs no shell to attach to, so seeding one leaves a stray
- // "Terminal 1" beside the chat. Splits and issue automation still need a pane to split from.
- const setupNeedsHostTerminal =
- setup !== undefined &&
- (useAppStore.getState().settings?.setupScriptLaunchMode ?? 'new-tab') !== 'new-tab'
- if (
- opts?.callerProvidesSurface === true &&
- renderableTabCount === 0 &&
- !sequencedStartup &&
- !issueCommand &&
- !setupNeedsHostTerminal &&
- !defaultTabs?.tabs.length &&
- opts?.createNewTerminalForStartup !== true
- ) {
- queueSetupAndIssueCommands(
- store,
- worktreeId,
- null,
- setup,
- undefined,
- wrappedSetupCommandStr,
- opts
- )
- return null
- }
// Why: only startup hydration honours the closed-last-tab tombstone. Every explicit
// activation (sidebar, palette, automation resume, wake) re-seeds a surface instead,
// because closing the last terminal normally deactivates the workspace too
@@ -175,15 +128,18 @@ export function ensureWorktreeHasInitialTerminal(
// deactivation hooks for pane moves and retirement, where re-seeding is the wanted outcome.
const shouldHonourClosedTerminalTombstone =
Object.hasOwn(store.tabsByWorktree, worktreeId) && opts?.reseedEmptiedWorkspace !== true
- // Why: an execution host that has not answered is not a host with no terminals; seeding into that
- // gap is what adds a tab per launch (STA-4658). Explicit launch work below is a request to create
- // a terminal now, so it stays ungated.
+ // Why: an execution host that has not answered is not a host with no terminals; neither automatic
+ // recovery nor explicit launch work can turn that uncertainty into permission for another writer.
const shouldAutoCreate =
hostAuthority === 'none' &&
+ executionEvidence === 'exited' &&
shouldAutoCreateInitialTerminal(renderableTabCount, shouldHonourClosedTerminalTombstone)
- const shouldCreateForExplicitWork = renderableTabCount === 0 && hasExplicitLaunchWork
+ const shouldCreateForExplicitWork =
+ executionEvidence === 'exited' && renderableTabCount === 0 && hasExplicitLaunchWork
const shouldCreateNewStartupTerminal =
- opts?.createNewTerminalForStartup === true && sequencedStartup !== undefined
+ executionEvidence === 'exited' &&
+ opts?.createNewTerminalForStartup === true &&
+ sequencedStartup !== undefined
if (!shouldAutoCreate && !shouldCreateForExplicitWork && !shouldCreateNewStartupTerminal) {
const existingTerminalTabId = store.tabsByWorktree[worktreeId]?.[0]?.id
if (existingTerminalTabId && (setup || issueCommand)) {
diff --git a/src/renderer/src/lib/worktree-setup-issue-command-queue.ts b/src/renderer/src/lib/worktree-setup-issue-command-queue.ts
index 3a66305fb67..de2e4aeb57d 100644
--- a/src/renderer/src/lib/worktree-setup-issue-command-queue.ts
+++ b/src/renderer/src/lib/worktree-setup-issue-command-queue.ts
@@ -1,4 +1,7 @@
-import type { WorktreeSetupLaunch } from '../../../shared/worktree/launch-types'
+import type {
+ WorktreeDefaultTabsLaunch,
+ WorktreeSetupLaunch
+} from '../../../shared/worktree/launch-types'
import { buildSetupRunnerCommand } from './setup-runner'
import { useAppStore } from '@/store'
import type {
@@ -11,6 +14,34 @@ export type IssueCommandLaunch =
| WorktreeSetupLaunch
| { command: string; env?: Record }
+export function queueStandaloneSetupTab(args: {
+ store: WorktreeActivationStore
+ worktreeId: string
+ setup: WorktreeSetupLaunch | undefined
+ issueCommand: IssueCommandLaunch | undefined
+ defaultTabs: WorktreeDefaultTabsLaunch | undefined
+ opts?: InitialTerminalOptions
+}): boolean {
+ if (
+ !args.setup ||
+ args.issueCommand ||
+ args.defaultTabs?.tabs.length ||
+ (useAppStore.getState().settings?.setupScriptLaunchMode ?? 'new-tab') !== 'new-tab'
+ ) {
+ return false
+ }
+ queueSetupAndIssueCommands(
+ args.store,
+ args.worktreeId,
+ null,
+ args.setup,
+ undefined,
+ undefined,
+ args.opts
+ )
+ return true
+}
+
export function queueSetupAndIssueCommands(
store: WorktreeActivationStore,
worktreeId: string,