diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index 7c40688c9..3b63b6d8a 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -2,7 +2,7 @@ import type { Repo } from '../shared/types' import { describe, expect, it, vi } from 'vitest' -import { parseOrcaYaml } from './hooks' +import { getDefaultTabsLaunch, parseOrcaYaml } from './hooks' // Mock fs and path used by loadHooks vi.mock('fs', () => ({ @@ -142,6 +142,44 @@ describe('parseOrcaYaml', () => { issueCommand: 'claude -p "Read issue #{{issue}}"' }) }) + + it('parses default terminal tabs from orca.yaml', () => { + const yaml = [ + 'defaultTabs:', + ' - title: Claude', + ' color: "#f97316"', + ' command: claude', + ' - title: LocalHost', + ' color: "#9ca3af"', + ' command: pnpm dev', + ' - title: Notes' + ].join('\n') + + expect(parseOrcaYaml(yaml)).toEqual({ + scripts: {}, + defaultTabs: [ + { title: 'Claude', color: '#f97316', command: 'claude' }, + { title: 'LocalHost', color: '#9ca3af', command: 'pnpm dev' }, + { title: 'Notes' } + ] + }) + }) + + it('drops invalid default tab entries and unsafe color values', () => { + const yaml = [ + 'defaultTabs:', + ' - title: Server', + ' color: "red"', + ' command: pnpm dev', + ' - 42', + ' - title: ""' + ].join('\n') + + expect(parseOrcaYaml(yaml)).toEqual({ + scripts: {}, + defaultTabs: [{ title: 'Server', command: 'pnpm dev' }] + }) + }) }) describe('hasUnrecognizedOrcaYamlKeys', () => { @@ -174,7 +212,15 @@ describe('hasUnrecognizedOrcaYamlKeys', () => { it('returns false when the file contains only recognised keys', async () => { const fs = await import('fs') vi.mocked(fs.readFileSync).mockReturnValue( - 'scripts:\n setup: |\n pnpm install\nissueCommand: |\n claude -p "test"\n' + [ + 'scripts:', + ' setup: |', + ' pnpm install', + 'issueCommand: |', + ' claude -p "test"', + 'defaultTabs:', + ' - title: Claude' + ].join('\n') ) const { hasUnrecognizedOrcaYamlKeys } = await import('./hooks') @@ -834,3 +880,47 @@ describe('shouldRunSetupForCreate', () => { expect(shouldRunSetupForCreate(makeRepo('run-by-default'), 'skip')).toBe(false) }) }) + +describe('getDefaultTabsLaunch', () => { + const makeRepo = (setupRunPolicy?: 'ask' | 'run-by-default' | 'skip-by-default') => + ({ + id: 'test-id', + path: '/test/repo', + displayName: 'Test Repo', + badgeColor: '#000', + addedAt: Date.now(), + hookSettings: { + mode: 'auto', + setupRunPolicy, + scripts: { setup: '', archive: '' } + } + }) as unknown as Repo + + it('opts into default tab command execution through the setup decision', () => { + const hooks = { + scripts: {}, + defaultTabs: [{ title: 'Server', command: 'pnpm dev' }] + } + + expect(getDefaultTabsLaunch(hooks, makeRepo('skip-by-default'), 'run')).toEqual({ + tabs: hooks.defaultTabs, + runCommands: true + }) + expect(getDefaultTabsLaunch(hooks, makeRepo('run-by-default'), 'skip')).toEqual({ + tabs: hooks.defaultTabs, + runCommands: false + }) + }) + + it('creates commandless default tabs without requiring setup approval', () => { + const hooks = { + scripts: {}, + defaultTabs: [{ title: 'Notes' }] + } + + expect(getDefaultTabsLaunch(hooks, makeRepo('ask'))).toEqual({ + tabs: hooks.defaultTabs, + runCommands: false + }) + }) +}) diff --git a/src/main/hooks.ts b/src/main/hooks.ts index 2b092d944..8fcead5cb 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -2,6 +2,7 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync, rmSync } from 'fs' import { dirname, join } from 'path' import { exec, execFile } from 'child_process' +import { parse } from 'yaml' import { getDefaultRepoHookSettings } from '../shared/constants' import { getRuntimePathBasename } from '../shared/cross-platform-path' import { resolveHookCommandSourcePolicy } from '../shared/hook-command-source-policy' @@ -9,10 +10,12 @@ import { gitExecFileSync } from './git/runner' import { isWslPath, parseWslPath, toWindowsWslPath, toLinuxPath } from './wsl' import type { HookCommandSourcePolicy, + OrcaDefaultTabTemplate, OrcaHooks, Repo, SetupDecision, SetupRunPolicy, + WorktreeDefaultTabsLaunch, WorktreeSetupLaunch } from '../shared/types' @@ -26,88 +29,79 @@ function getHookShell(): string | undefined { return '/bin/bash' } +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} + +function asTrimmedString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +const DEFAULT_TAB_COLOR_RE = /^#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?$/ + +function normalizeDefaultTabs(value: unknown): OrcaDefaultTabTemplate[] { + if (!Array.isArray(value)) { + return [] + } + + return value + .map((entry) => { + const record = asRecord(entry) + if (!record) { + return null + } + const title = asTrimmedString(record.title) + const command = asTrimmedString(record.command) + const color = asTrimmedString(record.color) + const normalizedColor = color && DEFAULT_TAB_COLOR_RE.test(color) ? color : undefined + if (!title && !command && !normalizedColor) { + return null + } + return { + ...(title ? { title } : {}), + ...(normalizedColor ? { color: normalizedColor } : {}), + ...(command ? { command } : {}) + } + }) + .filter((entry): entry is OrcaDefaultTabTemplate => entry !== null) +} + /** - * Parse a simple orca.yaml file. Handles only the supported `scripts:` and - * `issueCommand:` keys with multiline string values (YAML block scalar `|`). + * Parse the supported project defaults from `orca.yaml`. */ export function parseOrcaYaml(content: string): OrcaHooks | null { - const hooks: OrcaHooks = { scripts: {} } - const lines = content.split(/\r?\n/) - - let currentSection: 'scripts' | 'issueCommand' | null = null - let currentKey: 'setup' | 'archive' | null = null - let issueCommandValue = '' - - for (const line of lines) { - const topLevelKeyMatch = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(\|)?\s*(.*)$/) - if (topLevelKeyMatch) { - if (currentSection === 'scripts' && currentKey) { - hooks.scripts[currentKey] = issueCommandValue.trimEnd() - } else if (currentSection === 'issueCommand') { - hooks.issueCommand = issueCommandValue.trimEnd() || undefined - } - - const [, key, blockScalar, rest] = topLevelKeyMatch - currentKey = null - issueCommandValue = '' - - if (key === 'scripts') { - currentSection = 'scripts' - continue - } - - if (key === 'issueCommand') { - currentSection = 'issueCommand' - if (blockScalar) { - continue - } - hooks.issueCommand = rest.trim() || undefined - currentSection = null - continue - } - - currentSection = null - continue - } - - if (currentSection === 'scripts') { - // Indented key like " setup: |" or " archive: |" or " setup: echo hello" - const keyMatch = line.match(/^ (setup|archive):\s*(\|)?\s*(.*)$/) - if (keyMatch) { - // Save previous key - if (currentKey) { - hooks.scripts[currentKey] = issueCommandValue.trimEnd() - } - currentKey = keyMatch[1] as 'setup' | 'archive' - issueCommandValue = keyMatch[3] ? `${keyMatch[3]}\n` : '' - continue - } - - // Content line (indented by 4+ spaces under a key) - if (currentKey && line.startsWith(' ')) { - issueCommandValue += `${line.slice(4)}\n` - } - continue - } - - if (currentSection === 'issueCommand' && line.startsWith(' ')) { - // Why: `issueCommand` is a top-level scalar in `orca.yaml`, so its block - // content must stay separate from the `scripts:` parser rather than being - // shoehorned into that section's indentation rules. - issueCommandValue += `${line.slice(2)}\n` - } - } - - if (currentSection === 'scripts' && currentKey) { - hooks.scripts[currentKey] = issueCommandValue.trimEnd() - } else if (currentSection === 'issueCommand') { - hooks.issueCommand = issueCommandValue.trimEnd() || undefined - } - - if (!hooks.scripts.setup && !hooks.scripts.archive && !hooks.issueCommand) { + let root: unknown + try { + root = parse(content) + } catch { return null } - return hooks + + const record = asRecord(root) + if (!record) { + return null + } + + const scriptsRecord = asRecord(record.scripts) + const setup = scriptsRecord ? asTrimmedString(scriptsRecord.setup) : undefined + const archive = scriptsRecord ? asTrimmedString(scriptsRecord.archive) : undefined + const issueCommand = asTrimmedString(record.issueCommand) + const defaultTabs = normalizeDefaultTabs(record.defaultTabs) + + if (!setup && !archive && !issueCommand && defaultTabs.length === 0) { + return null + } + + return { + scripts: { + ...(setup ? { setup } : {}), + ...(archive ? { archive } : {}) + }, + ...(issueCommand ? { issueCommand } : {}), + ...(defaultTabs.length > 0 ? { defaultTabs } : {}) + } } /** @@ -139,7 +133,7 @@ export function hasHooksFile(repoPath: string): boolean { // return `null` from `parseOrcaYaml` and show a confusing "could not be parsed" // error. Detecting well-formed but unrecognised keys lets the UI suggest an // update instead of implying the file is broken. -const RECOGNIZED_ORCA_YAML_KEYS = new Set(['scripts', 'issueCommand']) +const RECOGNIZED_ORCA_YAML_KEYS = new Set(['scripts', 'issueCommand', 'defaultTabs']) /** * Return true when `orca.yaml` contains at least one top-level key that this @@ -339,6 +333,34 @@ export function shouldRunSetupForCreate(repo: Repo, decision: SetupDecision = 'i return policy === 'run-by-default' } +export function getDefaultTabCommandTrustContent(hooks: OrcaHooks | null): string { + const commands = (hooks?.defaultTabs ?? []) + .map((tab, index) => { + const command = tab.command?.trim() + if (!command) { + return null + } + const label = tab.title ? ` ${tab.title}` : '' + return `# defaultTabs[${index + 1}]${label}\n${command}` + }) + .filter((entry): entry is string => entry !== null) + return [hooks?.scripts.setup?.trim(), ...commands].filter(Boolean).join('\n\n') +} + +export function getDefaultTabsLaunch( + hooks: OrcaHooks | null, + repo: Repo, + decision: SetupDecision = 'inherit' +): WorktreeDefaultTabsLaunch | undefined { + const tabs = hooks?.defaultTabs ?? [] + if (tabs.length === 0) { + return undefined + } + const hasCommands = tabs.some((tab) => Boolean(tab.command?.trim())) + const runCommands = hasCommands ? shouldRunSetupForCreate(repo, decision) : false + return { tabs, runCommands } +} + export function getSetupCommandSource( repo: Repo, worktreePath?: string diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index d7b801b55..4690f5126 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -35,9 +35,11 @@ import { buildPosixRunnerScript, buildWindowsRunnerScript, createSetupRunnerScript, + getDefaultTabsLaunch, getEffectiveHooks, getEffectiveHooksFromConfig, getSetupRunnerEnvVars, + loadHooks, parseOrcaYaml, shouldRunSetupForCreate } from '../hooks' @@ -721,12 +723,18 @@ async function readRemoteEffectiveHooks( fsProvider: IFilesystemProvider, hooksRootPath: string ): Promise> { + return getEffectiveHooksFromConfig(repo, await readRemoteOrcaYaml(fsProvider, hooksRootPath)) +} + +async function readRemoteOrcaYaml( + fsProvider: IFilesystemProvider, + hooksRootPath: string +): Promise> { try { const result = await fsProvider.readFile(joinWorktreeRelativePath(hooksRootPath, 'orca.yaml')) - const yamlHooks = result.isBinary ? null : parseOrcaYaml(result.content) - return getEffectiveHooksFromConfig(repo, yamlHooks) + return result.isBinary ? null : parseOrcaYaml(result.content) } catch { - return getEffectiveHooksFromConfig(repo, null) + return null } } @@ -1177,8 +1185,20 @@ export async function createRemoteWorktree( // `symlinkPaths` configured have them silently ignored here. let setup: CreateWorktreeResult['setup'] + let defaultTabs: CreateWorktreeResult['defaultTabs'] if (fsProvider) { - const hooks = await readRemoteEffectiveHooks(repo, fsProvider, created.path) + const yamlHooks = await readRemoteOrcaYaml(fsProvider, created.path) + const hooks = getEffectiveHooksFromConfig(repo, yamlHooks) + try { + defaultTabs = getDefaultTabsLaunch(yamlHooks, repo, args.setupDecision) + } catch (error) { + // Why: default tab commands share setup's run policy. If the target branch + // adds commands without a renderer decision, create the tabs but don't run them. + console.warn(`[hooks] default tab commands skipped for ${created.path}:`, error) + defaultTabs = yamlHooks?.defaultTabs + ? { tabs: yamlHooks.defaultTabs, runCommands: false } + : undefined + } const setupScript = hooks?.scripts.setup let shouldLaunchSetup = false if (setupScript) { @@ -1210,6 +1230,7 @@ export async function createRemoteWorktree( return { worktree, ...(setup ? { setup } : {}), + ...(defaultTabs ? { defaultTabs } : {}), ...(localBaseRefRefresh ? { localBaseRefRefresh } : {}) } } @@ -1610,7 +1631,20 @@ export async function createLocalWorktree( // disabling setup with no UI signal. See #1280 for the original gate and // the regression this replaced. let setup: CreateWorktreeResult['setup'] - const setupScript = getEffectiveHooks(repo, worktreePath)?.scripts.setup + let defaultTabs: CreateWorktreeResult['defaultTabs'] + const createdYamlHooks = loadHooks(worktreePath) + const createdEffectiveHooks = getEffectiveHooksFromConfig(repo, createdYamlHooks) + try { + defaultTabs = getDefaultTabsLaunch(createdYamlHooks, repo, args.setupDecision) + } catch (error) { + // Why: default tab commands share setup's run policy. If the target branch + // adds commands without a renderer decision, create the tabs but don't run them. + console.warn(`[hooks] default tab commands skipped for ${worktreePath}:`, error) + defaultTabs = createdYamlHooks?.defaultTabs + ? { tabs: createdYamlHooks.defaultTabs, runCommands: false } + : undefined + } + const setupScript = createdEffectiveHooks?.scripts.setup let shouldLaunchSetup = false if (setupScript) { try { @@ -1644,6 +1678,7 @@ export async function createLocalWorktree( return { worktree, ...(setup ? { setup } : {}), + ...(defaultTabs ? { defaultTabs } : {}), ...(addResult.localBaseRefRefresh ? { localBaseRefRefresh: addResult.localBaseRefRefresh } : {}) } } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 7077924df..0619a8a5c 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -220,6 +220,10 @@ vi.mock('../hooks', () => ({ createSetupRunnerScript: vi.fn(), getEffectiveHooks: vi.fn().mockReturnValue(null), getEffectiveHooksFromConfig: vi.fn().mockReturnValue(null), + getDefaultTabCommandTrustContent: vi.fn( + (hooks: { scripts?: { setup?: string } } | null) => hooks?.scripts?.setup?.trim() ?? '' + ), + getDefaultTabsLaunch: vi.fn().mockReturnValue(undefined), getSetupRunnerEnvVars: (_repo: never, worktreePath: string) => ({ ORCA_ROOT_PATH: '/remote/repo', ORCA_WORKTREE_PATH: worktreePath @@ -8044,7 +8048,13 @@ describe('OrcaRuntimeService', () => { } } }) - expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), result.setup) + expect(activateWorktree).toHaveBeenCalledWith( + 'repo-1', + expect.any(String), + result.setup, + undefined, + undefined + ) }) it('passes setup payloads through when explicitly activating CLI-created worktrees', async () => { @@ -8096,7 +8106,13 @@ describe('OrcaRuntimeService', () => { activate: true }) - expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), result.setup) + expect(activateWorktree).toHaveBeenCalledWith( + 'repo-1', + expect.any(String), + result.setup, + undefined, + undefined + ) }) it('follows normal setup policy for CLI-created worktrees without activating them', async () => { @@ -8731,7 +8747,13 @@ describe('OrcaRuntimeService', () => { expect(detectRemoteAgentsMock).not.toHaveBeenCalled() expect(spawn).not.toHaveBeenCalled() expect(metaById[result.worktree.id]?.createdWithAgent).toBeUndefined() - expect(activateWorktree).toHaveBeenCalledWith('repo-1', result.worktree.id, undefined) + expect(activateWorktree).toHaveBeenCalledWith( + 'repo-1', + result.worktree.id, + undefined, + undefined, + undefined + ) }) it('detects agents on the SSH host before launching remote startup drafts', async () => { @@ -9679,7 +9701,13 @@ describe('OrcaRuntimeService', () => { activate: true }) - expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), undefined) + expect(activateWorktree).toHaveBeenCalledWith( + 'repo-1', + expect.any(String), + undefined, + undefined, + undefined + ) }) it('stamps createdAt alongside lastActivityAt so CLI-created worktrees get the Recent-sort grace window', async () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 399d14fed..3bd64012b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -346,6 +346,8 @@ import type { AddWorktreeResult } from '../git/worktree' import { isENOENT } from '../ipc/filesystem-auth' import { createSetupRunnerScript, + getDefaultTabCommandTrustContent, + getDefaultTabsLaunch, getEffectiveHooks, getEffectiveSetupRunPolicy, hasUnrecognizedOrcaYamlKeys, @@ -682,7 +684,8 @@ type RuntimeNotifier = { repoId: string, worktreeId: string, setup?: CreateWorktreeResult['setup'], - startup?: WorktreeStartupLaunch + startup?: WorktreeStartupLaunch, + defaultTabs?: CreateWorktreeResult['defaultTabs'] ): void createTerminal(worktreeId: string, opts: { command?: string; title?: string }): void revealTerminalSession?( @@ -6856,9 +6859,9 @@ export class OrcaRuntimeService { private getSetupHookTrustPayload( repo: Repo, - setupScript: string | undefined + scriptContentValue: string | undefined ): { contentHash: string; scriptContent: string } | undefined { - const scriptContent = setupScript?.trim() + const scriptContent = scriptContentValue?.trim() if (!scriptContent || repo.hookSettings?.commandSourcePolicy === 'local-only') { return undefined } @@ -6898,7 +6901,10 @@ export class OrcaRuntimeService { hooks, setupRunPolicy: getEffectiveSetupRunPolicy(repo), source: hooks ? 'orca.yaml' : null, - setupTrust: this.getSharedSetupHookTrustPayload(repo, hooks?.scripts?.setup) + setupTrust: this.getSharedSetupHookTrustPayload( + repo, + getDefaultTabCommandTrustContent(hooks) + ) } } catch { return { @@ -6918,7 +6924,10 @@ export class OrcaRuntimeService { hooks, setupRunPolicy, source: hasFile ? 'orca.yaml' : hooks ? 'legacy' : null, - setupTrust: this.getSharedSetupHookTrustPayload(repo, sharedHooks?.scripts?.setup) + setupTrust: this.getSharedSetupHookTrustPayload( + repo, + getDefaultTabCommandTrustContent(sharedHooks) + ) } } @@ -6938,9 +6947,7 @@ export class OrcaRuntimeService { if (result.isBinary) { return { hasHooks: false, hooks: null, mayNeedUpdate: false } } - const { parse } = await import('yaml') - const parsed = parse(result.content) - return { hasHooks: true, hooks: parsed, mayNeedUpdate: false } + return { hasHooks: true, hooks: parseOrcaYaml(result.content), mayNeedUpdate: false } } catch { return { hasHooks: false, hooks: null, mayNeedUpdate: false } } @@ -7936,12 +7943,22 @@ export class OrcaRuntimeService { // Why: CLI-created worktrees do not have a renderer preview to mismatch // against. Trust is granted by the direct CLI invocation (`--run-hooks`), // so loading the setup hook from the created worktree is intentional here. + const yamlHooks = loadHooks(worktreePath) const hooks = getEffectiveHooks(repo, worktreePath) // Why: setupDecision lets mobile/CLI callers control whether the setup // script runs. 'skip' suppresses it, 'run' forces it, 'inherit' (default) // defers to the repo's orca.yaml setupRunPolicy. runHooks === true maps // to 'run' for backwards compatibility with the desktop create flow. const effectiveDecision = args.runHooks ? 'run' : (args.setupDecision ?? 'inherit') + let defaultTabs: CreateWorktreeResult['defaultTabs'] + try { + defaultTabs = getDefaultTabsLaunch(yamlHooks, repo, effectiveDecision) + } catch (error) { + console.warn(`[hooks] default tab commands skipped for ${worktreePath}:`, error) + defaultTabs = yamlHooks?.defaultTabs + ? { tabs: yamlHooks.defaultTabs, runCommands: false } + : undefined + } const shouldRunSetup = hooks?.scripts.setup && shouldRunSetupForCreate(repo, effectiveDecision) if (shouldRunSetup && hooks?.scripts.setup) { if (this.authoritativeWindowId !== null) { @@ -8053,9 +8070,21 @@ export class OrcaRuntimeService { // the user can watch prompts/output in a visible pane. const activationSetup = didSpawnSetup ? undefined : setup if (effectiveStartup && !didSpawnStartup) { - this.notifier?.activateWorktree(repo.id, worktree.id, activationSetup, effectiveStartup) + this.notifier?.activateWorktree( + repo.id, + worktree.id, + activationSetup, + effectiveStartup, + defaultTabs + ) } else { - this.notifier?.activateWorktree(repo.id, worktree.id, activationSetup) + this.notifier?.activateWorktree( + repo.id, + worktree.id, + activationSetup, + undefined, + defaultTabs + ) } } else if (this.ptyController?.spawn) { try { @@ -8090,6 +8119,7 @@ export class OrcaRuntimeService { }, ...(lineageInput ? { lineage, warnings: lineageWarnings } : {}), ...(setup ? { setup } : {}), + ...(defaultTabs ? { defaultTabs } : {}), ...(warning ? { warning } : {}), ...(addResult.localBaseRefRefresh ? { localBaseRefRefresh: addResult.localBaseRefRefresh } diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index f1a9ac191..d457f6f25 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -270,13 +270,15 @@ function registerRuntimeWindowLifecycle( repoId, worktreeId, setup?: CreateWorktreeResult['setup'], - startup?: WorktreeStartupLaunch + startup?: WorktreeStartupLaunch, + defaultTabs?: CreateWorktreeResult['defaultTabs'] ) => { send('ui:activateWorktree', { repoId, worktreeId, ...(setup ? { setup } : {}), - ...(startup ? { startup } : {}) + ...(startup ? { startup } : {}), + ...(defaultTabs ? { defaultTabs } : {}) }) }, createTerminal: (worktreeId, opts) => diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 7b2142bc3..c2b988d80 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -122,6 +122,7 @@ import type { WorktreeMeta, WorktreeRemoteBranchConflictEvent, RemoveWorktreeResult, + WorktreeDefaultTabsLaunch, WorktreeSetupLaunch, WorktreeStartupLaunch, WorkspaceSessionPatch, @@ -1971,6 +1972,7 @@ export type PreloadApi = { worktreeId: string setup?: WorktreeSetupLaunch startup?: WorktreeStartupLaunch + defaultTabs?: WorktreeDefaultTabsLaunch }) => void ) => () => void onCreateTerminal: ( diff --git a/src/preload/index.ts b/src/preload/index.ts index 7be44b6ee..87223f84b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -37,6 +37,7 @@ import type { SearchResult, UpdateStatus, WorktreeBaseStatusEvent, + WorktreeDefaultTabsLaunch, WorktreeRemoteBranchConflictEvent } from '../shared/types' import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' @@ -2552,6 +2553,7 @@ const api = { worktreeId: string setup?: { runnerScriptPath: string; envVars: Record } startup?: { command: string; env?: Record } + defaultTabs?: WorktreeDefaultTabsLaunch }) => void ): (() => void) => { const listener = ( @@ -2561,6 +2563,7 @@ const api = { worktreeId: string setup?: { runnerScriptPath: string; envVars: Record } startup?: { command: string; env?: Record } + defaultTabs?: WorktreeDefaultTabsLaunch } ) => callback(data) ipcRenderer.on('ui:activateWorktree', listener) diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index af6bd6404..51c7bc3c0 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -1931,6 +1931,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS activateAndRevealWorktree(worktree.id, { sidebarRevealBehavior: 'auto', setup: result.setup, + defaultTabs: result.defaultTabs, issueCommand, ...(startupPlan ? { @@ -2197,6 +2198,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS activateAndRevealWorktree(worktree.id, { sidebarRevealBehavior: 'auto', setup: result.setup, + defaultTabs: result.defaultTabs, ...(startupPlan ? { startup: { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index a7c6a72d5..5132f72df 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -866,7 +866,7 @@ export function useIpcEvents(): void { ) unsubs.push( - window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup, startup }) => { + window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup, startup, defaultTabs }) => { void (async () => { if (isRuntimeEnvironmentActive()) { // Why: local CLI-created worktree events carry local repo/worktree @@ -890,6 +890,7 @@ export function useIpcEvents(): void { activateAndRevealWorktree(worktreeId, { ...(setup ? { setup } : {}), ...(startup ? { startup } : {}), + ...(defaultTabs ? { defaultTabs } : {}), ...(!existedBeforeFetch && existsAfterFetch ? { sidebarRevealBehavior: 'auto' } : {}) }) })().catch((error) => { diff --git a/src/renderer/src/lib/ensure-hooks-confirmed.test.ts b/src/renderer/src/lib/ensure-hooks-confirmed.test.ts index 2ae9041b0..8f584416e 100644 --- a/src/renderer/src/lib/ensure-hooks-confirmed.test.ts +++ b/src/renderer/src/lib/ensure-hooks-confirmed.test.ts @@ -92,6 +92,33 @@ describe('ensureHooksConfirmed', () => { await expect(promise).resolves.toBe('run') }) + it('includes default tab commands in the setup trust prompt', async () => { + const { state, pending } = createTestState() + hooksCheckMock.mockResolvedValue({ + hasHooks: true, + hooks: { + scripts: { setup: 'pnpm install' }, + defaultTabs: [ + { title: 'Server', command: 'pnpm dev' }, + { title: 'Notes' }, + { command: 'codex' } + ] + }, + mayNeedUpdate: false + }) + + const promise = ensureHooksConfirmed(state, 'repo-1', 'setup') + + await vi.waitFor(() => expect(pending).toHaveLength(1)) + const expectedContent = + 'pnpm install\n\n# defaultTabs[1] Server\npnpm dev\n\n# defaultTabs[3]\ncodex' + expect(pending[0].data.scriptContent).toBe(expectedContent) + expect(pending[0].data.contentHash).toBe(await hashOrcaHookScript(expectedContent)) + + pending[0].resolve('skip') + await expect(promise).resolves.toBe('skip') + }) + it('returns run without inspecting hooks when the repo is always trusted', async () => { const { state, pending } = createTestState() state.trustedOrcaHooks['repo-1'] = { diff --git a/src/renderer/src/lib/ensure-hooks-confirmed.ts b/src/renderer/src/lib/ensure-hooks-confirmed.ts index ee7faba13..1c9e0d4b6 100644 --- a/src/renderer/src/lib/ensure-hooks-confirmed.ts +++ b/src/renderer/src/lib/ensure-hooks-confirmed.ts @@ -19,6 +19,20 @@ export function __resetTrustPromptChainForTests(): void { trustPromptChain = Promise.resolve() } +function getSetupTrustContent(yamlHooks: OrcaHooks | null): string { + const defaultTabCommands = (yamlHooks?.defaultTabs ?? []) + .map((tab, index) => { + const command = tab.command?.trim() + if (!command) { + return null + } + const label = tab.title ? ` ${tab.title}` : '' + return `# defaultTabs[${index + 1}]${label}\n${command}` + }) + .filter((entry): entry is string => entry !== null) + return [yamlHooks?.scripts?.setup?.trim(), ...defaultTabCommands].filter(Boolean).join('\n\n') +} + export async function ensureHooksConfirmed( state: AppState, repoId: string, @@ -61,7 +75,10 @@ export async function ensureHooksConfirmed( return 'skip' } const yamlHooks = (result.hooks as OrcaHooks | null) ?? null - scriptContent = (yamlHooks?.scripts?.[scriptKind] ?? '').trim() + scriptContent = + scriptKind === 'setup' + ? getSetupTrustContent(yamlHooks) + : (yamlHooks?.scripts?.[scriptKind] ?? '').trim() } } catch { // Fail closed: if we cannot inspect the script, we cannot trust it. diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index e73515bdc..fb0a1f7c0 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -338,6 +338,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom const activation = activateAndRevealWorktree(worktreeId, { sidebarRevealBehavior: 'auto', setup: result.setup, + defaultTabs: result.defaultTabs, ...buildStartupOpts(effectiveAgent, startupPlan, launchSource) }) if (!activation) { diff --git a/src/renderer/src/lib/workspace-session-browser-history.test.ts b/src/renderer/src/lib/workspace-session-browser-history.test.ts index fff4ae79b..b60f128da 100644 --- a/src/renderer/src/lib/workspace-session-browser-history.test.ts +++ b/src/renderer/src/lib/workspace-session-browser-history.test.ts @@ -27,7 +27,8 @@ function createSnapshot(browserUrlHistory: BrowserHistoryEntry[]): WorkspaceSess repos: [], worktreesByRepo: {}, lastKnownRelayPtyIdByTabId: {}, - lastVisitedAtByWorktreeId: {} + lastVisitedAtByWorktreeId: {}, + defaultTerminalTabsAppliedByWorktreeId: {} } } diff --git a/src/renderer/src/lib/workspace-session-liveness.test.ts b/src/renderer/src/lib/workspace-session-liveness.test.ts index 1ee0b3412..fab4d56bf 100644 --- a/src/renderer/src/lib/workspace-session-liveness.test.ts +++ b/src/renderer/src/lib/workspace-session-liveness.test.ts @@ -28,6 +28,7 @@ function createSnapshot( worktreesByRepo: {}, lastKnownRelayPtyIdByTabId: {}, lastVisitedAtByWorktreeId: {}, + defaultTerminalTabsAppliedByWorktreeId: {}, ...overrides } } diff --git a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts index 05bf8a7d1..57990b9b6 100644 --- a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts +++ b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts @@ -27,7 +27,8 @@ describe('SESSION_RELEVANT_FIELDS', () => { repos: true, worktreesByRepo: true, lastKnownRelayPtyIdByTabId: true, - lastVisitedAtByWorktreeId: true + lastVisitedAtByWorktreeId: true, + defaultTerminalTabsAppliedByWorktreeId: true } it('contains every key of WorkspaceSessionSnapshot', () => { diff --git a/src/renderer/src/lib/workspace-session.test.ts b/src/renderer/src/lib/workspace-session.test.ts index a6701243a..8ce7e1ca2 100644 --- a/src/renderer/src/lib/workspace-session.test.ts +++ b/src/renderer/src/lib/workspace-session.test.ts @@ -105,6 +105,16 @@ describe('buildWorkspaceSessionPayload', () => { expect(payload.activeWorktreeIdsOnShutdown).toEqual(['wt-1']) }) + it('persists the default-tab idempotency marker when present', () => { + const payload = buildWorkspaceSessionPayload( + createSnapshot({ + defaultTerminalTabsAppliedByWorktreeId: { 'wt-1': true } + }) + ) + + expect(payload.defaultTerminalTabsAppliedByWorktreeId).toEqual({ 'wt-1': true }) + }) + it('persists floating terminal tabs for daemon reattach after restart', () => { const payload = buildWorkspaceSessionPayload( createSnapshot({ diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index b5bad02fc..e5a8531c1 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -52,6 +52,7 @@ export type WorkspaceSessionSnapshot = Pick< | 'worktreesByRepo' | 'lastKnownRelayPtyIdByTabId' | 'lastVisitedAtByWorktreeId' + | 'defaultTerminalTabsAppliedByWorktreeId' > // Why: the App-level Zustand subscriber that debounces session writes uses @@ -83,7 +84,8 @@ export const SESSION_RELEVANT_FIELDS = [ 'repos', 'worktreesByRepo', 'lastKnownRelayPtyIdByTabId', - 'lastVisitedAtByWorktreeId' + 'lastVisitedAtByWorktreeId', + 'defaultTerminalTabsAppliedByWorktreeId' ] as const satisfies readonly (keyof WorkspaceSessionSnapshot)[] type _MissingSessionField = Exclude< @@ -349,7 +351,12 @@ export function buildWorkspaceSessionPayload( // Omit when empty so sessions written by builds that never stamped // anything don't bloat the payload. See // docs/cmd-j-empty-query-ordering.md. - lastVisitedAtByWorktreeId: buildLastVisitedAtByWorktreeId(snapshot) + lastVisitedAtByWorktreeId: buildLastVisitedAtByWorktreeId(snapshot), + defaultTerminalTabsAppliedByWorktreeId: + snapshot.defaultTerminalTabsAppliedByWorktreeId && + Object.keys(snapshot.defaultTerminalTabsAppliedByWorktreeId).length > 0 + ? snapshot.defaultTerminalTabsAppliedByWorktreeId + : undefined } return pruneLocalTerminalScrollbackBuffers(payload, snapshot.repos) diff --git a/src/renderer/src/lib/worktree-activation.test.ts b/src/renderer/src/lib/worktree-activation.test.ts index 47e90c547..04bec5792 100644 --- a/src/renderer/src/lib/worktree-activation.test.ts +++ b/src/renderer/src/lib/worktree-activation.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: these activation cases share one mock store and assert ordering across startup, setup, issue commands, and default tabs. */ import { afterEach, describe, expect, it, vi } from 'vitest' import type { SetupScriptLaunchMode } from '../../../shared/types' import { ensureWorktreeHasInitialTerminal } from './worktree-activation' @@ -26,9 +27,12 @@ afterEach(() => { function createMockStore(overrides: Record = {}) { return { tabsByWorktree: {} as Record, + defaultTerminalTabsAppliedByWorktreeId: {} as Record, createTab: vi.fn(() => ({ id: 'tab-1' })), setActiveTab: vi.fn(), setTabCustomTitle: vi.fn(), + setTabColor: vi.fn(), + markDefaultTerminalTabsApplied: vi.fn(), reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 0 })), queueTabStartupCommand: vi.fn(), queueTabSetupSplit: vi.fn(), @@ -80,6 +84,82 @@ describe('ensureWorktreeHasInitialTerminal', () => { expect(store.queueTabSetupSplit).not.toHaveBeenCalled() }) + it('creates configured default tabs once with title, color, and opted-in commands', () => { + let createdIndex = 0 + const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` })) + const store = createMockStore({ createTab }) + + const result = ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + undefined, + undefined, + undefined, + { + runCommands: true, + tabs: [ + { title: 'Claude', color: '#f97316', command: 'claude' }, + { title: 'LocalHost', color: '#9ca3af', command: 'pnpm dev' } + ] + } + ) + + expect(result).toBe('tab-1') + expect(store.markDefaultTerminalTabsApplied).toHaveBeenCalledWith('wt-1') + expect(createTab).toHaveBeenCalledTimes(2) + expect(createTab).toHaveBeenNthCalledWith(1, 'wt-1', undefined, undefined, { + pendingActivationSpawn: true, + recordInteraction: false + }) + expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-1', 'Claude', { + recordInteraction: false + }) + expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-2', 'LocalHost', { + recordInteraction: false + }) + expect(store.setTabColor).toHaveBeenCalledWith('tab-1', '#f97316') + expect(store.setTabColor).toHaveBeenCalledWith('tab-2', '#9ca3af') + expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-1', { command: 'claude' }) + expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-2', { command: 'pnpm dev' }) + expect(store.setActiveTab).toHaveBeenLastCalledWith('tab-1') + }) + + it('does not run default tab commands when command execution is not approved', () => { + const store = createMockStore() + + ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, undefined, undefined, { + runCommands: false, + tabs: [{ title: 'Server', command: 'pnpm dev' }] + }) + + expect(store.queueTabStartupCommand).not.toHaveBeenCalled() + expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-1', 'Server', { + recordInteraction: false + }) + }) + + it('does not duplicate default tabs after the worktree marker is persisted', () => { + const store = createMockStore({ + defaultTerminalTabsAppliedByWorktreeId: { 'wt-1': true } + }) + + ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, undefined, undefined, { + runCommands: true, + tabs: [ + { title: 'Claude', command: 'claude' }, + { title: 'Server', command: 'pnpm dev' } + ] + }) + + expect(store.createTab).toHaveBeenCalledTimes(1) + expect(store.setTabCustomTitle).not.toHaveBeenCalledWith('tab-1', 'Claude', { + recordInteraction: false + }) + expect(store.queueTabStartupCommand).not.toHaveBeenCalledWith('tab-1', { + command: 'claude' + }) + }) + it('does not create a local fallback tab in the paired web runtime client', () => { ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true useAppStore.setState((state) => ({ diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index d58c4177a..dcf93e53c 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -1,7 +1,9 @@ +/* eslint-disable max-lines -- Why: worktree activation is a single ordered flow spanning startup, setup, issue commands, and default tabs; splitting it would obscure sequencing guarantees. */ import type { SetupSplitDirection, TuiAgent, Worktree, + WorktreeDefaultTabsLaunch, WorktreeSetupLaunch } from '../../../shared/types' import type { EventProps } from '../../../shared/telemetry-events' @@ -39,6 +41,7 @@ export type IssueCommandLaunch = type WorktreeActivationStore = { tabsByWorktree: Record + defaultTerminalTabsAppliedByWorktreeId: Record createTab: ( worktreeId: string, targetGroupId?: string, @@ -55,6 +58,8 @@ type WorktreeActivationStore = { title: string | null, opts?: { recordInteraction?: boolean } ) => void + setTabColor: (tabId: string, color: string | null) => void + markDefaultTerminalTabsApplied: (worktreeId: string) => void reconcileWorktreeTabModel: (worktreeId: string) => { renderableTabCount: number } queueTabStartupCommand: ( tabId: string, @@ -139,6 +144,7 @@ export function activateAndRevealWorktree( telemetry?: AgentStartedTelemetry } setup?: WorktreeSetupLaunch + defaultTabs?: WorktreeDefaultTabsLaunch issueCommand?: IssueCommandLaunch sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior'] } @@ -192,7 +198,8 @@ export function activateAndRevealWorktree( worktreeId, opts?.startup ?? buildCreatedAgentReopenStartup(wt), opts?.setup, - opts?.issueCommand + opts?.issueCommand, + opts?.defaultTabs ) // 5. Clear sidebar filters that would hide the target worktree @@ -223,7 +230,8 @@ export function ensureWorktreeHasInitialTerminal( telemetry?: AgentStartedTelemetry }, setup?: WorktreeSetupLaunch, - issueCommand?: IssueCommandLaunch + issueCommand?: IssueCommandLaunch, + defaultTabs?: WorktreeDefaultTabsLaunch ): string | null { const { renderableTabCount } = store.reconcileWorktreeTabModel(worktreeId) // Why: activation can now restore editor- or browser-only worktrees from the @@ -240,6 +248,18 @@ export function ensureWorktreeHasInitialTerminal( return null } + const templatedTabId = applyDefaultTerminalTabs( + store, + worktreeId, + startup, + setup, + issueCommand, + defaultTabs + ) + if (templatedTabId) { + return templatedTabId + } + // Why: this tab only exists because the user clicked/activated a worktree // that had no focusable surface yet. Tag it so the resulting PTY spawn // does not count as activity and reshuffle the Recent sort. Explicit @@ -265,7 +285,73 @@ export function ensureWorktreeHasInitialTerminal( if (startup) { store.queueTabStartupCommand(terminalTab.id, startup) } + queueSetupAndIssueCommands(store, worktreeId, terminalTab.id, setup, issueCommand) + return terminalTab.id +} + +function applyDefaultTerminalTabs( + store: WorktreeActivationStore, + worktreeId: string, + startup: + | { + command: string + env?: Record + initialAgentStatus?: { agent: TuiAgent; prompt: string } + telemetry?: AgentStartedTelemetry + } + | undefined, + setup: WorktreeSetupLaunch | undefined, + issueCommand: IssueCommandLaunch | undefined, + defaultTabs: WorktreeDefaultTabsLaunch | undefined +): string | null { + if (!defaultTabs || store.defaultTerminalTabsAppliedByWorktreeId[worktreeId]) { + return null + } + store.markDefaultTerminalTabsApplied(worktreeId) + if (defaultTabs.tabs.length === 0) { + return null + } + + let firstTabId: string | null = null + for (const [index, template] of defaultTabs.tabs.entries()) { + const tab = store.createTab(worktreeId, undefined, undefined, { + pendingActivationSpawn: true, + recordInteraction: false + }) + if (index === 0) { + firstTabId = tab.id + } + if (template.title) { + store.setTabCustomTitle(tab.id, template.title, { recordInteraction: false }) + } + if (template.color) { + store.setTabColor(tab.id, template.color) + } + const templateCommand = template.command?.trim() + if (templateCommand && defaultTabs.runCommands && !(index === 0 && startup)) { + store.queueTabStartupCommand(tab.id, { command: templateCommand }) + } + } + + if (!firstTabId) { + return null + } + store.setActiveTab(firstTabId) + if (startup) { + store.queueTabStartupCommand(firstTabId, startup) + } + queueSetupAndIssueCommands(store, worktreeId, firstTabId, setup, issueCommand) + return firstTabId +} + +function queueSetupAndIssueCommands( + store: WorktreeActivationStore, + worktreeId: string, + terminalTabId: string, + setup: WorktreeSetupLaunch | undefined, + issueCommand: IssueCommandLaunch | undefined +): void { // Why: the setup script launch location is user-configurable. The default // 'new-tab' creates a separate background tab titled "Setup" without // stealing focus from the main terminal, so setup output never crowds the @@ -284,14 +370,14 @@ export function ensureWorktreeHasInitialTerminal( // Why: createTab auto-activates the new tab. Revert activation so the // user's focus stays on the primary terminal — per the design, the // Setup tab runs unattended in the background. - store.setActiveTab(terminalTab.id) + store.setActiveTab(terminalTabId) // Why: customTitle wins over the auto-generated "Terminal N" label // everywhere the tab is rendered (tab bar, switcher, session snapshots), // so labeling via customTitle is the single authoritative source. store.setTabCustomTitle(setupTab.id, 'Setup', { recordInteraction: false }) store.queueTabStartupCommand(setupTab.id, setupCommand) } else { - store.queueTabSetupSplit(terminalTab.id, { + store.queueTabSetupSplit(terminalTabId, { ...setupCommand, direction: mode === 'split-horizontal' ? 'horizontal' : 'vertical' }) @@ -314,10 +400,8 @@ export function ensureWorktreeHasInitialTerminal( env: issueCommand.envVars } : { command: issueCommand.command, env: issueCommand.env } - store.queueTabIssueCommandSplit(terminalTab.id, queuedIssueCommand) + store.queueTabIssueCommandSplit(terminalTabId, queuedIssueCommand) } - - return terminalTab.id } // Why: break the import cycle — the nav-history slice must call diff --git a/src/renderer/src/store/slices/terminals-hydration.test.ts b/src/renderer/src/store/slices/terminals-hydration.test.ts index ace73b30b..7ba26f57a 100644 --- a/src/renderer/src/store/slices/terminals-hydration.test.ts +++ b/src/renderer/src/store/slices/terminals-hydration.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: hydration regressions share store setup and session invariants that are easier to audit together. */ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) @@ -201,6 +202,31 @@ describe('hydrateWorkspaceSession', () => { ]) }) + it('hydrates the default-tab idempotency marker', () => { + const store = createTestStore() + const worktreeId = 'repo1::/wt-1' + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/wt-1' })] + } + }) + + const session: WorkspaceSessionState = { + activeRepoId: 'repo1', + activeWorktreeId: worktreeId, + activeTabId: null, + terminalLayoutsByTabId: {}, + tabsByWorktree: {}, + defaultTerminalTabsAppliedByWorktreeId: { [worktreeId]: true } + } + + store.getState().hydrateWorkspaceSession(session) + + expect(store.getState().defaultTerminalTabsAppliedByWorktreeId).toEqual({ + [worktreeId]: true + }) + }) + it('seeds worktree nav history with the restored active worktree', () => { // Why: without seeding, the first sidebar click after startup becomes the // only history entry, so Back stays disabled until the user clicks a diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index d82eccf67..64e2f29ed 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -259,6 +259,8 @@ export type TerminalSlice = { pendingIssueCommandSplitByTabId: Record }> tabBarOrderByWorktree: Record workspaceSessionReady: boolean + defaultTerminalTabsAppliedByWorktreeId: Record + markDefaultTerminalTabsApplied: (worktreeId: string) => void /** True only after hydrateWorkspaceSession ran from a real load of * orca-data.json. Guards the debounced session writer so that a crash * during early startup (fetchRepos / fetchAllWorktrees / session.get / @@ -430,6 +432,19 @@ export const createTerminalSlice: StateCreator pendingIssueCommandSplitByTabId: {}, tabBarOrderByWorktree: {}, workspaceSessionReady: false, + defaultTerminalTabsAppliedByWorktreeId: {}, + markDefaultTerminalTabsApplied: (worktreeId) => + set((s) => { + if (s.defaultTerminalTabsAppliedByWorktreeId[worktreeId]) { + return {} + } + return { + defaultTerminalTabsAppliedByWorktreeId: { + ...s.defaultTerminalTabsAppliedByWorktreeId, + [worktreeId]: true + } + } + }), hydrationSucceeded: false, setHydrationSucceeded: (value) => { set({ hydrationSucceeded: value }) @@ -2103,6 +2118,8 @@ export const createTerminalSlice: StateCreator // after hydration) — not here — because SSH worktrees may still be // appearing in worktreesByRepo at this moment. lastVisitedAtByWorktreeId: session.lastVisitedAtByWorktreeId ?? {}, + defaultTerminalTabsAppliedByWorktreeId: + session.defaultTerminalTabsAppliedByWorktreeId ?? {}, pendingReconnectWorktreeIds, pendingReconnectTabByWorktree, pendingReconnectPtyIdByTabId, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index c965fe4cc..8e6a70c56 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -429,6 +429,7 @@ export function getDefaultWorkspaceSession(): WorkspaceSessionState { activeBrowserTabIdByWorktree: {}, activeFileIdByWorktree: {}, activeTabTypeByWorktree: {}, - browserUrlHistory: [] + browserUrlHistory: [], + defaultTerminalTabsAppliedByWorktreeId: {} } } diff --git a/src/shared/remote-workspace-session-projection.test.ts b/src/shared/remote-workspace-session-projection.test.ts index efd91adb1..76fdadc8d 100644 --- a/src/shared/remote-workspace-session-projection.test.ts +++ b/src/shared/remote-workspace-session-projection.test.ts @@ -45,6 +45,10 @@ describe('remote workspace session projection', () => { remoteSessionIdsByTabId: { 'tab-1': 'pty-1', 'tab-local': 'pty-local' + }, + defaultTerminalTabsAppliedByWorktreeId: { + 'repo-a::/srv/app': true as const, + 'repo-local::/tmp/local': true as const } } @@ -61,6 +65,7 @@ describe('remote workspace session projection', () => { 'tab-1': { root: null, activeLeafId: null, expandedLeafId: null } }) expect(projected.remoteSessionIdsByTabId).toEqual({ 'tab-1': 'pty-1' }) + expect(projected.defaultTerminalTabsAppliedByWorktreePath).toEqual({ '/srv/app': true }) }) it('imports projected terminal state into this client repo id', () => { @@ -85,7 +90,8 @@ describe('remote workspace session projection', () => { terminalLayoutsByTabId: { 'tab-1': { root: null, activeLeafId: null, expandedLeafId: null } }, - remoteSessionIdsByTabId: { 'tab-1': 'pty-1' } + remoteSessionIdsByTabId: { 'tab-1': 'pty-1' }, + defaultTerminalTabsAppliedByWorktreePath: { '/srv/app': true } }, { resolveWorktreeId: (path) => (path === '/srv/app' ? 'repo-b::/srv/app' : null) } ) @@ -97,6 +103,9 @@ describe('remote workspace session projection', () => { worktreeId: 'repo-b::/srv/app' }) expect(session.remoteSessionIdsByTabId).toEqual({ 'tab-1': 'pty-1' }) + expect(session.defaultTerminalTabsAppliedByWorktreeId).toEqual({ + 'repo-b::/srv/app': true + }) }) it('imports active worktree metadata even when the worktree has no terminal tabs', () => { diff --git a/src/shared/remote-workspace-session-projection.ts b/src/shared/remote-workspace-session-projection.ts index e86417835..647b577db 100644 --- a/src/shared/remote-workspace-session-projection.ts +++ b/src/shared/remote-workspace-session-projection.ts @@ -79,6 +79,17 @@ export function exportRemoteWorkspaceSession( } } + const defaultTerminalTabsAppliedByWorktreePath: Record = {} + for (const worktreeId of Object.keys(session.defaultTerminalTabsAppliedByWorktreeId ?? {})) { + if (!options.isTargetWorktree(worktreeId)) { + continue + } + const worktreePath = worktreePathFromId(worktreeId) + if (worktreePath) { + defaultTerminalTabsAppliedByWorktreePath[worktreePath] = true + } + } + return { activeWorktreePath, activeTabId, @@ -100,7 +111,8 @@ export function exportRemoteWorkspaceSession( ) ) : undefined, - lastVisitedAtByWorktreePath + lastVisitedAtByWorktreePath, + defaultTerminalTabsAppliedByWorktreePath } } @@ -157,6 +169,14 @@ export function importRemoteWorkspaceSession( } } + const defaultTerminalTabsAppliedByWorktreeId: Record = {} + for (const worktreePath of Object.keys(remote.defaultTerminalTabsAppliedByWorktreePath ?? {})) { + const worktreeId = resolvePath(worktreePath) + if (worktreeId) { + defaultTerminalTabsAppliedByWorktreeId[worktreeId] = true + } + } + return { ...session, activeRepoId: activeWorktreeId ? (splitWorktreeId(activeWorktreeId)?.repoId ?? null) : null, @@ -179,6 +199,7 @@ export function importRemoteWorkspaceSession( ) ) : undefined, - lastVisitedAtByWorktreeId + lastVisitedAtByWorktreeId, + defaultTerminalTabsAppliedByWorktreeId } } diff --git a/src/shared/remote-workspace-types.ts b/src/shared/remote-workspace-types.ts index 0a60057d2..419bb7651 100644 --- a/src/shared/remote-workspace-types.ts +++ b/src/shared/remote-workspace-types.ts @@ -13,6 +13,7 @@ export type RemoteWorkspaceSession = { activeTabIdByWorktreePath?: Record remoteSessionIdsByTabId?: Record lastVisitedAtByWorktreePath?: Record + defaultTerminalTabsAppliedByWorktreePath?: Record } export type RemoteWorkspaceSnapshot = { diff --git a/src/shared/types.ts b/src/shared/types.ts index 9cd5a2a67..4ff2d78f1 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -698,6 +698,10 @@ export type WorkspaceSessionState = { * older builds — hydration tolerates missing/partial maps and the * active worktree is seeded on first restore. */ lastVisitedAtByWorktreeId?: Record + /** Worktrees whose repo-defined default terminal tabs have already been + * considered. Persisted so closing all tabs and re-opening the workspace + * does not recreate the template. */ + defaultTerminalTabsAppliedByWorktreeId?: Record } export type WorkspaceSessionPatch = Partial @@ -1463,6 +1467,13 @@ export type OrcaHooks = { archive?: string // Runs before worktree is archived } issueCommand?: string // Shared default command for linked GitHub issues + defaultTabs?: OrcaDefaultTabTemplate[] // Terminal tabs to create once for a new worktree +} + +export type OrcaDefaultTabTemplate = { + title?: string + color?: string + command?: string } export type RepoHookSettings = { @@ -1487,6 +1498,11 @@ export type WorktreeStartupLaunch = { env?: Record } +export type WorktreeDefaultTabsLaunch = { + tabs: OrcaDefaultTabTemplate[] + runCommands: boolean +} + export type CreateSparseCheckoutRequest = { directories: string[] /** Set when the directories came from a saved preset and the user did not @@ -1552,6 +1568,7 @@ export type CreateWorktreeResult = { lineage?: WorktreeLineage | null warnings?: WorktreeLineageWarning[] setup?: WorktreeSetupLaunch + defaultTabs?: WorktreeDefaultTabsLaunch warning?: string initialBaseStatus?: WorktreeBaseStatusEvent localBaseRefRefresh?: LocalBaseRefRefreshResult diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts index 409658740..7b02044a6 100644 --- a/src/shared/workspace-session-schema.test.ts +++ b/src/shared/workspace-session-schema.test.ts @@ -222,6 +222,26 @@ describe('parseWorkspaceSession', () => { } }) + it('accepts default-tab idempotency markers', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + defaultTerminalTabsAppliedByWorktreeId: { + 'repo1::/path/wt1': true + } + }) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.defaultTerminalTabsAppliedByWorktreeId).toEqual({ + 'repo1::/path/wt1': true + }) + } + }) + it('caps oversized browser history while parsing legacy workspace sessions', () => { const result = parseWorkspaceSession({ activeRepoId: null, diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 440d53250..4c77de68c 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -252,7 +252,8 @@ export const workspaceSessionStateSchema: z.ZodType = z.o }, z.record(z.string(), z.number().finite().nonnegative()) ) - .optional() + .optional(), + defaultTerminalTabsAppliedByWorktreeId: z.record(z.string(), z.literal(true)).optional() }) export type ParsedWorkspaceSession =