From ef890ae2bd547206868cbec3a506d85fd8b274b6 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:07:23 -0400 Subject: [PATCH] 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 --- config/focus-setting-read-baseline.txt | 1 - config/scripts/ci-shard-timings.json | 1 - src/main/ipc/worktree-remote.ts | 42 +- ...ktrees-automation-create-terminals.test.ts | 180 ++++++++ .../orca-runtime-activate-managed-worktree.ts | 2 +- .../hooks/automation-dispatch-workspace.ts | 12 - .../hooks/useAutomationDispatchEvents.test.ts | 118 +---- src/renderer/src/i18n/locales/en.json | 7 - src/renderer/src/i18n/locales/es.json | 7 - src/renderer/src/i18n/locales/fr.json | 7 - src/renderer/src/i18n/locales/ja.json | 7 - src/renderer/src/i18n/locales/ko.json | 7 - src/renderer/src/i18n/locales/zh.json | 7 - .../src/lib/background-pane-exit-output.ts | 46 -- ...unch-worktree-background-terminals.test.ts | 418 ------------------ .../launch-worktree-background-terminals.ts | 310 ------------- 16 files changed, 214 insertions(+), 958 deletions(-) create mode 100644 src/main/ipc/worktrees-automation-create-terminals.test.ts delete mode 100644 src/renderer/src/lib/background-pane-exit-output.ts delete mode 100644 src/renderer/src/lib/launch-worktree-background-terminals.test.ts delete mode 100644 src/renderer/src/lib/launch-worktree-background-terminals.ts diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 20f9e85dc52..5c1bc52ff16 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -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 diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 38ddcfe1572..6ea0701fac8 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -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, diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index db38fec58da..a6cf75150b4 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -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 { 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 { // 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 { 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 } : {}), diff --git a/src/main/ipc/worktrees-automation-create-terminals.test.ts b/src/main/ipc/worktrees-automation-create-terminals.test.ts new file mode 100644 index 00000000000..56076452f07 --- /dev/null +++ b/src/main/ipc/worktrees-automation-create-terminals.test.ts @@ -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>()), + 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>() + ) +) +vi.mock('../worktree-runner-script', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( + await importOriginal>() + ) +) +vi.mock('../effective-hook-config', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( + await importOriginal>() + ) +) +vi.mock('../setup-hook-env-vars', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( + await importOriginal>() + ) +) +vi.mock('./worktree-logic', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( + await importOriginal>() + ) +) +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>()), + 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 + + 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() + }) +}) diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index 193f410cc2f..4fe3601c9ce 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -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 diff --git a/src/renderer/src/hooks/automation-dispatch-workspace.ts b/src/renderer/src/hooks/automation-dispatch-workspace.ts index ea2be2708de..191b5529ecb 100644 --- a/src/renderer/src/hooks/automation-dispatch-workspace.ts +++ b/src/renderer/src/hooks/automation-dispatch-workspace.ts @@ -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 } diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts index b8c1cb49c17..1094332cfda 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts @@ -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((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', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c2f146c796d..b7bfb99958b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -832,13 +832,6 @@ } } }, - "worktree": { - "background": { - "terminals": { - "setupTitle": "Setup" - } - } - }, "work": { "item": { "direct": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 81c958b7b6d..abb6d07b2e2 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -649,13 +649,6 @@ } } }, - "worktree": { - "background": { - "terminals": { - "setupTitle": "Configuración" - } - } - }, "work": { "item": { "direct": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index ee7828feb3b..b197a01853c 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -750,13 +750,6 @@ } } }, - "worktree": { - "background": { - "terminals": { - "setupTitle": "Configuration" - } - } - }, "work": { "item": { "direct": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c650eab84a8..5f1c17928dc 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -649,13 +649,6 @@ } } }, - "worktree": { - "background": { - "terminals": { - "setupTitle": "設定" - } - } - }, "work": { "item": { "direct": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index aeb4264177f..53aa6c58954 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -649,13 +649,6 @@ } } }, - "worktree": { - "background": { - "terminals": { - "setupTitle": "설정" - } - } - }, "work": { "item": { "direct": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 961a3ae8674..46d7ef615c1 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -649,13 +649,6 @@ } } }, - "worktree": { - "background": { - "terminals": { - "setupTitle": "设置" - } - } - }, "work": { "item": { "direct": { diff --git a/src/renderer/src/lib/background-pane-exit-output.ts b/src/renderer/src/lib/background-pane-exit-output.ts deleted file mode 100644 index 5d077313bf1..00000000000 --- a/src/renderer/src/lib/background-pane-exit-output.ts +++ /dev/null @@ -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) -} diff --git a/src/renderer/src/lib/launch-worktree-background-terminals.test.ts b/src/renderer/src/lib/launch-worktree-background-terminals.test.ts deleted file mode 100644 index af7d330fe4e..00000000000 --- a/src/renderer/src/lib/launch-worktree-background-terminals.test.ts +++ /dev/null @@ -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' - }) - }) - ) - }) -}) diff --git a/src/renderer/src/lib/launch-worktree-background-terminals.ts b/src/renderer/src/lib/launch-worktree-background-terminals.ts deleted file mode 100644 index d96e1e47412..00000000000 --- a/src/renderer/src/lib/launch-worktree-background-terminals.ts +++ /dev/null @@ -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 - 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 | undefined -): Record { - 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 -}): Promise { - 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 { - 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 { - 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 { - 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 - } - }) - } -}