From 2ffac471bddf6b743512fcf39bbbbd1a437e2d37 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:05:30 -0700 Subject: [PATCH] fix(workspaces): seed shells only for blank selection (#19940) * fix(workspaces): seed shells only for blank selection * fix(workspaces): create runtime-owned launch surfaces * fix(workspaces): report runtime surface failures --------- Co-authored-by: Merge Sim --- .../runtime/runtime-folder-worktree-create.ts | 2 +- ...me-local-worktree-terminal-startup.test.ts | 74 ++++++ ...runtime-local-worktree-terminal-startup.ts | 2 +- .../runtime-remote-managed-worktree-create.ts | 2 +- .../folder-workspace-composer-submit.test.ts | 3 + .../folder-workspace-composer-submit.ts | 2 + .../composer-state/full-creation-execution.ts | 2 + .../full-creation-structured-launch.test.ts | 2 + .../full-creation-structured-launch.ts | 3 + ...eb-runtime-worktree-terminal-after-wake.ts | 110 +++++++-- ...ctivation-emptied-workspace-reseed.test.ts | 33 +++ .../worktree-activation-empty-remote.test.ts | 54 +++++ ...e-activation-surface-caller-wiring.test.ts | 5 +- .../worktree-activation-surface-selection.ts | 39 +++ .../worktree-activation-web-runtime.test.ts | 222 +++++++++++++++++- src/renderer/src/lib/worktree-activation.ts | 77 +++--- .../worktree-creation-agent-seeding.test.ts | 87 +++++++ .../src/lib/worktree-creation-flow-execute.ts | 13 +- .../src/lib/worktree-creation-flow.test.ts | 2 +- ...rktree-creation-structured-session.test.ts | 10 + .../worktree-creation-structured-session.ts | 27 ++- .../src/runtime/web-runtime-session-types.ts | 2 + .../web-runtime-terminal-create-operation.ts | 4 +- .../active-session-subscription.ts | 19 +- 24 files changed, 701 insertions(+), 95 deletions(-) create mode 100644 src/main/runtime/runtime-local-worktree-terminal-startup.test.ts create mode 100644 src/renderer/src/lib/worktree-activation-surface-selection.ts create mode 100644 src/renderer/src/lib/worktree-creation-agent-seeding.test.ts diff --git a/src/main/runtime/runtime-folder-worktree-create.ts b/src/main/runtime/runtime-folder-worktree-create.ts index efea3c22c72..ef7798c91f9 100644 --- a/src/main/runtime/runtime-folder-worktree-create.ts +++ b/src/main/runtime/runtime-folder-worktree-create.ts @@ -172,7 +172,7 @@ export async function createRuntimeFolderWorktree(args: { undefined, args.startup && !didSpawnStartup ? args.startup : undefined ) - } else if (deps.ptySpawnAvailable && !didSpawnStartup) { + } else if (deps.ptySpawnAvailable && !didSpawnStartup && !args.createdWithAgent) { try { await deps.createTerminal(`id:${worktree.id}`, { surfaceOwner: false }) } catch (error) { diff --git a/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts b/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts new file mode 100644 index 00000000000..1153b173553 --- /dev/null +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import type { Worktree } from '../../shared/worktree/types' +import { startRuntimeLocalWorktreeTerminals } from './runtime-local-worktree-terminal-startup' + +const repo: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1 +} + +const worktree: Worktree = { + id: 'worktree-1', + repoId: repo.id, + path: '/worktree', + head: 'abc', + branch: 'feature', + isBare: false, + isMainWorktree: false, + displayName: 'feature', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1 +} + +type StartupArgs = Parameters[0] + +function createPorts() { + const createTerminal = vi.fn().mockResolvedValue({ + handle: 'term-1', + worktreeId: worktree.id, + title: null + }) + const ports: StartupArgs['ports'] = { + canSpawn: true, + markTrusted: vi.fn(), + createTerminal, + pasteDraft: vi.fn(), + sendFollowup: vi.fn(), + provision: vi.fn().mockResolvedValue({ setupSpawned: false, setupTerminalHandle: null }), + activate: vi.fn() + } + return { createTerminal, ports } +} + +describe('startRuntimeLocalWorktreeTerminals default shell seeding', () => { + it.each([ + ['Blank Terminal', undefined, 1], + ['an agent', 'codex' as const, 0] + ])('seeds a background shell for %s selection only', async (_label, agent, expectedCalls) => { + const { createTerminal, ports } = createPorts() + + await startRuntimeLocalWorktreeTerminals({ + request: { repoSelector: `id:${repo.id}`, name: worktree.displayName }, + repo, + worktree, + ...(agent ? { createdWithAgent: agent } : {}), + ports + }) + + expect(createTerminal).toHaveBeenCalledTimes(expectedCalls) + if (expectedCalls > 0) { + expect(createTerminal).toHaveBeenCalledWith(`id:${worktree.id}`, { surfaceOwner: false }) + } + }) +}) diff --git a/src/main/runtime/runtime-local-worktree-terminal-startup.ts b/src/main/runtime/runtime-local-worktree-terminal-startup.ts index 35985b53497..7babcd7da7d 100644 --- a/src/main/runtime/runtime-local-worktree-terminal-startup.ts +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.ts @@ -163,7 +163,7 @@ export async function startRuntimeLocalWorktreeTerminals(args: { didSpawnSetup = true } } - } else if (ports.canSpawn) { + } else if (ports.canSpawn && !args.createdWithAgent) { try { await ports.createTerminal(`id:${worktree.id}`, { surfaceOwner: false }) } catch (error) { diff --git a/src/main/runtime/runtime-remote-managed-worktree-create.ts b/src/main/runtime/runtime-remote-managed-worktree-create.ts index 01a49142dc4..83de82b0594 100644 --- a/src/main/runtime/runtime-remote-managed-worktree-create.ts +++ b/src/main/runtime/runtime-remote-managed-worktree-create.ts @@ -222,7 +222,7 @@ export async function createRuntimeRemoteManagedWorktree( didSpawnSetup = true } } - } else if (!shouldActivate && deps.canSpawn()) { + } else if (!shouldActivate && deps.canSpawn() && !args.createdWithAgent) { try { await deps.createTerminal(`path:${result.worktree.path}`, { surfaceOwner: false }) } catch (err) { diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts index a96564b6652..ef27dfcc03b 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts @@ -116,6 +116,7 @@ describe('submitFolderWorkspaceCreate', () => { }) expect(onOpenChange).toHaveBeenCalledWith(false) expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1', { + agent: null, runtimeEnvironmentId: null }) expect(consoleError).toHaveBeenCalledWith( @@ -532,6 +533,7 @@ describe('submitFolderWorkspaceCreate', () => { linkedTask: linkedWorkItem }) expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1', { + agent: null, runtimeEnvironmentId: null }) expect(mocks.ensureAgentStartupInTerminal).not.toHaveBeenCalled() @@ -659,6 +661,7 @@ describe('submitFolderWorkspaceCreate', () => { }) expect(onOpenChange).toHaveBeenCalledWith(false) expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1', { + agent: null, runtimeEnvironmentId: null }) }) diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 34eab00e730..77ff1c193b8 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -207,6 +207,7 @@ export async function submitFolderWorkspaceCreate({ onOpenChange(false) try { let activation = activateAndRevealFolderWorkspace(workspace.id, { + agent: quickAgent, ...(!structuredLaunch && startup ? { startup } : {}), ...(structuredLaunch ? { providesInitialSurface: true } : {}), runtimeEnvironmentId @@ -229,6 +230,7 @@ export async function submitFolderWorkspaceCreate({ connectionId: workspace.connectionId ?? projectGroup.connectionId }) const fallbackActivation = activateAndRevealFolderWorkspace(workspace.id, { + agent: quickAgent, ...(startup ? { startup } : {}), runtimeEnvironmentId }) diff --git a/src/renderer/src/hooks/composer-state/full-creation-execution.ts b/src/renderer/src/hooks/composer-state/full-creation-execution.ts index 28961e355c6..1ad5a92abab 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-execution.ts @@ -219,6 +219,7 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) { const initialActivation = activateAndRevealWorktree(worktree.id, { sidebarRevealBehavior: 'auto', + agent: tuiAgent, setup: result.setup, defaultTabs: result.defaultTabs, issueCommand, @@ -229,6 +230,7 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) { const settlement = await settleFullCreationStructuredLaunch({ plan: launchPlan, + agent: tuiAgent, worktreeId: worktree.id, startup, pendingFirstAgentMessageRename, diff --git a/src/renderer/src/hooks/composer-state/full-creation-structured-launch.test.ts b/src/renderer/src/hooks/composer-state/full-creation-structured-launch.test.ts index 4a7b569bf79..d3808357ee8 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-structured-launch.test.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-structured-launch.test.ts @@ -36,6 +36,7 @@ const plan = (overrides: Partial = {}) => const baseArgs = { plan: plan(), + agent: 'codex' as const, worktreeId: 'worktree-1', startup: { command: 'codex' } as never, pendingFirstAgentMessageRename: true, @@ -94,6 +95,7 @@ describe('settleFullCreationStructuredLaunch', () => { }) expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('worktree-1', { sidebarRevealBehavior: 'auto', + agent: 'codex', createNewTerminalForStartup: true, startup: baseArgs.startup }) diff --git a/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts b/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts index c7e19026ba7..4293e97505a 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-structured-launch.ts @@ -3,12 +3,14 @@ import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload' 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' /** 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. */ export async function settleFullCreationStructuredLaunch(args: { /** Planned before the worktree existed; `worktreeId` names the one that was created. */ plan: AgentSessionLaunchPlan + agent: TuiAgent worktreeId: string startup: WorktreeStartupPayload | undefined pendingFirstAgentMessageRename: boolean @@ -27,6 +29,7 @@ export async function settleFullCreationStructuredLaunch(args: { } const activation = activateAndRevealWorktree(args.worktreeId, { sidebarRevealBehavior: 'auto', + agent: args.agent, createNewTerminalForStartup: true, ...(args.startup ? { startup: args.startup } : {}) }) diff --git a/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts b/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts index bf1664dc57c..bb3cf9f26a8 100644 --- a/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts +++ b/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts @@ -11,50 +11,114 @@ import { endWebRuntimeWakeTerminalRespawn } from '@/runtime/web-runtime-wake-terminal-respawn' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + draftViewModeProps, + resolveStartupLaunchDraftText, + type WorktreeStartupPayload +} from '@/lib/worktree-startup-payload' +import type { TuiAgent } from '../../../shared/tui-agent' +import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' +import { getConnectionId } from '@/lib/connection-context' +import { toast } from 'sonner' -export function ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId: string): void { - const state = useAppStore.getState() - const worktree = state.getKnownWorktreeById(worktreeId) - if (!worktree) { - return +export function ensureWebRuntimeWorktreeTerminalAfterWake( + worktreeId: string, + opts?: { + runtimeEnvironmentId?: string | null + startup?: WorktreeStartupPayload + agent?: TuiAgent | null + activate?: boolean } - const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktree.id) +): void { + const state = useAppStore.getState() + const runtimeEnvironmentId = + opts && 'runtimeEnvironmentId' in opts + ? (opts.runtimeEnvironmentId ?? null) + : getRuntimeEnvironmentIdForWorktree(state, worktreeId) if (!runtimeEnvironmentId || !isWebRuntimeSessionActive(runtimeEnvironmentId)) { return } const tabs = state.tabsByWorktree[worktreeId] ?? [] - const hasLivePty = tabs.some((tab) => tabHasLivePty(state.ptyIdsByTabId, tab.id)) - if (hasLivePty) { + const launchAgent = opts?.startup?.launchAgent ?? opts?.agent ?? undefined + if ( + launchAgent && + tabs.some( + (tab) => + tab.launchAgent === launchAgent && + (isWebTerminalSurfaceTabId(tab.id) || tabHasLivePty(state.ptyIdsByTabId, tab.id)) + ) + ) { return } - const hasMirroredHostTabs = tabs.some((tab) => isWebTerminalSurfaceTabId(tab.id)) - if (hasMirroredHostTabs) { - // Why: the host session still owns these tabs — wait for the mirror to repopulate PTY handles instead of duplicating a terminal. - return - } + if (!launchAgent) { + const hasLivePty = tabs.some((tab) => tabHasLivePty(state.ptyIdsByTabId, tab.id)) + if (hasLivePty) { + return + } - if (getLastKnownHostTerminalTabCount(runtimeEnvironmentId, worktreeId) > 0) { - return - } + const hasMirroredHostTabs = tabs.some((tab) => isWebTerminalSurfaceTabId(tab.id)) + if (hasMirroredHostTabs) { + // Why: the host session still owns these tabs — wait for the mirror to repopulate PTY handles instead of duplicating a terminal. + return + } - const { renderableTabCount } = state.reconcileWorktreeTabModel(worktreeId) - if (tabs.length > 0 && renderableTabCount === 0) { - return + if (getLastKnownHostTerminalTabCount(runtimeEnvironmentId, worktreeId) > 0) { + return + } + + const { renderableTabCount } = state.reconcileWorktreeTabModel(worktreeId) + if (tabs.length > 0 && renderableTabCount === 0) { + return + } } if (!beginWebRuntimeWakeTerminalRespawn(worktreeId)) { return } - // Why: sleep keeps tab rows but terminal.stop clears host PTYs, so a woke workspace can have tab chrome but no surface. + const startup = opts?.startup + const viewModeProps = launchAgent + ? initialAgentTabViewModeProps(state.settings, { + agent: launchAgent, + ...draftViewModeProps(resolveStartupLaunchDraftText(startup)), + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + getConnectionId(worktreeId) + ) + }) + : {} + // 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, - activate: true, + ...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 - }).finally(() => { - endWebRuntimeWakeTerminalRespawn(worktreeId) }) + .then((outcome) => { + if (outcome.status === 'failed') { + toast.error(outcome.message, { + id: `web-runtime-worktree-terminal:${runtimeEnvironmentId}:${worktreeId}` + }) + } + }) + .finally(() => { + endWebRuntimeWakeTerminalRespawn(worktreeId) + }) } diff --git a/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts b/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts index f9ebf55b0d2..ea4f3119bbc 100644 --- a/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts +++ b/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts @@ -32,6 +32,23 @@ function seedClosedLastTerminal(worktreeId: string): void { } describe('activating a 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) => { + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + + 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) + }) + it.each([true, false])( 'forwards providesInitialSurface=%s through the async activation gate', async (providesInitialSurface) => { @@ -234,6 +251,22 @@ function seedEmptiedFolderWorkspaceOnTwoHosts(): void { } 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() + + const result = activateAndRevealFolderWorkspace(FOLDER_ID, { + agent, + executionHostId: 'local' + }) + + expect(result).not.toBe(false) + expect(useAppStore.getState().activeWorktreeId).toBe(FOLDER_KEY) + expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toHaveLength(expectedTabCount) + }) + it.each([true, false])( 'forwards providesInitialSurface=%s through the async activation gate', async (providesInitialSurface) => { diff --git a/src/renderer/src/lib/worktree-activation-empty-remote.test.ts b/src/renderer/src/lib/worktree-activation-empty-remote.test.ts index 9326be93960..c16a8d6a448 100644 --- a/src/renderer/src/lib/worktree-activation-empty-remote.test.ts +++ b/src/renderer/src/lib/worktree-activation-empty-remote.test.ts @@ -6,6 +6,9 @@ import { resetWebRuntimeWakeTerminalRespawnForTests } from '@/runtime/web-runtim import { resetWebSessionTabsSnapshotFreshnessForTests } from '@/runtime/web-session-tabs-sync' import { useAppStore } from '@/store' import { ensureWebRuntimeWorktreeTerminalAfterWake } from './web-runtime-worktree-terminal-after-wake' +import { toast } from 'sonner' + +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) const initialAppStoreState = useAppStore.getState() const WORKTREE_PATH = path.join('workspace', 'feature') @@ -13,6 +16,7 @@ const REPO_PATH = path.join('workspace', 'repo') const ORCA_WORKSPACES_PATH = path.join('workspace', '.orca-workspaces') afterEach(() => { + vi.clearAllMocks() delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ vi.unstubAllGlobals() resetWebSessionTabsSnapshotFreshnessForTests() @@ -115,5 +119,55 @@ describe('empty remote worktree activation', () => { }) }) ) + expect(toast.error).not.toHaveBeenCalled() + }) + + it('surfaces a failed host terminal request without retrying ambiguously', async () => { + const worktree = makeWorktree() + const callRuntimeEnvironment = vi.fn().mockResolvedValueOnce({ + ok: false, + error: { code: 'terminal_create_failed', message: 'Host refused the terminal' } + }) + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: callRuntimeEnvironment, + subscribe: vi.fn() + } + } + }) + + useAppStore.setState({ + repos: [ + { + id: 'repo-1', + path: REPO_PATH, + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0 + } + ], + worktreesByRepo: { 'repo-1': [worktree] }, + tabsByWorktree: {}, + ptyIdsByTabId: {}, + settings: { + ...getDefaultSettings(ORCA_WORKSPACES_PATH), + activeRuntimeEnvironmentId: 'web-runtime-1' + }, + reconcileWorktreeTabModel: vi.fn(() => ({ + renderableTabCount: 0, + activeRenderableTabId: null + })) + }) + + ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id) + + await vi.waitFor(() => + expect(toast.error).toHaveBeenCalledWith('Host refused the terminal', { + id: `web-runtime-worktree-terminal:web-runtime-1:${worktree.id}` + }) + ) + expect(callRuntimeEnvironment).toHaveBeenCalledTimes(1) }) }) diff --git a/src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts b/src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts index ff2d5c802c1..4e7e02336ca 100644 --- a/src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts +++ b/src/renderer/src/lib/worktree-activation-surface-caller-wiring.test.ts @@ -23,7 +23,10 @@ const SURFACE_PROVIDING_CALLERS = [ ] // The activation seam itself: declares the option and forwards it into the tombstone gate. -const SEAM_FILES = ['src/renderer/src/lib/worktree-activation.ts'] +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) => { diff --git a/src/renderer/src/lib/worktree-activation-surface-selection.ts b/src/renderer/src/lib/worktree-activation-surface-selection.ts new file mode 100644 index 00000000000..ef4b5c1386a --- /dev/null +++ b/src/renderer/src/lib/worktree-activation-surface-selection.ts @@ -0,0 +1,39 @@ +import type { TuiAgent } from '../../../shared/tui-agent' +import type { + WorktreeDefaultTabsLaunch, + WorktreeSetupLaunch +} from '../../../shared/worktree/launch-types' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui' +import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload' +import type { IssueCommandLaunch } from '@/lib/worktree-setup-issue-command-queue' + +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 & { + startup?: WorktreeStartupPayload + initialCwd?: string + setup?: WorktreeSetupLaunch + defaultTabs?: WorktreeDefaultTabsLaunch + issueCommand?: IssueCommandLaunch + sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior'] + notifyHostRuntime?: boolean + revealInSidebar?: boolean + executionHostId?: ExecutionHostId + backendStartupTerminalSpawned?: boolean + /** Install a preserved fallback startup beside setup/default terminals already seeded. */ + createNewTerminalForStartup?: boolean + /** Keep sidebar filters intact when navigating to a hidden target. */ + clearSidebarFilters?: boolean +} + +export function activationProvidesInitialSurface( + selection?: WorktreeActivationSurfaceSelection +): boolean { + return selection?.providesInitialSurface === true || selection?.agent != null +} diff --git a/src/renderer/src/lib/worktree-activation-web-runtime.test.ts b/src/renderer/src/lib/worktree-activation-web-runtime.test.ts index 98a7679ae6d..2daee618eba 100644 --- a/src/renderer/src/lib/worktree-activation-web-runtime.test.ts +++ b/src/renderer/src/lib/worktree-activation-web-runtime.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { activateAndRevealFolderWorkspace, activateAndRevealWorktree } from './worktree-activation' import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding' import type { AppStoreState } from './worktree-activation-test-harness' import { @@ -6,9 +7,228 @@ import { registerWorktreeActivationReset } from './worktree-activation-test-harness' import { useAppStore } from '@/store' +import { + makeCreatedAgentWorktree, + seedEmptyActivatableWorktree +} from './worktree-activation-created-agent-test-state' +import { + resetWebRuntimeWakeTerminalRespawnForTests, + shouldSkipWebRuntimeWakeTerminalRespawn +} from '@/runtime/web-runtime-wake-terminal-respawn' registerWorktreeActivationReset() +afterEach(() => { + vi.unstubAllGlobals() + resetWebRuntimeWakeTerminalRespawnForTests() +}) + +describe('activateAndRevealWorktree', () => { + it('asks the paired host for the prepared agent terminal when backend startup did not spawn', async () => { + const worktree = { + ...makeCreatedAgentWorktree(), + hostId: 'local' as const, + runtimeOwnerEnvironmentId: 'web-runtime-1' + } + const callRuntimeEnvironment = vi.fn( + async (request: { method: string; params?: Record }) => + request.method === 'session.tabs.createTerminal' + ? { + ok: true, + result: { + tab: { id: 'host-agent-tab', leafId: 'host-agent-leaf' }, + publicationEpoch: 'epoch-1', + snapshotVersion: 1 + } + } + : { ok: false, error: { code: 'test', message: 'stop after recording the request' } } + ) + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: callRuntimeEnvironment } } + }) + seedEmptyActivatableWorktree(worktree) + const settings = useAppStore.getState().settings + useAppStore.setState({ + settings: settings + ? { ...settings, activeRuntimeEnvironmentId: 'web-runtime-1' } + : ({ activeRuntimeEnvironmentId: 'web-runtime-1' } as unknown as typeof settings) + }) + useAppStore.setState({ + tabsByWorktree: { + [worktree.id]: [ + { + id: 'stale-local-agent-tab', + ptyId: 'stale-local-agent-pty', + worktreeId: worktree.id, + title: 'Codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + launchAgent: 'codex' + } + ] + } + }) + + activateAndRevealWorktree(worktree.id, { + agent: 'codex', + startup: { + command: "codex 'fix the ownership race'", + env: { ORCA_AGENT_PROFILE: 'review' }, + launchAgent: 'codex', + launchToken: 'launch-1' + } + }) + await vi.waitFor(() => + expect(callRuntimeEnvironment).toHaveBeenCalledWith( + expect.objectContaining({ method: 'worktree.activate' }) + ) + ) + + const createRequests = callRuntimeEnvironment.mock.calls.filter( + ([request]) => request.method === 'session.tabs.createTerminal' + ) + expect(createRequests).toHaveLength(1) + expect(createRequests[0]?.[0]).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + command: "codex 'fix the ownership race'", + env: { ORCA_AGENT_PROFILE: 'review' }, + launchAgent: 'codex', + launchToken: 'launch-1' + }) + }) + ) + await vi.waitFor(() => expect(shouldSkipWebRuntimeWakeTerminalRespawn(worktree.id)).toBe(false)) + }) + + it('does not request another host terminal when backend startup already spawned', async () => { + const worktree = { + ...makeCreatedAgentWorktree(), + hostId: 'local' as const, + runtimeOwnerEnvironmentId: 'web-runtime-1' + } + const callRuntimeEnvironment = vi.fn().mockResolvedValue({ + ok: false, + error: { code: 'test', message: 'stop after recording the request' } + }) + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: callRuntimeEnvironment } } + }) + seedEmptyActivatableWorktree(worktree) + useAppStore.setState((state) => ({ + settings: state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: 'web-runtime-1' } + : ({ activeRuntimeEnvironmentId: 'web-runtime-1' } as unknown as typeof state.settings) + })) + + activateAndRevealWorktree(worktree.id, { + agent: 'codex', + backendStartupTerminalSpawned: true + }) + await vi.waitFor(() => + expect(callRuntimeEnvironment).toHaveBeenCalledWith( + expect.objectContaining({ method: 'worktree.activate' }) + ) + ) + + expect( + callRuntimeEnvironment.mock.calls.filter( + ([request]) => request.method === 'session.tabs.createTerminal' + ) + ).toHaveLength(0) + }) +}) + +describe('activateAndRevealFolderWorkspace', () => { + it.each([ + [ + 'the selected agent', + { + agent: 'codex' as const, + startup: { command: 'codex', launchAgent: 'codex' as const, launchToken: 'launch-1' } + }, + { command: 'codex', launchAgent: 'codex', launchToken: 'launch-1' } + ], + ['Blank Terminal', { agent: null }, { command: undefined }] + ])('asks the runtime owner for exactly one %s surface', async (_label, activation, expected) => { + const callRuntimeEnvironment = vi.fn( + async (request: { method: string; params?: Record }) => + request.method === 'session.tabs.createTerminal' + ? { + ok: true, + result: { + tab: { id: 'host-tab', leafId: 'host-leaf' }, + publicationEpoch: 'epoch-1', + snapshotVersion: 1 + } + } + : { ok: false, error: { code: 'test', message: 'stop after recording the request' } } + ) + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: callRuntimeEnvironment } } + }) + const settings = useAppStore.getState().settings + useAppStore.setState({ + activeView: 'terminal', + folderWorkspaces: [ + { + id: 'folder-1', + projectGroupId: 'group-1', + name: 'runtime folder', + folderPath: '/workspace/runtime-folder', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + executionHostId: 'runtime:web-runtime-1' + } + ], + getFreshFolderWorkspacePathStatus: vi.fn(() => ({ exists: true })), + tabsByWorktree: {}, + settings: settings + ? { ...settings, activeRuntimeEnvironmentId: 'web-runtime-1' } + : ({ activeRuntimeEnvironmentId: 'web-runtime-1' } as unknown as typeof settings), + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + revealWorktreeInSidebar: vi.fn() + } as unknown as Partial) + + activateAndRevealFolderWorkspace('folder-1', { + ...activation, + runtimeEnvironmentId: 'web-runtime-1' + }) + + await vi.waitFor(() => + expect( + callRuntimeEnvironment.mock.calls.filter( + ([request]) => request.method === 'session.tabs.createTerminal' + ) + ).toHaveLength(1) + ) + const createRequest = callRuntimeEnvironment.mock.calls.find( + ([request]) => request.method === 'session.tabs.createTerminal' + )?.[0] + expect(createRequest).toEqual( + expect.objectContaining({ + params: expect.objectContaining(expected) + }) + ) + if (activation.agent === null) { + expect(createRequest?.params).not.toHaveProperty('launchAgent') + } + await vi.waitFor(() => + expect(shouldSkipWebRuntimeWakeTerminalRespawn('folder:folder-1')).toBe(false) + ) + }) +}) + describe('ensureWorktreeHasInitialTerminal', () => { it('does not create a local fallback tab in the paired web runtime client', () => { ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index d2304c28b9d..a77a16682c8 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -1,8 +1,4 @@ import type { FolderWorkspace } from '../../../shared/folder-workspace-types' -import type { - WorktreeDefaultTabsLaunch, - WorktreeSetupLaunch -} from '../../../shared/worktree/launch-types' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui' @@ -29,13 +25,17 @@ 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 type { IssueCommandLaunch } from '@/lib/worktree-setup-issue-command-queue' import { ensureWorktreeHasInitialTerminal, reseedGatedEmptyWorkspace } 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 +} from './worktree-activation-surface-selection' /** * Shared activation sequence used by the worktree palette and add-repo/worktree dialogs. @@ -80,14 +80,12 @@ function canInspectAgentActivationInventory(): boolean { export function activateAndRevealFolderWorkspace( folderWorkspaceId: string, - opts?: { + opts?: WorktreeActivationSurfaceSelection & { sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior'] revealInSidebar?: boolean startup?: WorktreeStartupPayload runtimeEnvironmentId?: string | null executionHostId?: ExecutionHostId - /** See activateAndRevealWorktree — same contract for folder workspaces. */ - providesInitialSurface?: boolean } ): ActivateAndRevealResult | false { const state = useAppStore.getState() @@ -133,6 +131,7 @@ 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) @@ -151,17 +150,13 @@ export function activateAndRevealFolderWorkspace( if (shouldGateAgentActivation) { void gateWorktreeAgentActivation(workspaceKey).then((outcome) => { if (outcome === 'empty') { - reseedGatedEmptyWorkspace(workspaceKey, opts?.providesInitialSurface) + reseedGatedEmptyWorkspace(workspaceKey, providesInitialSurface) } }) } const primaryTabId = shouldGateAgentActivation ? null - : ensureFolderWorkspaceInitialTerminal( - folderWorkspace, - opts?.startup, - opts?.providesInitialSurface - ) + : ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts?.startup, providesInitialSurface) if (opts?.revealInSidebar !== false) { state.revealWorktreeInSidebar( @@ -170,33 +165,20 @@ export function activateAndRevealFolderWorkspace( ) } + if (opts?.providesInitialSurface !== true) { + ensureWebRuntimeWorktreeTerminalAfterWake(workspaceKey, { + runtimeEnvironmentId, + startup: opts?.startup, + agent: opts?.agent + }) + } + return { primaryTabId } } export function activateAndRevealWorktree( worktreeId: string, - opts?: { - startup?: WorktreeStartupPayload - initialCwd?: string - setup?: WorktreeSetupLaunch - defaultTabs?: WorktreeDefaultTabsLaunch - issueCommand?: IssueCommandLaunch - sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior'] - notifyHostRuntime?: boolean - revealInSidebar?: boolean - executionHostId?: ExecutionHostId - backendStartupTerminalSpawned?: boolean - /** Install a preserved fallback startup beside setup/default terminals already seeded. */ - createNewTerminalForStartup?: boolean - /** Set by callers that navigate here only to open their own non-terminal surface - * (an editor file, a diff). Activation then leaves a closed-last-terminal workspace - * empty instead of adding a shell the user never asked for. Caveat: on a - * runtime-owned workspace with a live web session the host owns terminal creation, - * so ensureWebRuntimeWorktreeTerminalAfterWake may still seed one (matches main). */ - providesInitialSurface?: boolean - /** Keep sidebar filters intact when navigating to a hidden target. */ - clearSidebarFilters?: boolean - } + opts?: WorktreeActivationOptions ): ActivateAndRevealResult | false { const state = useAppStore.getState() const wt = state.getKnownWorktreeById(worktreeId, opts?.executionHostId) @@ -206,6 +188,7 @@ export function activateAndRevealWorktree( const hasActivationWork = Boolean( opts?.startup || opts?.setup || opts?.defaultTabs || opts?.issueCommand ) + const providesInitialSurface = activationProvidesInitialSurface(opts) // Why: a plain reselect should still reveal the sidebar row but must not restamp focus recency or wake persistence. const isPlainAlreadyActiveTerminal = !hasActivationWork && @@ -266,7 +249,7 @@ export function activateAndRevealWorktree( if (shouldGateAgentActivation) { void gateWorktreeAgentActivation(worktreeId).then((outcome) => { if (outcome === 'empty') { - reseedGatedEmptyWorkspace(worktreeId, opts?.providesInitialSurface) + reseedGatedEmptyWorkspace(worktreeId, providesInitialSurface) } }) } @@ -274,7 +257,7 @@ export function activateAndRevealWorktree( // 4. Ensure a focusable surface exists for externally-created worktrees const primaryTabId = shouldGateAgentActivation ? null - : opts?.providesInitialSurface === true && !hasActivationWork + : providesInitialSurface && !hasActivationWork ? null : ensureWorktreeHasInitialTerminal( useAppStore.getState(), @@ -286,8 +269,8 @@ export function activateAndRevealWorktree( { ...(opts?.backendStartupTerminalSpawned ? { backendStartupTerminalSpawned: true } : {}), ...(opts?.createNewTerminalForStartup ? { createNewTerminalForStartup: true } : {}), - ...(opts?.providesInitialSurface === true ? { callerProvidesSurface: true } : {}), - reseedEmptiedWorkspace: opts?.providesInitialSurface !== true + ...(providesInitialSurface ? { callerProvidesSurface: true } : {}), + reseedEmptiedWorkspace: !providesInitialSurface } ) if (primaryTabId && opts?.initialCwd) { @@ -325,8 +308,15 @@ export function activateAndRevealWorktree( } } - if (opts?.notifyHostRuntime !== false && !opts?.backendStartupTerminalSpawned) { - ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId) + if ( + opts?.notifyHostRuntime !== false && + !opts?.backendStartupTerminalSpawned && + opts?.providesInitialSurface !== true + ) { + ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId, { + startup: opts?.startup, + agent: opts?.agent + }) } return { primaryTabId } @@ -340,9 +330,8 @@ export function activateAndRevealWorktree( */ export function activateAndRevealWorkspace( workspaceId: string, - opts?: { + opts?: WorktreeActivationSurfaceSelection & { executionHostId?: ExecutionHostId - providesInitialSurface?: boolean revealInSidebar?: boolean /** Worktree-only: folder workspaces are never filter-hidden. */ clearSidebarFilters?: boolean diff --git a/src/renderer/src/lib/worktree-creation-agent-seeding.test.ts b/src/renderer/src/lib/worktree-creation-agent-seeding.test.ts new file mode 100644 index 00000000000..601ad837843 --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-agent-seeding.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorktreeCreationRequest } from './pending-worktree-creation' + +const mocks = vi.hoisted(() => ({ + activateAndRevealWorktree: vi.fn(), + completeWorktreeCreation: vi.fn(), + ensureWorktreeHasInitialTerminal: vi.fn(), + ensureWebRuntimeWorktreeTerminalAfterWake: vi.fn() +})) + +const store = { + activePendingCreationId: null as string | null, + activeView: 'tasks' as 'tasks' | 'terminal', + createWorktree: vi.fn(), + pendingWorktreeCreations: {} as Record, + repos: [] +} + +vi.mock('@/store', () => ({ useAppStore: { getState: () => store } })) +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: mocks.activateAndRevealWorktree +})) +vi.mock('@/lib/worktree-initial-terminal-seeding', () => ({ + ensureWorktreeHasInitialTerminal: mocks.ensureWorktreeHasInitialTerminal +})) +vi.mock('@/lib/worktree-creation-completion', () => ({ + completeWorktreeCreation: mocks.completeWorktreeCreation +})) +vi.mock('@/lib/web-runtime-worktree-terminal-after-wake', () => ({ + ensureWebRuntimeWorktreeTerminalAfterWake: mocks.ensureWebRuntimeWorktreeTerminalAfterWake +})) + +import { executeWorktreeCreation } from './worktree-creation-flow-execute' + +const request: WorktreeCreationRequest = { + repoId: 'repo-1', + name: 'feature', + setupDecision: 'inherit', + agent: 'codex', + agentLaunchRoute: 'terminal-tui', + pendingFirstAgentMessageRename: false, + note: '', + startupPlan: null, + quickPrompt: '', + quickTelemetry: null +} + +describe('executeWorktreeCreation agent seeding', () => { + beforeEach(() => { + vi.clearAllMocks() + store.activePendingCreationId = null + store.activeView = 'tasks' + store.pendingWorktreeCreations = { 'creation-1': { creationId: 'creation-1' } } + store.createWorktree.mockResolvedValue({ + worktree: { id: 'worktree-1', repoId: request.repoId } + }) + }) + + it('routes a background agent selection through host-aware surface creation', async () => { + await executeWorktreeCreation('creation-1', request) + + expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(mocks.ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() + expect(mocks.ensureWebRuntimeWorktreeTerminalAfterWake).toHaveBeenCalledOnce() + expect(mocks.ensureWebRuntimeWorktreeTerminalAfterWake).toHaveBeenCalledWith('worktree-1', { + startup: undefined, + agent: 'codex', + activate: false + }) + expect(mocks.completeWorktreeCreation).toHaveBeenCalledWith( + expect.objectContaining({ primaryTabId: null }) + ) + }) + + it('passes the agent selection through an active reveal', async () => { + store.activePendingCreationId = 'creation-1' + store.activeView = 'terminal' + mocks.activateAndRevealWorktree.mockReturnValue({ primaryTabId: null }) + + await executeWorktreeCreation('creation-1', request) + + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith( + 'worktree-1', + expect.objectContaining({ agent: 'codex' }) + ) + }) +}) diff --git a/src/renderer/src/lib/worktree-creation-flow-execute.ts b/src/renderer/src/lib/worktree-creation-flow-execute.ts index 273b3ece1a8..839f454eef5 100644 --- a/src/renderer/src/lib/worktree-creation-flow-execute.ts +++ b/src/renderer/src/lib/worktree-creation-flow-execute.ts @@ -22,6 +22,7 @@ import { buildWorktreeCreationStartupOpt } from '@/lib/worktree-creation-flow-st import { launchStructuredWorktreeSession } from '@/lib/worktree-creation-structured-session' 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' // Why: activePendingCreationId can outlive the terminal route when the user // switches app views; only the terminal route renders the creation panel. @@ -168,6 +169,7 @@ export async function executeWorktreeCreation( if (shouldActivateOnCompletion && !structuredLaunch) { activation = activateAndRevealWorktree(worktree.id, { sidebarRevealBehavior: 'auto', + ...(preparedRequest.agent !== null ? { agent: preparedRequest.agent } : {}), ...(result.setup ? { setup: result.setup } : {}), ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), ...(startupOpt ? { startup: startupOpt } : {}), @@ -181,7 +183,7 @@ export async function executeWorktreeCreation( startupOpt || result.setup || preparedRequest.issueCommand || result.defaultTabs ) primaryTabId = - structuredLaunch && !hasExplicitTerminalWork + preparedRequest.agent !== null && !hasExplicitTerminalWork ? null : ensureWorktreeHasInitialTerminal( useAppStore.getState(), @@ -192,10 +194,17 @@ export async function executeWorktreeCreation( result.defaultTabs, { activateCreatedTabs: false, - ...(structuredLaunch ? { callerProvidesSurface: true } : {}), + ...(preparedRequest.agent !== null ? { callerProvidesSurface: true } : {}), ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}) } ) + if (!structuredLaunch && !backendSpawned) { + ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id, { + startup: startupOpt, + agent: preparedRequest.agent, + activate: false + }) + } } let structuredLaunchAccepted = structuredLaunch diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index 84ac3a61b32..a599a265513 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -606,7 +606,7 @@ describe('staged background worktree creation', () => { delete store.pendingWorktreeCreations['creation-1'] store.activePendingCreationId = null resolveTrust() - await vi.waitFor(() => expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(store.removePendingWorktreeCreation).toHaveBeenCalled()) expect(activateAndRevealWorktree).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/lib/worktree-creation-structured-session.test.ts b/src/renderer/src/lib/worktree-creation-structured-session.test.ts index b8e1d609ea8..f072c17d417 100644 --- a/src/renderer/src/lib/worktree-creation-structured-session.test.ts +++ b/src/renderer/src/lib/worktree-creation-structured-session.test.ts @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ activateStructuredAgentSessionById: vi.fn(), activateAndRevealWorktree: vi.fn(), ensureWorktreeHasInitialTerminal: vi.fn(), + ensureWebRuntimeWorktreeTerminalAfterWake: vi.fn(), preflightAgentTrust: vi.fn(), updateWorktreeMeta: vi.fn() })) @@ -54,6 +55,10 @@ vi.mock('@/lib/worktree-initial-terminal-seeding', () => ({ ensureWorktreeHasInitialTerminal: mocks.ensureWorktreeHasInitialTerminal })) +vi.mock('@/lib/web-runtime-worktree-terminal-after-wake', () => ({ + ensureWebRuntimeWorktreeTerminalAfterWake: mocks.ensureWebRuntimeWorktreeTerminalAfterWake +})) + vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: mocks.activateAndRevealWorktree })) @@ -351,6 +356,11 @@ describe('launchStructuredWorktreeSession', () => { undefined, { activateCreatedTabs: false, createNewTerminalForStartup: true } ) + expect(mocks.ensureWebRuntimeWorktreeTerminalAfterWake).toHaveBeenCalledWith('worktree-1', { + startup: undefined, + agent: 'codex', + activate: false + }) }) it('stops the fallback mid-way when the creation is dismissed and retires nothing', async () => { diff --git a/src/renderer/src/lib/worktree-creation-structured-session.ts b/src/renderer/src/lib/worktree-creation-structured-session.ts index ee7fd594502..df86f56efed 100644 --- a/src/renderer/src/lib/worktree-creation-structured-session.ts +++ b/src/renderer/src/lib/worktree-creation-structured-session.ts @@ -12,6 +12,7 @@ import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload' import { closeStructuredAgentSession } from '@/runtime/structured-agent-session-close' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake' export type WorktreeCreationStructuredSessionResult = { accepted: boolean @@ -92,17 +93,21 @@ async function openLegacyWorktreeSurface( }) return { activation, primaryTabId: activation === false ? null : activation.primaryTabId } } - return { - primaryTabId: ensureWorktreeHasInitialTerminal( - useAppStore.getState(), - args.worktreeId, - args.fallbackStartupOpt, - undefined, - undefined, - undefined, - { activateCreatedTabs: false, createNewTerminalForStartup: true } - ) - } + const primaryTabId = ensureWorktreeHasInitialTerminal( + useAppStore.getState(), + args.worktreeId, + args.fallbackStartupOpt, + undefined, + undefined, + undefined, + { activateCreatedTabs: false, createNewTerminalForStartup: true } + ) + ensureWebRuntimeWorktreeTerminalAfterWake(args.worktreeId, { + startup: args.fallbackStartupOpt, + agent: args.request.agent, + activate: false + }) + return { primaryTabId } } export async function launchStructuredWorktreeSession( diff --git a/src/renderer/src/runtime/web-runtime-session-types.ts b/src/renderer/src/runtime/web-runtime-session-types.ts index 289562674cb..8664c9192ce 100644 --- a/src/renderer/src/runtime/web-runtime-session-types.ts +++ b/src/renderer/src/runtime/web-runtime-session-types.ts @@ -28,6 +28,8 @@ export type CreateWebRuntimeSessionTerminalArgs = { launchToken?: string agent?: TuiAgent launchAgent?: TuiAgent + /** The command already encodes the complete agent startup and prompt-delivery plan. */ + preparedAgentCommand?: boolean agentSessionKind?: 'fresh' | 'resume' prompt?: string promptDelivery?: AgentPromptDelivery diff --git a/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts b/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts index 601888e5f9b..d14adc5d889 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts @@ -88,7 +88,9 @@ export async function createWebRuntimeSessionTerminalResult( let legacyAlreadyPlacedInGroup = false // Why: structured creation cannot yet express afterTabId; keep the exact legacy placement contract until it can. // Why: focus belongs to the paired client; a headless execution host has no renderer to focus. - const hostAuthority = args.afterTabId + // Why: rebuilding a prepared command through host authority can discard its embedded prompt and delivery flags. + const mustUseLegacyAgentCreate = args.preparedAgentCommand || args.afterTabId + const hostAuthority = mustUseLegacyAgentCreate ? undefined : args.agentSessionKind === 'resume' ? args.providerSession diff --git a/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts b/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts index 71475ff2e79..83789643f1f 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts @@ -141,13 +141,16 @@ export function installActiveSessionTabsSubscription({ const hasLiveLocalPty = localTabs.some( (tab) => (syncState.ptyIdsByTabId[tab.id] ?? []).length > 0 ) - const bootstrap = shouldBootstrapInitialWebRuntimeTerminal({ - event: recoveredEvent, - activeWorktreeId, - requestedInitialTerminal, - snapshotIsFresh: decision.apply, - localTerminalCount - }) + const skipAutomaticTerminal = shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId) + const bootstrap = + !skipAutomaticTerminal && + shouldBootstrapInitialWebRuntimeTerminal({ + event: recoveredEvent, + activeWorktreeId, + requestedInitialTerminal, + snapshotIsFresh: decision.apply, + localTerminalCount + }) const respawn = shouldRespawnWebRuntimeTerminalAfterWake({ event: recoveredEvent, activeWorktreeId, @@ -155,7 +158,7 @@ export function installActiveSessionTabsSubscription({ snapshotIsFresh: decision.apply, localTerminalCount, hasLiveLocalPty, - skipWakeRespawn: shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId) + skipWakeRespawn: skipAutomaticTerminal }) let settle: HostSessionMirrorSettle | null = decision.apply ? null