refactor(automations): main creates an automation workspace's setup and default tabs (#28130)

* refactor(automations): main provisions an automation workspace's setup and default tabs

An automation's create handed setup and default tabs back to the window, which
launched them through its own background-terminal code. Main now provisions
them through the same provisionWorktreeTerminals the CLI create uses, and
returns neither, so setup runs once and the window's copy is deleted.

* fix(worktree): translate the Setup tab title wherever it is created

Main had no copy of the automation path's translated title, and the window and
CLI creates used the English literal. All three now read worktree.setupTabTitle
through the existing main and renderer translators. The create path's runtime is
required, since every production caller passes it.

* fix(worktree): keep the Setup title in English; runtime code cannot load main's translator

main-i18n imports Electron, which orcad and the runtime must not load. The
title stays the English literal the window and CLI creates already use.

* refactor(worktree): drop the runtime check the required runtime made dead
This commit is contained in:
Jinwoo Hong
2026-10-13 01:07:23 -04:00
committed by GitHub
parent e713bcbb82
commit ef890ae2bd
16 changed files with 214 additions and 958 deletions
-1
View File
@@ -333,7 +333,6 @@
2 src/renderer/src/lib/launch-agent-web-host-tab.ts
2 src/renderer/src/lib/launch-ai-vault-session.ts
1 src/renderer/src/lib/launch-structured-agent-session.ts
1 src/renderer/src/lib/launch-worktree-background-terminals.ts
1 src/renderer/src/lib/linear-issue-url-lookup.ts
1 src/renderer/src/lib/local-preflight-context.ts
3 src/renderer/src/lib/open-agent-message-sender.ts
-1
View File
@@ -9384,7 +9384,6 @@
"src/renderer/src/lib/launch-work-item-direct-agent.test.ts": 163,
"src/renderer/src/lib/launch-work-item-direct-messages.test.ts": 48,
"src/renderer/src/lib/launch-work-item-direct.test.ts": 1033,
"src/renderer/src/lib/launch-worktree-background-terminals.test.ts": 848,
"src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts": 200,
"src/renderer/src/lib/lazy-with-retry.never-landed-reload.test.ts": 172,
"src/renderer/src/lib/lazy-with-retry.test.ts": 232,
+32 -10
View File
@@ -306,7 +306,7 @@ export function recordWorkspaceLineageForCreatedWorktree(
}
async function spawnLocalStartupAndSetupTerminals(args: {
runtime: OrcaRuntimeService | undefined
runtime: OrcaRuntimeService
worktree: Worktree
startup: CreateWorktreeArgs['startup']
setup: CreateWorktreeResult['setup']
@@ -315,7 +315,7 @@ async function spawnLocalStartupAndSetupTerminals(args: {
createdWithAgent: CreateWorktreeArgs['createdWithAgent']
}): Promise<StagedStartupResult> {
const { runtime, worktree, startup, setup, defaultTabs, settings, createdWithAgent } = args
if (!runtime || !startup || defaultTabs?.tabs.length) {
if (!startup || defaultTabs?.tabs.length) {
return { didSpawnSetup: false }
}
@@ -953,7 +953,7 @@ export function createLocalWorktree(
repo: Repo,
store: Store,
mainWindow: BrowserWindow,
runtime?: OrcaRuntimeService,
runtime: OrcaRuntimeService,
timing: WorktreeCreateTimingRecorder = createWorktreeCreateTimingRecorder()
): Promise<CreateWorktreeResult> {
// Why a holder fired in `finally`: consuming a prepared checkout leaves the pool one short, so a
@@ -976,7 +976,7 @@ async function performLocalWorktreeCreate(
mainWindow: BrowserWindow,
rearm: PreparationRearmHolder,
timing: WorktreeCreateTimingRecorder,
runtime?: OrcaRuntimeService
runtime: OrcaRuntimeService
): Promise<CreateWorktreeResult> {
const settings = store.getSettings()
const worktreePathSettings = getWorktreePathSettings(
@@ -1691,6 +1691,24 @@ async function performLocalWorktreeCreate(
})
)
// Why: an automation's terminals start in main, so the window is handed none to launch.
const provisionedByHost = Boolean(args.automationProvenance)
if (provisionedByHost && (setup || defaultTabs?.tabs.length)) {
void runtime.provisionManagedWorktreeTerminals(
{
worktreeSelector: `id:${worktree.id}`,
worktreeId: worktree.id,
worktreePath: worktree.path,
...(setup ? { setup } : {}),
...(defaultTabs ? { defaultTabs } : {}),
hasStartupTerminal: false,
setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(setup, 'posix'),
surfaceOwner: false
},
worktree
)
}
notifyWorktreesChanged(mainWindow, repo.id)
return {
worktree: {
@@ -1702,12 +1720,16 @@ async function performLocalWorktreeCreate(
},
...(worktreeLineage ? { lineage: worktreeLineage } : {}),
...(workspaceLineage ? { workspaceLineage } : {}),
...(stagedStartup.activationSetup
? { setup: stagedStartup.activationSetup }
: setup && !stagedStartup.didSpawnSetup
? { setup }
: {}),
...(defaultTabs ? { defaultTabs } : {}),
...(provisionedByHost
? {}
: {
...(stagedStartup.activationSetup
? { setup: stagedStartup.activationSetup }
: setup && !stagedStartup.didSpawnSetup
? { setup }
: {}),
...(defaultTabs ? { defaultTabs } : {})
}),
...(addResult.localBaseRefRefresh
? { localBaseRefRefresh: addResult.localBaseRefRefresh }
: {}),
@@ -0,0 +1,180 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
listWorktreesMock,
getEffectiveHooksMock,
getDefaultTabsLaunchMock,
createSetupRunnerScriptMock,
shouldRunSetupForCreateMock
} from './worktrees-test-module-mocks'
import { handlers, setupWorktreeHandlers } from './worktrees-test-harness'
import { createdWorktreeList } from './worktrees-test-fixtures'
vi.mock('electron', async () =>
(await import('./worktrees-test-module-mocks')).electronModuleMock()
)
vi.mock('../git/worktree', async () =>
(await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock()
)
vi.mock('../git/runner', async () =>
(await import('./worktrees-test-module-mocks')).gitRunnerModuleMock()
)
vi.mock('../git/repo', async () =>
(await import('./worktrees-test-module-mocks')).gitRepoModuleMock()
)
vi.mock('../git/git-username', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
resolveLocalGitUsername: (await import('./worktrees-test-module-mocks'))
.resolveLocalGitUsernameMock
}))
vi.mock('../github/client', async () =>
(await import('./worktrees-test-module-mocks')).githubClientModuleMock()
)
vi.mock('../source-control/hosted-review', async () =>
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
)
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
)
vi.mock('./worktree-symlinks', async () =>
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
)
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
vi.mock('../setup-runner-script-text', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
await importOriginal<Record<string, unknown>>()
)
)
vi.mock('../worktree-runner-script', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock(
await importOriginal<Record<string, unknown>>()
)
)
vi.mock('../effective-hook-config', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock(
await importOriginal<Record<string, unknown>>()
)
)
vi.mock('../setup-hook-env-vars', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock(
await importOriginal<Record<string, unknown>>()
)
)
vi.mock('./worktree-logic', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock(
await importOriginal<Record<string, unknown>>()
)
)
vi.mock('../terminal-history-deletion', async () =>
(await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock()
)
vi.mock('../ports/advertised-url-watcher', async () =>
(await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock()
)
vi.mock('../workspace-cleanup-scan-snapshot', async () =>
(await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock()
)
vi.mock('../workspace-space-analysis-snapshot', async () =>
(await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock()
)
vi.mock('../workspace-cleanup-removal-snapshot-prune', async () =>
(await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock()
)
vi.mock('../runtime/worktree-teardown', async () =>
(await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock()
)
vi.mock('./pty/provider/registry', async () =>
(await import('./worktrees-test-module-mocks')).ptyRegistryModuleMock()
)
vi.mock('./pty/provider/state-cleanup', async () =>
(await import('./worktrees-test-module-mocks')).ptyStateCleanupModuleMock()
)
vi.mock('../automations/workspace-provenance', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
resolveAutomationWorkspaceProvenance: ({ request }: { request?: unknown }) =>
request ? { kind: 'created-by-automation', automationId: 'automation-1' } : undefined,
finishAutomationWorkspaceProvenanceRequest: () => {},
releaseAutomationWorkspaceProvenanceRequest: () => {}
}))
const automationProvenanceRequest = {
automationId: 'automation-1',
automationRunId: 'run-1',
dispatchToken: 'token-1',
createRequestId: 'create-1'
}
const defaultTabs = { tabs: [{ title: 'Dev', command: 'pnpm dev' }], runCommands: true }
describe('automation creates: main provisions setup and default tabs', () => {
let provision: ReturnType<typeof vi.fn>
beforeEach(() => {
const runtime = setupWorktreeHandlers()
provision = vi.fn().mockResolvedValue({ setupSpawned: true, setupTerminalHandle: 'term-setup' })
Object.assign(runtime, { provisionManagedWorktreeTerminals: provision })
listWorktreesMock.mockResolvedValue(createdWorktreeList)
getEffectiveHooksMock.mockReturnValue({ scripts: { setup: 'pnpm worktree:setup' } })
shouldRunSetupForCreateMock.mockReturnValue(true)
getDefaultTabsLaunchMock.mockReturnValue(defaultTabs)
})
it('provisions once in main and hands the window nothing to launch', async () => {
const result = await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard',
setupDecision: 'run',
automationProvenanceRequest
})
expect(result).not.toHaveProperty('setup')
expect(result).not.toHaveProperty('defaultTabs')
expect(provision).toHaveBeenCalledOnce()
expect(provision).toHaveBeenCalledWith(
expect.objectContaining({
worktreePath: '/workspace/improve-dashboard',
setup: expect.objectContaining({
runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh'
}),
defaultTabs,
hasStartupTerminal: false,
setupCommandPlatform: 'posix',
surfaceOwner: false
}),
expect.objectContaining({ path: '/workspace/improve-dashboard' })
)
})
// Same pick the window made: the runner's path decides, a bare path falls back to posix.
it.each([
{ runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.cmd', platform: 'windows' },
{ runnerScriptPath: '/home/me/repo/.git/orca/setup-runner.sh', platform: 'posix' }
])(
'runs a $platform runner with the $platform command',
async ({ runnerScriptPath, platform }) => {
createSetupRunnerScriptMock.mockReturnValueOnce({ runnerScriptPath, envVars: {} })
await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard',
setupDecision: 'run',
automationProvenanceRequest
})
expect(provision).toHaveBeenCalledWith(
expect.objectContaining({ setupCommandPlatform: platform }),
expect.anything()
)
}
)
it('leaves a create from the app window to the window', async () => {
const result = await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard',
setupDecision: 'run'
})
expect(result).toMatchObject({ setup: expect.anything(), defaultTabs })
expect(provision).not.toHaveBeenCalled()
})
})
@@ -312,7 +312,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM
return deliverWorktreeStartupFollowup(this.getWorktreeStartupReadinessHost(), handle, followup)
}
protected async provisionManagedWorktreeTerminals(
async provisionManagedWorktreeTerminals(
args: {
worktreeSelector: string
worktreeId: string
@@ -1,4 +1,3 @@
import { launchWorktreeBackgroundTerminals } from '@/lib/launch-worktree-background-terminals'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { createBrowserUuid } from '@/lib/browser-uuid'
@@ -244,17 +243,6 @@ export async function prepareAutomationDispatchWorkspace(args: {
}
context.workspaceId = worktree.id
context.workspaceDisplayName = worktree.displayName
if (createResult?.setup || createResult?.defaultTabs) {
void launchWorktreeBackgroundTerminals({
worktreeId: worktree.id,
setup: createResult.setup,
defaultTabs: createResult.defaultTabs
}).catch((error) => {
// Why: setup/defaultTabs match normal worktree creation: they are
// best-effort terminal work and must not block the automation agent.
console.warn('[automations] Failed to launch workspace setup/default tabs:', error)
})
}
return worktree
}
@@ -6,7 +6,6 @@ import { countUnchangedObserverHistoryReads } from './automation-dispatch-observ
const mockDispatchEvent = vi.fn()
const mockLaunchAgentBackgroundSession = vi.fn()
const mockLaunchWorktreeBackgroundTerminals = vi.fn()
const mockFindReusableAutomationSession = vi.fn()
const mockObserveExistingAutomationSession = vi.fn()
const mockSubmitPromptToAgentPty = vi.fn()
@@ -25,11 +24,6 @@ const mockStoreSubscribe = vi.fn((listener: () => void) => {
return () => {}
})
const setupLaunch = {
runnerScriptPath: '/tmp/setup.sh',
envVars: { ORCA_WORKTREE_PATH: '/repo/worktree' }
}
const createdWorktree = {
id: 'wt-created',
repoId: 'repo-1',
@@ -130,10 +124,6 @@ vi.mock('@/lib/launch-agent-background-session', () => ({
launchAgentBackgroundSession: mockLaunchAgentBackgroundSession
}))
vi.mock('@/lib/launch-worktree-background-terminals', () => ({
launchWorktreeBackgroundTerminals: mockLaunchWorktreeBackgroundTerminals
}))
vi.mock('@/lib/agent-paste-draft', () => ({
submitPromptToAgentPty: mockSubmitPromptToAgentPty
}))
@@ -202,8 +192,7 @@ describe('useAutomationDispatchEvents setup launch', () => {
latestStoreSubscriber = null
state.allWorktrees.mockReturnValue([])
state.getKnownWorktreeById.mockReturnValue(undefined)
mockCreateWorktree.mockResolvedValue({ worktree: createdWorktree, setup: setupLaunch })
mockLaunchWorktreeBackgroundTerminals.mockResolvedValue(undefined)
mockCreateWorktree.mockResolvedValue({ worktree: createdWorktree })
mockLaunchAgentBackgroundSession.mockResolvedValue({
tabId: 'agent-tab',
paneKey: 'agent-tab:7c6fb4e5-3bf1-4ff4-8259-03f7ae81c40d',
@@ -248,84 +237,6 @@ describe('useAutomationDispatchEvents setup launch', () => {
).toEqual([])
})
it('starts setup terminal launch without waiting before launching the automation agent', async () => {
const order: string[] = []
let finishSetupLaunch: (() => void) | null = null
mockLaunchWorktreeBackgroundTerminals.mockImplementation(
() =>
new Promise<void>((resolve) => {
finishSetupLaunch = () => {
order.push('setup')
resolve()
}
})
)
mockLaunchAgentBackgroundSession.mockImplementation(async () => {
order.push('agent')
return { tabId: 'agent-tab', ptyId: 'agent-pty', startupPlan: {} }
})
await registerAndDispatch()
expect(mockCreateWorktree).toHaveBeenCalled()
expect(mockCreateWorktree.mock.calls[0][3]).toBe('run')
expect(mockLaunchWorktreeBackgroundTerminals).toHaveBeenCalledWith({
worktreeId: 'wt-created',
setup: setupLaunch,
defaultTabs: undefined
})
expect(state.setActiveView).not.toHaveBeenCalled()
expect(state.setActiveWorktree).not.toHaveBeenCalled()
expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith(
expect.objectContaining({
worktreeId: 'wt-created',
prompt: 'run this'
})
)
expect(order).toEqual(['agent'])
expect(finishSetupLaunch).not.toBeNull()
const completeSetupLaunch = finishSetupLaunch as unknown as () => void
completeSetupLaunch()
await Promise.resolve()
expect(order).toEqual(['agent', 'setup'])
expect(mockMarkDispatchResult).toHaveBeenCalledWith(
expect.objectContaining({
runId: 'run-1',
status: 'dispatched',
workspaceId: 'wt-created',
terminalSessionId: 'agent-tab'
})
)
})
it('launches setup and default tabs without activating the created worktree', async () => {
const defaultTabs = {
tabs: [{ title: 'Dev', command: 'pnpm dev' }],
runCommands: true
}
mockCreateWorktree.mockResolvedValue({
worktree: createdWorktree,
setup: setupLaunch,
defaultTabs
})
await registerAndDispatch()
expect(mockLaunchWorktreeBackgroundTerminals).toHaveBeenCalledWith({
worktreeId: 'wt-created',
setup: setupLaunch,
defaultTabs
})
expect(state.setActiveView).not.toHaveBeenCalled()
expect(state.setActiveWorktree).not.toHaveBeenCalled()
expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith(
expect.objectContaining({
worktreeId: 'wt-created',
prompt: 'run this'
})
)
})
it('defaults legacy automations without a setup choice to skipping setup', async () => {
await registerAndDispatch(makeAutomation({ setupDecision: undefined }))
@@ -346,32 +257,6 @@ describe('useAutomationDispatchEvents setup launch', () => {
)
})
it('keeps launching the agent when background setup terminal launch fails', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
mockLaunchWorktreeBackgroundTerminals.mockRejectedValue(new Error('tab launch failed'))
try {
await registerAndDispatch()
} finally {
warnSpy.mockRestore()
}
expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith(
expect.objectContaining({
worktreeId: 'wt-created',
prompt: 'run this'
})
)
expect(mockMarkDispatchResult).toHaveBeenCalledWith(
expect.objectContaining({
runId: 'run-1',
status: 'dispatched',
workspaceId: 'wt-created',
terminalSessionId: 'agent-tab'
})
)
})
it('does not rerun setup for existing-worktree automations', async () => {
const existingWorktree = {
id: 'wt-existing',
@@ -390,7 +275,6 @@ describe('useAutomationDispatchEvents setup launch', () => {
)
expect(mockCreateWorktree).not.toHaveBeenCalled()
expect(mockLaunchWorktreeBackgroundTerminals).not.toHaveBeenCalled()
expect(mockLaunchAgentBackgroundSession).toHaveBeenCalledWith(
expect.objectContaining({
worktreeId: 'wt-existing',
-7
View File
@@ -832,13 +832,6 @@
}
}
},
"worktree": {
"background": {
"terminals": {
"setupTitle": "Setup"
}
}
},
"work": {
"item": {
"direct": {
-7
View File
@@ -649,13 +649,6 @@
}
}
},
"worktree": {
"background": {
"terminals": {
"setupTitle": "Configuración"
}
}
},
"work": {
"item": {
"direct": {
-7
View File
@@ -750,13 +750,6 @@
}
}
},
"worktree": {
"background": {
"terminals": {
"setupTitle": "Configuration"
}
}
},
"work": {
"item": {
"direct": {
-7
View File
@@ -649,13 +649,6 @@
}
}
},
"worktree": {
"background": {
"terminals": {
"setupTitle": "設定"
}
}
},
"work": {
"item": {
"direct": {
-7
View File
@@ -649,13 +649,6 @@
}
}
},
"worktree": {
"background": {
"terminals": {
"setupTitle": "설정"
}
}
},
"work": {
"item": {
"direct": {
-7
View File
@@ -649,13 +649,6 @@
}
}
},
"worktree": {
"background": {
"terminals": {
"setupTitle": "设置"
}
}
},
"work": {
"item": {
"direct": {
@@ -1,46 +0,0 @@
import {
registerEagerPtyBuffer,
type EagerPtyHandle
} from '@/components/terminal-pane/pty-dispatcher'
import { useAppStore } from '@/store'
function persistExitedPaneOutput(tabId: string, leafId: string, output: string): void {
const store = useAppStore.getState()
const layout = store.terminalLayoutsByTabId[tabId]
if (!layout) {
return
}
const { ptyIdsByLeafId: existingPtyIds, buffersByLeafId: existingBuffers, ...rest } = layout
const nextPtyIds = { ...existingPtyIds }
delete nextPtyIds[leafId]
const trimmedOutput = output.trim() ? output : ''
store.setTabLayout(tabId, {
...rest,
...(Object.keys(nextPtyIds).length > 0 ? { ptyIdsByLeafId: nextPtyIds } : {}),
...(trimmedOutput
? {
buffersByLeafId: {
...existingBuffers,
[leafId]: output
}
}
: existingBuffers
? { buffersByLeafId: existingBuffers }
: {})
})
}
// Why the incarnation: a relay-recycled id can hold the previous owner's exit, and draining that
// into this handler tears the pane down seconds after it launched.
export function registerBackgroundPaneBuffer(
tabId: string,
leafId: string,
pane: { ptyId: string; incarnationId?: string }
): void {
let eagerBuffer: EagerPtyHandle | null = null
const onExit = (exitPtyId: string): void => {
persistExitedPaneOutput(tabId, leafId, eagerBuffer?.flush() ?? '')
useAppStore.getState().clearTabPtyId(tabId, exitPtyId)
}
eagerBuffer = registerEagerPtyBuffer(pane.ptyId, onExit, pane.incarnationId)
}
@@ -1,418 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockSpawn = vi.fn()
const mockKill = vi.fn()
const mockCreateTab = vi.fn()
const mockSetTabCustomTitle = vi.fn()
const mockSetTabColor = vi.fn()
const mockSetTabLayout = vi.fn()
const mockUpdateTabPtyId = vi.fn()
const mockClearTabPtyId = vi.fn()
const mockCloseTab = vi.fn()
const mockRegisterEagerPtyBuffer = vi.fn()
const mockOwnerEnvironmentId = vi.fn()
let uuidIndex = 0
const setupLaunch = {
runnerScriptPath: '/tmp/setup.sh',
envVars: { ORCA_WORKTREE_PATH: '/repo/worktree' }
}
const state = {
settings: { activeRuntimeEnvironmentId: null as string | null, setupScriptLaunchMode: 'new-tab' },
repos: [{ id: 'repo-1', connectionId: null as string | null }],
worktreesByRepo: {
'repo-1': [
{
id: 'wt-1',
repoId: 'repo-1',
path: '/repo/worktree',
displayName: 'Worktree'
}
]
},
tabsByWorktree: { 'wt-1': [] as { id: string }[] },
allWorktrees: vi.fn(() => state.worktreesByRepo['repo-1'] ?? []),
createTab: mockCreateTab,
setTabCustomTitle: mockSetTabCustomTitle,
setTabColor: mockSetTabColor,
setTabLayout: mockSetTabLayout,
updateTabPtyId: mockUpdateTabPtyId,
clearTabPtyId: mockClearTabPtyId,
closeTab: mockCloseTab
}
vi.mock('@/store', () => ({
useAppStore: {
getState: () => state
}
}))
vi.mock('@/lib/browser-uuid', () => ({
createBrowserUuid: () => `00000000-0000-4000-8000-${String(++uuidIndex).padStart(12, '0')}`
}))
vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
registerEagerPtyBuffer: mockRegisterEagerPtyBuffer
}))
vi.mock('@/lib/worktree-runtime-owner', () => ({
getRuntimeEnvironmentIdForWorktree: () => mockOwnerEnvironmentId()
}))
describe('launchWorktreeBackgroundTerminals', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
uuidIndex = 0
state.settings = { activeRuntimeEnvironmentId: null, setupScriptLaunchMode: 'new-tab' }
state.repos = [{ id: 'repo-1', connectionId: null }]
state.worktreesByRepo['repo-1'] = [
{
id: 'wt-1',
repoId: 'repo-1',
path: '/repo/worktree',
displayName: 'Worktree'
}
]
state.tabsByWorktree = { 'wt-1': [] }
let tabIndex = 0
mockCreateTab.mockImplementation(() => {
const tab = { id: `tab-${++tabIndex}` }
state.tabsByWorktree['wt-1'].push(tab)
return tab
})
// Placement reads the row back from the store, so title and color writes must land there.
const patchTab = (tabId: string, patch: { customTitle?: string; color?: string }): void => {
state.tabsByWorktree['wt-1'] = state.tabsByWorktree['wt-1'].map((tab) =>
tab.id === tabId ? { ...tab, ...patch } : tab
)
}
mockSetTabCustomTitle.mockImplementation((tabId: string, customTitle: string) =>
patchTab(tabId, { customTitle })
)
mockSetTabColor.mockImplementation((tabId: string, color: string) => patchTab(tabId, { color }))
mockCloseTab.mockImplementation((tabId: string) => {
state.tabsByWorktree['wt-1'] = state.tabsByWorktree['wt-1'].filter((tab) => tab.id !== tabId)
})
let ptyIndex = 0
mockSpawn.mockImplementation(async () => ({ id: `pty-${++ptyIndex}` }))
mockOwnerEnvironmentId.mockReturnValue(null)
vi.stubGlobal('window', {
api: {
pty: {
spawn: mockSpawn,
kill: mockKill
}
}
})
})
it('spawns default tabs and setup as inactive background terminals', async () => {
const { launchWorktreeBackgroundTerminals } =
await import('./launch-worktree-background-terminals')
await launchWorktreeBackgroundTerminals({
worktreeId: 'wt-1',
setup: setupLaunch,
defaultTabs: {
runCommands: true,
tabs: [{ title: 'Dev', color: '#f97316', command: 'pnpm dev' }]
}
})
expect(mockCreateTab).toHaveBeenCalledTimes(2)
expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, {
activate: false,
recordInteraction: false
})
expect(mockSetTabCustomTitle).toHaveBeenCalledWith('tab-1', 'Dev', {
recordInteraction: false
})
expect(mockSetTabColor).toHaveBeenCalledWith('tab-1', '#f97316')
expect(mockSetTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup', {
recordInteraction: false
})
expect(mockSpawn).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
cwd: '/repo/worktree',
command: 'pnpm dev',
connectionId: null,
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: '00000000-0000-4000-8000-000000000001',
placement: {
kind: 'new-tab',
row: expect.objectContaining({ customTitle: 'Dev', color: '#f97316' })
}
})
)
expect(mockSpawn).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
command: 'bash /tmp/setup.sh',
env: expect.objectContaining({ ORCA_WORKTREE_PATH: '/repo/worktree' }),
tabId: 'tab-2',
leafId: '00000000-0000-4000-8000-000000000002',
placement: { kind: 'new-tab', row: expect.objectContaining({ customTitle: 'Setup' }) }
})
)
expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-1')
expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-2', 'pty-2')
expect(mockRegisterEagerPtyBuffer).toHaveBeenCalledTimes(2)
})
it('spawns setup in a split when setup launch mode requests a split', async () => {
state.settings = { activeRuntimeEnvironmentId: null, setupScriptLaunchMode: 'split-horizontal' }
const { launchWorktreeBackgroundTerminals } =
await import('./launch-worktree-background-terminals')
await launchWorktreeBackgroundTerminals({
worktreeId: 'wt-1',
setup: setupLaunch
})
expect(mockCreateTab).toHaveBeenCalledTimes(1)
expect(mockSpawn).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
tabId: 'tab-1',
leafId: '00000000-0000-4000-8000-000000000001',
placement: { kind: 'new-tab', row: expect.any(Object) }
})
)
expect(mockSpawn.mock.calls[0]?.[0]).not.toHaveProperty('command')
expect(mockSpawn).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
command: 'bash /tmp/setup.sh',
tabId: 'tab-1',
leafId: '00000000-0000-4000-8000-000000000002',
placement: {
kind: 'split',
parentLeafId: '00000000-0000-4000-8000-000000000001',
direction: 'horizontal',
proposedRoot: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: '00000000-0000-4000-8000-000000000001' },
second: { type: 'leaf', leafId: '00000000-0000-4000-8000-000000000002' }
}
}
})
)
expect(mockSetTabLayout).toHaveBeenLastCalledWith(
'tab-1',
expect.objectContaining({
root: expect.objectContaining({
type: 'split',
direction: 'horizontal'
}),
ptyIdsByLeafId: {
'00000000-0000-4000-8000-000000000001': 'pty-1',
'00000000-0000-4000-8000-000000000002': 'pty-2'
},
titlesByLeafId: { '00000000-0000-4000-8000-000000000002': 'Setup' }
})
)
})
it('spawns an initial terminal before a setup-only new-tab launch', async () => {
const { launchWorktreeBackgroundTerminals } =
await import('./launch-worktree-background-terminals')
await launchWorktreeBackgroundTerminals({
worktreeId: 'wt-1',
setup: setupLaunch
})
expect(mockCreateTab).toHaveBeenCalledTimes(2)
expect(mockSpawn.mock.calls[0]?.[0]).not.toHaveProperty('command')
expect(mockSpawn).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
command: 'bash /tmp/setup.sh',
tabId: 'tab-2',
leafId: '00000000-0000-4000-8000-000000000002'
})
)
expect(mockSetTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup', {
recordInteraction: false
})
})
it('uses Windows setup commands for Windows SSH runner paths', async () => {
state.repos = [{ id: 'repo-1', connectionId: 'ssh-windows' }]
state.worktreesByRepo['repo-1'] = [
{
id: 'wt-1',
repoId: 'repo-1',
path: 'C:\\repo\\worktree',
displayName: 'Worktree'
}
]
const { launchWorktreeBackgroundTerminals } =
await import('./launch-worktree-background-terminals')
await launchWorktreeBackgroundTerminals({
worktreeId: 'wt-1',
setup: {
runnerScriptPath: 'C:\\repo\\.git\\worktrees\\wt\\orca\\setup-runner.cmd',
envVars: { ORCA_WORKTREE_PATH: 'C:\\repo\\worktree' }
}
})
expect(mockSpawn).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
command: 'cmd.exe /c "C:\\repo\\.git\\worktrees\\wt\\orca\\setup-runner.cmd"',
connectionId: 'ssh-windows'
})
)
})
it('uses configured WSL setup commands for Windows bash runner paths', async () => {
state.repos = [{ id: 'repo-1', connectionId: null }]
state.worktreesByRepo['repo-1'] = [
{
id: 'wt-1',
repoId: 'repo-1',
path: 'C:\\repo\\worktree',
displayName: 'Worktree'
}
]
const { launchWorktreeBackgroundTerminals } =
await import('./launch-worktree-background-terminals')
await launchWorktreeBackgroundTerminals({
worktreeId: 'wt-1',
setup: {
runnerScriptPath: 'C:\\repo\\.git\\worktrees\\wt\\orca\\setup-runner.sh',
shell: { family: 'posix', executable: 'wsl.exe' },
envVars: { ORCA_WORKTREE_PATH: 'C:\\repo\\worktree' }
}
})
expect(mockSpawn).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
command: 'bash /mnt/c/repo/.git/worktrees/wt/orca/setup-runner.sh',
connectionId: null
})
)
})
it('still attempts setup when a default tab fails to spawn', async () => {
const spawnError = new Error('pty unavailable')
mockSpawn
.mockRejectedValueOnce(spawnError)
.mockResolvedValueOnce({ id: 'pty-initial' })
.mockResolvedValueOnce({ id: 'pty-setup' })
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { launchWorktreeBackgroundTerminals } =
await import('./launch-worktree-background-terminals')
await launchWorktreeBackgroundTerminals({
worktreeId: 'wt-1',
setup: setupLaunch,
defaultTabs: {
runCommands: true,
tabs: [{ title: 'Dev', command: 'pnpm dev' }]
}
})
expect(mockCloseTab).toHaveBeenCalledWith('tab-1', {
recordInteraction: false,
reason: 'cleanup'
})
expect(mockSpawn).toHaveBeenCalledTimes(3)
expect(mockSpawn).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
command: 'bash /tmp/setup.sh',
tabId: 'tab-3'
})
)
expect(warn).toHaveBeenCalledWith(
'[automations] Failed to launch workspace default tab:',
spawnError
)
warn.mockRestore()
})
it('does not duplicate runtime-owned setup/defaultTabs handled by runtime create', async () => {
mockOwnerEnvironmentId.mockReturnValue('env-1')
const { launchWorktreeBackgroundTerminals } =
await import('./launch-worktree-background-terminals')
await launchWorktreeBackgroundTerminals({
worktreeId: 'wt-1',
setup: setupLaunch,
defaultTabs: { runCommands: true, tabs: [{ title: 'Dev', command: 'pnpm dev' }] }
})
expect(mockCreateTab).not.toHaveBeenCalled()
expect(mockSpawn).not.toHaveBeenCalled()
})
it('kills a PTY whose tab is closed before the spawn resolves', async () => {
let resolveSpawn!: (result: { id: string }) => void
mockSpawn.mockReturnValueOnce(
new Promise<{ id: string }>((resolve) => {
resolveSpawn = resolve
})
)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { launchWorktreeBackgroundTerminals } =
await import('./launch-worktree-background-terminals')
const launch = launchWorktreeBackgroundTerminals({
worktreeId: 'wt-1',
defaultTabs: { runCommands: true, tabs: [{ command: 'pnpm dev' }] }
})
await vi.waitFor(() => expect(mockCreateTab).toHaveBeenCalledOnce())
state.tabsByWorktree['wt-1'] = []
resolveSpawn({ id: 'pty-after-close' })
await launch
expect(mockKill).toHaveBeenCalledWith('pty-after-close')
expect(mockUpdateTabPtyId).not.toHaveBeenCalled()
expect(mockRegisterEagerPtyBuffer).not.toHaveBeenCalled()
warn.mockRestore()
})
it('kills a late setup split PTY when its parent tab closes', async () => {
state.settings = { activeRuntimeEnvironmentId: null, setupScriptLaunchMode: 'split-horizontal' }
let resolveSetupSpawn!: (result: { id: string }) => void
mockSpawn.mockResolvedValueOnce({ id: 'pty-primary' }).mockReturnValueOnce(
new Promise<{ id: string }>((resolve) => {
resolveSetupSpawn = resolve
})
)
const { launchWorktreeBackgroundTerminals } =
await import('./launch-worktree-background-terminals')
const launch = launchWorktreeBackgroundTerminals({
worktreeId: 'wt-1',
setup: setupLaunch
})
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2))
state.closeTab('tab-1')
resolveSetupSpawn({ id: 'pty-setup-after-close' })
await launch
expect(mockKill).toHaveBeenCalledWith('pty-setup-after-close')
expect(mockUpdateTabPtyId).toHaveBeenCalledTimes(1)
expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-primary')
expect(mockSetTabLayout).not.toHaveBeenCalledWith(
'tab-1',
expect.objectContaining({
ptyIdsByLeafId: expect.objectContaining({
'00000000-0000-4000-8000-000000000002': 'pty-setup-after-close'
})
})
)
})
})
@@ -1,310 +0,0 @@
import { createBrowserUuid } from '@/lib/browser-uuid'
import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers'
import { retireUnownedTerminal } from '@/lib/retire-unowned-background-terminal'
import { registerBackgroundPaneBuffer } from '@/lib/background-pane-exit-output'
import { terminalPanePlacementRow } from '@/lib/terminal-pane-placement-row'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { makePaneKey } from '../../../shared/stable-pane-id'
import {
buildSetupRunnerCommand,
getSetupRunnerCommandPlatformForPath
} from '../../../shared/setup-runner-command'
import type {
TerminalLayoutSnapshot,
TerminalPaneLayoutNode
} from '../../../shared/terminal-tab-types'
import type { TerminalPanePlacement } from '../../../shared/terminal-pane-placement'
import type {
WorktreeDefaultTabsLaunch,
WorktreeSetupLaunch
} from '../../../shared/worktree/launch-types'
import type { Worktree } from '../../../shared/worktree/types'
type BackgroundPane = {
leafId: string
ptyId: string
}
type BackgroundTab = {
tabId: string
primary: BackgroundPane
}
type BackgroundTerminalLaunch = {
command?: string
env?: Record<string, string>
title?: string
color?: string
}
function getSetupTabTitle(): string {
return translate('auto.lib.launch.worktree.background.terminals.setupTitle', 'Setup')
}
export type LaunchWorktreeBackgroundTerminalsArgs = {
worktreeId: string
setup?: WorktreeSetupLaunch
defaultTabs?: WorktreeDefaultTabsLaunch
}
function buildPaneEnv(
worktreeId: string,
tabId: string,
leafId: string,
env: Record<string, string> | undefined
): Record<string, string> {
return {
...env,
ORCA_PANE_KEY: makePaneKey(tabId, leafId),
ORCA_TAB_ID: tabId,
ORCA_WORKTREE_ID: worktreeId
}
}
function buildSplitRoot(
firstLeafId: string,
secondLeafId: string,
direction: 'horizontal' | 'vertical'
): TerminalPaneLayoutNode {
return {
type: 'split',
direction,
first: { type: 'leaf', leafId: firstLeafId },
second: { type: 'leaf', leafId: secondLeafId }
}
}
function buildSplitLayout(
first: BackgroundPane,
second: BackgroundPane,
direction: 'horizontal' | 'vertical',
secondTitle: string
): TerminalLayoutSnapshot {
return {
root: buildSplitRoot(first.leafId, second.leafId, direction),
activeLeafId: first.leafId,
expandedLeafId: null,
ptyIdsByLeafId: {
[first.leafId]: first.ptyId,
[second.leafId]: second.ptyId
},
titlesByLeafId: {
[second.leafId]: secondTitle
}
}
}
function buildSetupCommand(setup: WorktreeSetupLaunch): string {
// Why: background setup tabs can launch later, so they must reuse the same shell chosen when the runner was written.
return buildSetupRunnerCommand(
setup.runnerScriptPath,
getSetupRunnerCommandPlatformForPath(setup.runnerScriptPath, 'posix'),
setup.shell
)
}
/** The id a background pane got, plus which lifetime of it this spawn owns. */
type SpawnedPane = { ptyId: string; incarnationId?: string }
async function spawnPane(args: {
worktree: Worktree
connectionId: string | null
tabId: string
leafId: string
placement: TerminalPanePlacement
command?: string
env?: Record<string, string>
}): Promise<SpawnedPane> {
const result = await window.api.pty.spawn({
cols: 120,
rows: 40,
cwd: args.worktree.path,
...(args.command ? { command: args.command } : {}),
env: buildPaneEnv(args.worktree.id, args.tabId, args.leafId, args.env),
connectionId: args.connectionId,
worktreeId: args.worktree.id,
tabId: args.tabId,
leafId: args.leafId,
placement: args.placement
})
return {
ptyId: result.id,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {})
}
}
async function createBackgroundTab(args: {
worktree: Worktree
connectionId: string | null
launch: BackgroundTerminalLaunch
}): Promise<BackgroundTab> {
const store = useAppStore.getState()
const tab = store.createTab(args.worktree.id, undefined, undefined, {
activate: false,
recordInteraction: false
})
if (args.launch.title) {
store.setTabCustomTitle(tab.id, args.launch.title, { recordInteraction: false })
}
if (args.launch.color) {
store.setTabColor(tab.id, args.launch.color)
}
const leafId = createBrowserUuid()
store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId))
const created =
useAppStore.getState().tabsByWorktree[args.worktree.id]?.find(({ id }) => id === tab.id) ?? tab
let pane: SpawnedPane
try {
pane = await spawnPane({
worktree: args.worktree,
connectionId: args.connectionId,
tabId: tab.id,
leafId,
placement: { kind: 'new-tab', row: terminalPanePlacementRow(created) },
command: args.launch.command,
env: args.launch.env
})
} catch (error) {
store.closeTab(tab.id, { recordInteraction: false, reason: 'cleanup' })
throw error
}
if (
await retireUnownedTerminal({
owner: { tabId: tab.id },
ptyId: pane.ptyId,
runtimeTarget: { kind: 'local' }
})
) {
throw new Error('The terminal tab was closed before its session finished starting.')
}
store.updateTabPtyId(tab.id, pane.ptyId)
store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId, pane.ptyId))
registerBackgroundPaneBuffer(tab.id, leafId, pane)
return { tabId: tab.id, primary: { leafId, ptyId: pane.ptyId } }
}
async function addSetupSplit(args: {
worktree: Worktree
connectionId: string | null
tab: BackgroundTab
setup: WorktreeSetupLaunch
direction: 'horizontal' | 'vertical'
}): Promise<void> {
const store = useAppStore.getState()
const setupLeafId = createBrowserUuid()
const setupPane = await spawnPane({
worktree: args.worktree,
connectionId: args.connectionId,
tabId: args.tab.tabId,
leafId: setupLeafId,
placement: {
kind: 'split',
parentLeafId: args.tab.primary.leafId,
direction: args.direction,
proposedRoot: buildSplitRoot(args.tab.primary.leafId, setupLeafId, args.direction)
},
command: buildSetupCommand(args.setup),
env: args.setup.envVars
})
if (
await retireUnownedTerminal({
owner: { tabId: args.tab.tabId },
ptyId: setupPane.ptyId,
runtimeTarget: { kind: 'local' }
})
) {
return
}
store.updateTabPtyId(args.tab.tabId, setupPane.ptyId)
store.setTabLayout(
args.tab.tabId,
buildSplitLayout(
args.tab.primary,
{ leafId: setupLeafId, ptyId: setupPane.ptyId },
args.direction,
getSetupTabTitle()
)
)
registerBackgroundPaneBuffer(args.tab.tabId, setupLeafId, setupPane)
}
function getDefaultTabLaunches(
defaultTabs: WorktreeDefaultTabsLaunch | undefined
): BackgroundTerminalLaunch[] {
return (defaultTabs?.tabs ?? []).map((tab) => {
const command = tab.command?.trim()
return {
...(tab.title ? { title: tab.title } : {}),
...(tab.color ? { color: tab.color } : {}),
...(command && defaultTabs?.runCommands ? { command } : {})
}
})
}
export async function launchWorktreeBackgroundTerminals(
args: LaunchWorktreeBackgroundTerminalsArgs
): Promise<void> {
if (!args.setup && !args.defaultTabs) {
return
}
const store = useAppStore.getState()
const runtimeTarget = runtimeTargetForOwnerEnvironment(
getRuntimeEnvironmentIdForWorktree(store, args.worktreeId)
)
if (runtimeTarget.kind === 'environment') {
// Runtime-owned worktrees materialize setup/defaultTabs inside createManagedWorktree.
return
}
const worktree = store.allWorktrees().find((entry) => entry.id === args.worktreeId)
if (!worktree) {
throw new Error('The target workspace is no longer available.')
}
const repo = store.repos.find((entry) => entry.id === worktree.repoId)
const connectionId = repo?.connectionId ?? null
const defaultLaunches = getDefaultTabLaunches(args.defaultTabs)
const launchedTabs: BackgroundTab[] = []
for (const launch of defaultLaunches) {
try {
launchedTabs.push(await createBackgroundTab({ worktree, connectionId, launch }))
} catch (error) {
console.warn('[automations] Failed to launch workspace default tab:', error)
}
}
const setupMode = store.settings?.setupScriptLaunchMode ?? 'new-tab'
const shouldSplitSetup =
args.setup && (setupMode === 'split-horizontal' || setupMode === 'split-vertical')
if (shouldSplitSetup) {
const primaryTab =
launchedTabs[0] ?? (await createBackgroundTab({ worktree, connectionId, launch: {} }))
await addSetupSplit({
worktree,
connectionId,
tab: primaryTab,
setup: args.setup!,
direction: setupMode === 'split-horizontal' ? 'horizontal' : 'vertical'
})
return
}
if (args.setup) {
if (launchedTabs.length === 0) {
launchedTabs.push(await createBackgroundTab({ worktree, connectionId, launch: {} }))
}
await createBackgroundTab({
worktree,
connectionId,
launch: {
title: getSetupTabTitle(),
command: buildSetupCommand(args.setup),
env: args.setup.envVars
}
})
}
}