Add default tab templates from orca.yaml

This commit is contained in:
Neil 2026-05-29 10:26:41 -07:00
parent 5d769d19b9
commit 372469b752
29 changed files with 676 additions and 119 deletions

View File

@ -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
})
})
})

View File

@ -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<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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

View File

@ -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<ReturnType<typeof getEffectiveHooksFromConfig>> {
return getEffectiveHooksFromConfig(repo, await readRemoteOrcaYaml(fsProvider, hooksRootPath))
}
async function readRemoteOrcaYaml(
fsProvider: IFilesystemProvider,
hooksRootPath: string
): Promise<ReturnType<typeof parseOrcaYaml>> {
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 } : {})
}
}

View File

@ -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 () => {

View File

@ -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 }

View File

@ -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) =>

View File

@ -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: (

View File

@ -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<string, string> }
startup?: { command: string; env?: Record<string, string> }
defaultTabs?: WorktreeDefaultTabsLaunch
}) => void
): (() => void) => {
const listener = (
@ -2561,6 +2563,7 @@ const api = {
worktreeId: string
setup?: { runnerScriptPath: string; envVars: Record<string, string> }
startup?: { command: string; env?: Record<string, string> }
defaultTabs?: WorktreeDefaultTabsLaunch
}
) => callback(data)
ipcRenderer.on('ui:activateWorktree', listener)

View File

@ -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: {

View File

@ -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) => {

View File

@ -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'] = {

View File

@ -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.

View File

@ -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) {

View File

@ -27,7 +27,8 @@ function createSnapshot(browserUrlHistory: BrowserHistoryEntry[]): WorkspaceSess
repos: [],
worktreesByRepo: {},
lastKnownRelayPtyIdByTabId: {},
lastVisitedAtByWorktreeId: {}
lastVisitedAtByWorktreeId: {},
defaultTerminalTabsAppliedByWorktreeId: {}
}
}

View File

@ -28,6 +28,7 @@ function createSnapshot(
worktreesByRepo: {},
lastKnownRelayPtyIdByTabId: {},
lastVisitedAtByWorktreeId: {},
defaultTerminalTabsAppliedByWorktreeId: {},
...overrides
}
}

View File

@ -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', () => {

View File

@ -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({

View File

@ -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)

View File

@ -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<string, unknown> = {}) {
return {
tabsByWorktree: {} as Record<string, { id: string }[]>,
defaultTerminalTabsAppliedByWorktreeId: {} as Record<string, true>,
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) => ({

View File

@ -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<string, { id: string }[]>
defaultTerminalTabsAppliedByWorktreeId: Record<string, true>
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<string, string>
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

View File

@ -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

View File

@ -259,6 +259,8 @@ export type TerminalSlice = {
pendingIssueCommandSplitByTabId: Record<string, { command: string; env?: Record<string, string> }>
tabBarOrderByWorktree: Record<string, string[]>
workspaceSessionReady: boolean
defaultTerminalTabsAppliedByWorktreeId: Record<string, true>
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<AppState, [], [], TerminalSlice>
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<AppState, [], [], TerminalSlice>
// 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,

View File

@ -429,6 +429,7 @@ export function getDefaultWorkspaceSession(): WorkspaceSessionState {
activeBrowserTabIdByWorktree: {},
activeFileIdByWorktree: {},
activeTabTypeByWorktree: {},
browserUrlHistory: []
browserUrlHistory: [],
defaultTerminalTabsAppliedByWorktreeId: {}
}
}

View File

@ -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', () => {

View File

@ -79,6 +79,17 @@ export function exportRemoteWorkspaceSession(
}
}
const defaultTerminalTabsAppliedByWorktreePath: Record<string, true> = {}
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<string, true> = {}
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
}
}

View File

@ -13,6 +13,7 @@ export type RemoteWorkspaceSession = {
activeTabIdByWorktreePath?: Record<string, string | null>
remoteSessionIdsByTabId?: Record<string, string>
lastVisitedAtByWorktreePath?: Record<string, number>
defaultTerminalTabsAppliedByWorktreePath?: Record<string, true>
}
export type RemoteWorkspaceSnapshot = {

View File

@ -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<string, number>
/** 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<string, true>
}
export type WorkspaceSessionPatch = Partial<WorkspaceSessionState>
@ -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<string, string>
}
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

View File

@ -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,

View File

@ -252,7 +252,8 @@ export const workspaceSessionStateSchema: z.ZodType<WorkspaceSessionState> = z.o
},
z.record(z.string(), z.number().finite().nonnegative())
)
.optional()
.optional(),
defaultTerminalTabsAppliedByWorktreeId: z.record(z.string(), z.literal(true)).optional()
})
export type ParsedWorkspaceSession =