mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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 <sim@local>
This commit is contained in:
co-authored by
Merge Sim
parent
d33354cfd2
commit
2ffac471bd
@@ -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 } : {})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -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<string, unknown> }) =>
|
||||
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<string, unknown> }) =>
|
||||
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<AppStoreState>)
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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
|
||||
}))
|
||||
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' })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user