fix(workspaces): seed shells only for blank selection

This commit is contained in:
Merge Sim
2026-09-10 15:35:52 -07:00
parent fb85f88d64
commit 4f01f05eb5
17 changed files with 288 additions and 31 deletions
@@ -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) {
@@ -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<typeof startRuntimeLocalWorktreeTerminals>[0]
function createPorts() {
const createTerminal = vi.fn<StartupArgs['ports']['createTerminal']>().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 })
}
})
})
@@ -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) {
@@ -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) {
@@ -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
})
})
@@ -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
})
@@ -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,
@@ -36,6 +36,7 @@ const plan = (overrides: Partial<AgentSessionLaunchVerdict> = {}) =>
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
})
@@ -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 } : {})
})
@@ -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) => {
@@ -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) => {
@@ -0,0 +1,14 @@
import type { TuiAgent } from '../../../shared/tui-agent'
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 function activationProvidesInitialSurface(
selection?: WorktreeActivationSurfaceSelection
): boolean {
return selection?.providesInitialSurface === true || selection?.agent != null
}
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { activateAndRevealWorktree } from './worktree-activation'
import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding'
import type { AppStoreState } from './worktree-activation-test-harness'
import {
@@ -6,9 +7,54 @@ import {
registerWorktreeActivationReset
} from './worktree-activation-test-harness'
import { useAppStore } from '@/store'
import {
makeCreatedAgentWorktree,
seedEmptyActivatableWorktree
} from './worktree-activation-created-agent-test-state'
import { resetWebRuntimeWakeTerminalRespawnForTests } from '@/runtime/web-runtime-wake-terminal-respawn'
registerWorktreeActivationReset()
afterEach(() => {
vi.unstubAllGlobals()
resetWebRuntimeWakeTerminalRespawnForTests()
})
describe('activateAndRevealWorktree', () => {
it('does not ask a paired host to seed a shell for an agent selection', 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' })
await vi.waitFor(() =>
expect(callRuntimeEnvironment).toHaveBeenCalledWith(
expect.objectContaining({ method: 'worktree.activate' })
)
)
expect(callRuntimeEnvironment).not.toHaveBeenCalledWith(
expect.objectContaining({ method: 'session.tabs.createTerminal' })
)
})
})
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
+20 -23
View File
@@ -36,6 +36,10 @@ import {
} 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 WorktreeActivationSurfaceSelection
} from './worktree-activation-surface-selection'
/**
* Shared activation sequence used by the worktree palette and add-repo/worktree dialogs.
@@ -80,14 +84,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 +135,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 +154,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(
@@ -175,7 +174,7 @@ export function activateAndRevealFolderWorkspace(
export function activateAndRevealWorktree(
worktreeId: string,
opts?: {
opts?: WorktreeActivationSurfaceSelection & {
startup?: WorktreeStartupPayload
initialCwd?: string
setup?: WorktreeSetupLaunch
@@ -188,12 +187,6 @@ export function activateAndRevealWorktree(
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
}
@@ -206,6 +199,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 +260,7 @@ export function activateAndRevealWorktree(
if (shouldGateAgentActivation) {
void gateWorktreeAgentActivation(worktreeId).then((outcome) => {
if (outcome === 'empty') {
reseedGatedEmptyWorkspace(worktreeId, opts?.providesInitialSurface)
reseedGatedEmptyWorkspace(worktreeId, providesInitialSurface)
}
})
}
@@ -274,7 +268,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 +280,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,7 +319,11 @@ export function activateAndRevealWorktree(
}
}
if (opts?.notifyHostRuntime !== false && !opts?.backendStartupTerminalSpawned) {
if (
opts?.notifyHostRuntime !== false &&
!opts?.backendStartupTerminalSpawned &&
opts?.agent == null
) {
ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId)
}
@@ -340,9 +338,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
@@ -0,0 +1,77 @@
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()
}))
const store = {
activePendingCreationId: null as string | null,
activeView: 'tasks' as 'tasks' | 'terminal',
createWorktree: vi.fn(),
pendingWorktreeCreations: {} as Record<string, unknown>,
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
}))
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('does not seed a background shell when the request carries an agent selection', async () => {
await executeWorktreeCreation('creation-1', request)
expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled()
expect(mocks.ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled()
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' })
)
})
})
@@ -168,6 +168,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 +182,7 @@ export async function executeWorktreeCreation(
startupOpt || result.setup || preparedRequest.issueCommand || result.defaultTabs
)
primaryTabId =
structuredLaunch && !hasExplicitTerminalWork
preparedRequest.agent !== null && !hasExplicitTerminalWork
? null
: ensureWorktreeHasInitialTerminal(
useAppStore.getState(),
@@ -192,7 +193,7 @@ export async function executeWorktreeCreation(
result.defaultTabs,
{
activateCreatedTabs: false,
...(structuredLaunch ? { callerProvidesSurface: true } : {}),
...(preparedRequest.agent !== null ? { callerProvidesSurface: true } : {}),
...(backendSpawned ? { backendStartupTerminalSpawned: true } : {})
}
)
@@ -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()
})