mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 00:02:34 +00:00
fix activation surface recovery ownership
This commit is contained in:
@@ -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({
|
||||
<div
|
||||
// Why: already out of flow via the workbench container when hidden, so retention only
|
||||
// has to drop `hidden` — it does not need to leave the flex column a second time.
|
||||
className={`flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${
|
||||
className={`relative flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${
|
||||
renderedActiveWorktreeId
|
||||
? ''
|
||||
: retainBrowserGuestPaint
|
||||
@@ -30,6 +31,9 @@ export function TerminalSurface({
|
||||
<TerminalTitlebarTabs controller={controller} />
|
||||
<TerminalSplitWorkspaceSurfaces controller={controller} />
|
||||
<TerminalLegacyWorkspaceSurface controller={controller} />
|
||||
{renderedActiveWorktreeId ? (
|
||||
<WorkspaceActivationRecoverySurface worktreeId={renderedActiveWorktreeId} />
|
||||
) : null}
|
||||
<TerminalWorkspaceDialogs controller={controller} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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<string, unknown[]> } = {
|
||||
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(<WorkspaceActivationRecoverySurface worktreeId={WORKSPACE_KEY} />)
|
||||
|
||||
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(<WorkspaceActivationRecoverySurface worktreeId={WORKSPACE_KEY} />)
|
||||
|
||||
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(<WorkspaceActivationRecoverySurface worktreeId={WORKSPACE_KEY} />)
|
||||
|
||||
expect(screen.getByRole('heading', { name: title })).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<div
|
||||
className="absolute inset-0 z-20 flex items-center justify-center bg-background"
|
||||
data-workspace-activation-recovery="recovering"
|
||||
role="status"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
|
||||
{translate(
|
||||
'auto.components.workspace.activation.recovery.progress',
|
||||
'Checking workspace surfaces…'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const copy = presentationCopy(presentation)
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-20 flex items-center justify-center bg-background p-6"
|
||||
data-workspace-activation-recovery={presentation.kind}
|
||||
role="alert"
|
||||
>
|
||||
<div className="flex w-full max-w-md flex-col items-center text-center">
|
||||
<CircleAlert className="size-8 text-destructive" aria-hidden="true" />
|
||||
<h2 className="mt-4 text-base font-medium text-foreground">{copy.title}</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{copy.description}</p>
|
||||
<Button type="button" className="mt-5 gap-2" onClick={presentation.retry}>
|
||||
<RefreshCw className="size-4" aria-hidden="true" />
|
||||
{translate('auto.components.workspace.activation.recovery.retry', 'Retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -33,6 +33,10 @@ const store = {
|
||||
agentStatusByPaneKey: {} as Record<string, { agentType?: string }>,
|
||||
tabsByWorktree: {} as Record<string, { id: string; launchAgent?: string | null }[]>,
|
||||
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'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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<typeof launchAgentInNewTab>
|
||||
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<typeof result.structuredSettlement>
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
+1
-5
@@ -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 () => {
|
||||
|
||||
@@ -65,8 +65,6 @@ function renderWatcherEffects(overrides: Partial<WatcherController> = {}): Promi
|
||||
activityTerminalPortals: [],
|
||||
anyMountedWorktreeHasLayout: false,
|
||||
backgroundMountRevision: 0,
|
||||
createTab: vi.fn(),
|
||||
reconcileWorktreeTabModel: vi.fn(),
|
||||
pairedRuntimeParkingEnvironmentIds: new Set(),
|
||||
terminalSshParkingEnabled: true,
|
||||
terminalProviderSnapshotCapabilityRevision: 0,
|
||||
|
||||
@@ -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(<Watcher activeWorktreeId="folder:workspace-1" />))
|
||||
|
||||
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(<Watcher activeWorktreeId="worktree-1" />))
|
||||
await act(async () => undefined)
|
||||
|
||||
await act(async () => root?.render(<Watcher activeWorktreeId="worktree-2" />))
|
||||
|
||||
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(<Watcher />))
|
||||
await act(async () => root?.render(<Watcher activeWorktreeId="worktree-1" />))
|
||||
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(<Watcher activeWorktreeId="folder:workspace-2" />))
|
||||
|
||||
expect(mocks.createTab).toHaveBeenCalledTimes(expectedTabs)
|
||||
expect(firstSignal?.aborted).toBe(true)
|
||||
expect(mocks.recover).toHaveBeenCalledTimes(2)
|
||||
await act(async () => settleSecond())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string> = 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<string>())
|
||||
// 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
|
||||
])
|
||||
|
||||
@@ -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<typeof FullCreationStructuredLaunchModule>()),
|
||||
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<T>() {
|
||||
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<FullCreationExecutionInput['createWorktree']>()
|
||||
.mockResolvedValue(created)
|
||||
const state = {
|
||||
applyWorktreeMeta: vi
|
||||
.fn<FullCreationExecutionInput['applyWorktreeMeta']>()
|
||||
.mockResolvedValue(),
|
||||
clearNewWorkspaceDraft: vi.fn<FullCreationExecutionInput['clearNewWorkspaceDraft']>(),
|
||||
createWorktree,
|
||||
effectivePresetId: null,
|
||||
isSubmissionCancelled: () => false,
|
||||
linkedGitLabIssue: null,
|
||||
linkedGitLabMR: null,
|
||||
normalizedSparseDirectories: [],
|
||||
note: '',
|
||||
onCreated: vi.fn<NonNullable<FullCreationExecutionInput['onCreated']>>(),
|
||||
parentWorktreeId: null,
|
||||
persistDraft: false,
|
||||
persistSetupAgentStartupPolicy: vi.fn(async () => true),
|
||||
prepareFullSubmit: vi
|
||||
.fn<FullCreationExecutionInput['prepareFullSubmit']>()
|
||||
.mockResolvedValue(prepared),
|
||||
resolvedInitialWorkspaceStatus: undefined,
|
||||
selectedRepoExecutionHostId: 'local',
|
||||
selectedRepoIsGit: true,
|
||||
setSidebarOpen: vi.fn<FullCreationExecutionInput['setSidebarOpen']>(),
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<typeof activateAndRevealWorktree>
|
||||
let settlement: Awaited<ReturnType<typeof settleFullCreationStructuredLaunch>>
|
||||
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.
|
||||
|
||||
@@ -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<typeof queueStandaloneSetupTab>[0]
|
||||
|
||||
export function beginFullCreationSurfaceProduction(
|
||||
args: Omit<StandaloneSetupArgs, 'store'> & { 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. */
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<typeof queueStandaloneSetupTab>[0]
|
||||
type StructuredSettlement = Awaited<ReturnType<typeof settleDirectWorkItemStructuredLaunch>>
|
||||
|
||||
export function beginDirectWorkItemSurfaceProduction(
|
||||
args: Omit<StandaloneSetupArgs, 'store'> & {
|
||||
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.')
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ReturnType<typeof settleDirectWorkItemStructuredLaunch>>
|
||||
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.
|
||||
|
||||
@@ -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<void> {
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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}` })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<WorkspaceActivationRecoveryResult> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<WorkspaceActivationRecoveryResult | 'empty' | 'produced'> {
|
||||
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<WorkspaceActivationRecoveryResult> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, WorkspaceActivationRecoveryPresentation>()
|
||||
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()
|
||||
}
|
||||
@@ -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<string, ReadonlySet<string>>()
|
||||
|
||||
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<WorkspaceActivationRecoveryResult | null> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>()
|
||||
const gateIdentityByPromise = new WeakMap<Promise<unknown>, 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<unknown>): string | undefined {
|
||||
return gateIdentityByPromise.get(gate)
|
||||
}
|
||||
|
||||
export function recordActivationRecoveryGateRoute(gate: Promise<unknown>, 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<string> {
|
||||
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<RecoveryWaitResult> {
|
||||
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<T>(
|
||||
promise: Promise<T>,
|
||||
deadlineAt: number,
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<
|
||||
| { kind: 'settled'; value: T }
|
||||
| { kind: 'rejected'; error: unknown }
|
||||
| Exclude<RecoveryWaitResult, 'changed'>
|
||||
> {
|
||||
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<RecoveryWaitResult, 'changed'>
|
||||
): 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'
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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<typeof useAppStore.getState>['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
|
||||
|
||||
@@ -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<typeof useAppStore.getState>['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.' }
|
||||
}
|
||||
}
|
||||
@@ -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<WorkspaceSurfaceProductionResult>
|
||||
}
|
||||
|
||||
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<string, WorkspaceSurfaceProducerEntry>()
|
||||
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<WorkspaceSurfaceProductionResult>((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<WorkspaceSurfaceProducerEntry>[] {
|
||||
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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<activationGate.WorktreeAgentActivationOutcome>((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<activationGate.WorktreeAgentActivationOutcome>((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<ReturnType<typeof useAppStore.getState>>)
|
||||
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<ReturnType<typeof useAppStore.getState>>)
|
||||
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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' } }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<WorktreeAgentActivationOutcome>,
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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<string, number> {
|
||||
const counts: Record<string, number> = {}
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string>
|
||||
): 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<string> {
|
||||
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)
|
||||
}
|
||||
@@ -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<string, { worktreeId: string }>
|
||||
tabsByWorktree: Record<string, unknown[]>
|
||||
unifiedTabsByWorktree: Record<string, FakeUnifiedTab[]>
|
||||
remoteWorkspaceHydratedTargetIds: Set<string>
|
||||
remoteWorkspaceSyncStatusByTargetId: Record<string, { phase: 'offline' | 'synced' }>
|
||||
reconcileWorktreeTabModel: (workspaceKey: string) => {
|
||||
renderableTabCount: number
|
||||
activeRenderableTabId: string | null
|
||||
}
|
||||
createTab: ReturnType<typeof vi.fn>
|
||||
}
|
||||
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<string>(),
|
||||
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> = {}
|
||||
): 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<unknown> | 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()
|
||||
})
|
||||
})
|
||||
@@ -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<WorkspaceActivationRecoveryResult> {
|
||||
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')
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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> = {}): 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', {
|
||||
|
||||
@@ -1,32 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
state: {
|
||||
pendingWorktreeCreations: { 'creation-1': {} } as Record<string, unknown>
|
||||
},
|
||||
listener: null as ((state: { pendingWorktreeCreations: Record<string, unknown> }) => 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<string, unknown>
|
||||
reconcileWorktreeTabModel: () => {
|
||||
renderableTabCount: number
|
||||
activeRenderableTabId: string
|
||||
}
|
||||
allWorktrees?: () => { id: string; path: string }[]
|
||||
repos?: { id: string; connectionId: string }[]
|
||||
updateWorktreeMeta?: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
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<string, unknown> }) => 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]
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -94,6 +94,7 @@ vi.mock('sonner', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
i18n: { language: 'en' },
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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<string, string> }
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user