Merge branch 'main' into improve-pr-actions-customization

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing
2026-06-08 13:35:33 -07:00
co-authored by Orca
122 changed files with 6262 additions and 707 deletions
+10 -2
View File
@@ -6,8 +6,11 @@ on:
- '.github/workflows/golden-e2e-experiment.yml'
- 'package.json'
- 'tests/e2e/golden-core-flows.spec.ts'
- 'tests/e2e/fixtures/terminal-emoji-table.md'
- 'tests/e2e/helpers/**'
- 'tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts'
- 'src/renderer/src/components/sidebar/SidebarHeader.tsx'
- 'src/renderer/src/lib/pane-manager/**'
workflow_dispatch:
inputs:
ref:
@@ -69,11 +72,15 @@ jobs:
- name: Run golden E2E tests on Linux
if: runner.os == 'Linux'
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
run: |
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
- name: Run golden E2E tests on macOS
if: runner.os == 'macOS'
run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
run: |
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
- name: Run golden E2E tests on Windows
if: runner.os == 'Windows'
@@ -82,6 +89,7 @@ jobs:
$env:SKIP_BUILD = '1'
$env:ORCA_E2E_FORWARD_APP_LOGS = '1'
pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
pnpm run test:e2e:terminal-rendering-golden
- name: Upload Playwright traces
if: failure()
@@ -211,4 +211,32 @@ describe('Electron runtime package contract', () => {
expect(uploadStep.uses).toBe('actions/upload-artifact@v7')
expect(uploadStep.with.path).toBe('${{ env.ORCA_E2E_TERMINAL_PERF_REPORT_PATH }}')
})
it('keeps terminal rendering regressions in the fast golden E2E gate', () => {
const packageScripts = packageJson.scripts
const goldenWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/golden-e2e-experiment.yml'), 'utf8')
)
const steps = goldenWorkflow.jobs['golden-e2e'].steps
const linuxRunStep = steps.find((step) => step.name === 'Run golden E2E tests on Linux')
const macRunStep = steps.find((step) => step.name === 'Run golden E2E tests on macOS')
const windowsRunStep = steps.find((step) => step.name === 'Run golden E2E tests on Windows')
const pullRequestPaths = goldenWorkflow.on.pull_request.paths
expect(packageScripts['test:e2e:terminal-rendering-golden']).toContain(
'@terminal-rendering-golden'
)
expect(packageScripts['test:e2e:terminal-rendering-golden']).toContain(
'terminal-raw-emoji-table-scroll-restore.spec.ts'
)
expect(packageScripts['test:e2e:terminal-rendering-golden']).not.toContain(
'terminal-long-table-scroll-restore.spec.ts'
)
for (const runStep of [linuxRunStep, macRunStep, windowsRunStep]) {
expect(runStep.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
}
expect(pullRequestPaths).toContain('tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts')
expect(pullRequestPaths).toContain('tests/e2e/fixtures/terminal-emoji-table.md')
expect(pullRequestPaths).toContain('src/renderer/src/lib/pane-manager/**')
})
})
+13 -5
View File
@@ -279,6 +279,7 @@ function prepareDevCliWrapper() {
const binDir = path.join(repoRoot, 'out', 'bin')
mkdirSync(binDir, { recursive: true })
const userDataPath = getDevUserDataPath()
const userDataBinDir = path.join(userDataPath, 'cli', 'bin')
const cliPath = path.join(repoRoot, 'out', 'cli', 'index.js')
const electronBin = getElectronExecutable()
@@ -289,13 +290,20 @@ function prepareDevCliWrapper() {
'utf8'
)
} else {
const wrapperContent = `#!/usr/bin/env bash\nexport ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}\nexport ORCA_APP_EXECUTABLE=${JSON.stringify(electronBin)}\nexport ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1\nexec node ${JSON.stringify(cliPath)} "$@"\n`
const wrapperPath = path.join(binDir, 'orca-dev')
writeFileSync(
wrapperPath,
`#!/usr/bin/env bash\nexport ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}\nexport ORCA_APP_EXECUTABLE=${JSON.stringify(electronBin)}\nexport ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1\nexec node ${JSON.stringify(cliPath)} "$@"\n`,
'utf8'
)
writeFileSync(wrapperPath, wrapperContent, 'utf8')
chmodSync(wrapperPath, 0o755)
mkdirSync(userDataBinDir, { recursive: true })
for (const commandName of ['orca-dev', 'orca']) {
const userDataWrapperPath = path.join(userDataBinDir, commandName)
// Why: dev Orca terminals prepend this directory to PATH; refreshing the
// `orca` alias prevents stale global/userData wrappers from hijacking
// Orca-owned commands such as `orca claude-teams`.
writeFileSync(userDataWrapperPath, wrapperContent, 'utf8')
chmodSync(userDataWrapperPath, 0o755)
}
}
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ''}`
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.51",
"version": "1.4.52-rc.0",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -60,6 +60,7 @@
"build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac",
"build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux",
"test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headless",
"test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:terminal-perf": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-typing-latency.spec.ts tests/e2e/terminal-foreground-redraw-freeze.spec.ts tests/e2e/terminal-output-scheduler.spec.ts tests/e2e/terminal-hidden-tui-visual-restore.spec.ts tests/e2e/artificial-opencode-terminal-load.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2",
"test:e2e:terminal-perf:scale": "pnpm run ensure:electron-runtime && node config/scripts/run-terminal-scale-perf-e2e.mjs",
"test:e2e:terminal-perf:scale:report": "pnpm run ensure:electron-runtime && node config/scripts/run-terminal-scale-perf-report-gate.mjs",
+63
View File
@@ -1,7 +1,41 @@
import { spawn } from 'child_process'
import type { CommandHandler } from '../dispatch'
import { formatCliStatus, formatStatus, printResult } from '../format'
import { RuntimeClientError, serveOrcaApp } from '../runtime-client'
function envRecord(): Record<string, string> {
return Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined)
)
}
function withTeammateModeAuto(args: string[]): string[] {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--teammate-mode' || arg.startsWith('--teammate-mode=')) {
return args
}
}
return ['--teammate-mode', 'auto', ...args]
}
async function runClaudeAgentTeams(env: Record<string, string>, args: string[]): Promise<number> {
return await new Promise((resolve, reject) => {
const child = spawn('claude', withTeammateModeAuto(args), {
stdio: 'inherit',
env
})
child.once('error', reject)
child.once('exit', (code, signal) => {
if (typeof code === 'number') {
resolve(code)
return
}
resolve(signal ? 1 : 0)
})
})
}
function getOptionalServePort(flags: Map<string, string | boolean>): string | null {
if (!flags.has('port')) {
return null
@@ -18,6 +52,35 @@ function getOptionalServePort(flags: Map<string, string | boolean>): string | nu
}
export const CORE_HANDLERS: Record<string, CommandHandler> = {
'claude-teams': async ({ client }) => {
if (process.platform === 'win32') {
throw new RuntimeClientError(
'unsupported_platform',
'Claude Agent Teams native panes are not supported on Windows.'
)
}
const paneKey = process.env.ORCA_PANE_KEY
if (!paneKey) {
throw new RuntimeClientError(
'invalid_environment',
'orca claude-teams must be run inside an Orca terminal.'
)
}
const response = await client.call<{ launch: { env: Record<string, string> } }>(
'agentTeams.prepareLaunch',
{
paneKey,
env: envRecord()
}
)
process.exitCode = await runClaudeAgentTeams(
{
...envRecord(),
...response.result.launch.env
},
[]
)
},
open: async ({ client, json }) => {
const result = await client.openOrca()
printResult(result, json, formatCliStatus)
+52 -2
View File
@@ -7,13 +7,15 @@ const {
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn()
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', () => {
@@ -79,6 +81,17 @@ vi.mock('./runtime/environments', () => ({
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { EventEmitter } = await import('events')
return {
spawn: spawnMock.mockImplementation(() => {
const child = new EventEmitter()
process.nextTick(() => child.emit('exit', 0, null))
return child
})
}
})
import {
buildCurrentWorktreeSelector,
COMMAND_SPECS,
@@ -147,6 +160,7 @@ describe('orca cli worktree awareness', () => {
getDefaultUserDataPathMock.mockClear()
addEnvironmentFromPairingCodeMock.mockReset()
listEnvironmentsMock.mockReset()
spawnMock.mockClear()
addEnvironmentFromPairingCodeMock.mockReturnValue({
id: 'env-1',
name: 'desk',
@@ -240,6 +254,42 @@ describe('orca cli worktree awareness', () => {
expect(logSpy).toHaveBeenCalledTimes(1)
})
it.skipIf(process.platform === 'win32')(
'prepares and starts Claude Agent Teams in the current Orca terminal',
async () => {
process.env.ORCA_PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111'
queueFixtures(
callMock,
okFixture('req_agent_teams_prepare', {
launch: {
env: {
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
TMUX: '/tmp/orca-claude-agent-teams/team-1,0,1',
TMUX_PANE: '%1',
PATH: '/tmp/orca-shim:/usr/bin'
}
}
})
)
await main(['claude-teams'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('agentTeams.prepareLaunch', {
paneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
env: expect.objectContaining({
ORCA_PANE_KEY: 'tab-1:11111111-1111-4111-8111-111111111111'
})
})
expect(spawnMock).toHaveBeenCalledWith('claude', ['--teammate-mode', 'auto'], {
stdio: 'inherit',
env: expect.objectContaining({
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
TMUX_PANE: '%1'
})
})
}
)
it('rejects remote `worktree current` without listing worktrees from client cwd', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+29
View File
@@ -23,6 +23,10 @@ function shouldIgnoreRemoteSelection(commandPath: string[]): boolean {
}
export async function main(argv = process.argv.slice(2), cwd = process.cwd()): Promise<void> {
if (argv[0] === 'agent-teams-tmux') {
await runAgentTeamsTmuxShim(argv.slice(1))
return
}
const parsed = normalizeCommandPositionals(COMMAND_SPECS, parseArgs(argv))
const helpPath = resolveHelpPath(parsed)
if (helpPath !== null) {
@@ -76,6 +80,31 @@ export async function main(argv = process.argv.slice(2), cwd = process.cwd()): P
}
}
async function runAgentTeamsTmuxShim(argv: string[]): Promise<void> {
try {
const client = new RuntimeClient(undefined, 10_000)
const response = await client.call<{
tmux: { stdout: string; stderr: string; exitCode: number }
}>(
'agentTeams.tmuxCompat',
{
teamId: process.env.ORCA_AGENT_TEAMS_TEAM_ID,
token: process.env.ORCA_AGENT_TEAMS_TOKEN,
envPane: process.env.TMUX_PANE,
cwd: process.cwd(),
argv
},
{ timeoutMs: 10_000 }
)
process.stdout.write(response.result.tmux.stdout)
process.stderr.write(response.result.tmux.stderr)
process.exitCode = response.result.tmux.exitCode
} catch (error) {
reportCliError(error, false, { commandPath: ['agent-teams-tmux'] })
process.exitCode = 1
}
}
if (require.main === module) {
void main()
}
+10
View File
@@ -35,6 +35,16 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
allowedFlags: [...GLOBAL_FLAGS],
examples: ['orca status', 'orca status --json']
},
{
path: ['claude-teams'],
summary: 'Start Claude Code Agent Teams in the current Orca terminal',
usage: 'orca claude-teams',
allowedFlags: [...GLOBAL_FLAGS],
notes: [
'Must be run from inside an Orca terminal. Starts Claude Code Agent Teams in the current pane and opens teammates as native Orca splits.'
],
examples: ['orca claude-teams']
},
{
path: ['repo', 'list'],
summary: 'List repos registered in Orca',
+3
View File
@@ -154,6 +154,9 @@ describe('CliInstaller', () => {
expect(installed.commandPath).toBe(join(commandDir, 'orca-dev'))
expect(installed.launcherPath).toBe(join(fixture.userDataPath, 'cli', 'bin', 'orca-dev'))
await expect(readlink(installed.commandPath as string)).resolves.toBe(installed.launcherPath)
await expect(
readFile(join(fixture.userDataPath, 'cli', 'bin', 'orca'), 'utf8')
).resolves.toBe(await readFile(installed.launcherPath as string, 'utf8'))
}
)
+9
View File
@@ -801,6 +801,15 @@ async function ensureDevLauncher(args: {
encoding: 'utf8',
mode: args.platform === 'win32' ? undefined : 0o755
})
if (args.commandName === DEV_COMMAND_NAME && args.platform !== 'win32') {
// Why: dev PTYs prepend userData/cli/bin to PATH, and product-owned
// commands are documented as `orca ...`. Keep that local alias fresh
// without claiming the global production command.
await writeFile(join(dirname(launcherPath), 'orca'), content, {
encoding: 'utf8',
mode: 0o755
})
}
return launcherPath
}
@@ -45,6 +45,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
workspaceDir: testState.fakeHomeDir,
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
localBaseRefSuggestionDismissed: false,
autoRenameBranchFromWork: false,
branchPrefix: 'git-username',
branchPrefixCustom: '',
@@ -130,7 +131,6 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
experimentalTerminalAttention: false,
experimentalCompactWorktreeCards: false,
experimentalWorktreeSymlinks: false,
experimentalUnifiedNewTabLauncher: false,
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'powershell.exe',
enableGitHubAttribution: true,
+1 -1
View File
@@ -49,6 +49,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
workspaceDir: testState.fakeHomeDir,
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
localBaseRefSuggestionDismissed: false,
autoRenameBranchFromWork: false,
branchPrefix: 'git-username',
branchPrefixCustom: '',
@@ -134,7 +135,6 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
experimentalTerminalAttention: false,
experimentalCompactWorktreeCards: false,
experimentalWorktreeSymlinks: false,
experimentalUnifiedNewTabLauncher: false,
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'powershell.exe',
enableGitHubAttribution: true,
+55
View File
@@ -613,6 +613,36 @@ describe('createPtySubprocess', () => {
expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0')
})
it('uses shell wrapper when Agent Teams shim path must survive shell startup', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'linux' })
try {
createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24,
env: {
SHELL: '/bin/zsh',
PATH: '/tmp/orca-agent-teams-bin:/usr/bin',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test',
ORCA_AGENT_TEAMS_SHIM_DIR: '/tmp/orca-agent-teams-bin'
}
})
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
const lastCall = spawnMock.mock.calls.at(-1)!
expect(lastCall[1]).toEqual(['-l'])
expect(lastCall[2].env.ZDOTDIR).toMatch(ZSH_SHELL_READY_DIR)
expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0')
})
it('deletes requested env keys after merging daemon process env', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
@@ -639,6 +669,31 @@ describe('createPtySubprocess', () => {
expect(lastCall[2].env.CODEX_HOME).toBeUndefined()
})
it('honors explicit terminal env overrides after deleting requested defaults', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24,
env: {
SHELL: '/bin/bash',
TERM: 'screen-256color',
PATH: '/tmp/orca-agent-teams-bin:/usr/bin',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test'
},
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
})
const lastCall = spawnMock.mock.calls.at(-1)!
expect(lastCall[2].name).toBe('screen-256color')
expect(lastCall[2].env.TERM).toBe('screen-256color')
expect(lastCall[2].env.PATH.split(':')[0]).toBe('/tmp/orca-agent-teams-bin')
expect(lastCall[2].env.TERM_PROGRAM).toBeUndefined()
expect(lastCall[2].env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
})
it('combines HOMEDRIVE and HOMEPATH for Windows default cwd', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
+31 -3
View File
@@ -2,7 +2,7 @@
preflight validation, and lifecycle guards that must stay in one execution path. */
import * as pty from 'node-pty'
import { statSync } from 'fs'
import { win32 as pathWin32 } from 'path'
import { delimiter, win32 as pathWin32 } from 'path'
import type { SubprocessHandle } from './session'
import { DaemonProtocolError } from './types'
import {
@@ -74,6 +74,21 @@ function removeUnspecifiedPaneIdentityEnv(
}
}
function promoteAgentTeamsShimPath(
env: Record<string, string>,
requestedPath: string | undefined
): void {
if (!env.ORCA_AGENT_TEAMS_TEAM_ID || !requestedPath) {
return
}
const shimDir = requestedPath.split(delimiter)[0]
if (!shimDir) {
return
}
const currentParts = env.PATH?.split(delimiter).filter(Boolean) ?? []
env.PATH = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(delimiter)
}
function removeInheritedDevAgentHookEndpoint(
env: Record<string, string>,
explicitEnv: Record<string, string> | undefined
@@ -248,6 +263,9 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
for (const key of opts.envToDelete ?? []) {
delete env[key]
}
if (opts.env?.TERM) {
env.TERM = opts.env.TERM
}
// Why: the daemon is forked from Electron and can inherit the pane identity
// of the terminal that launched `pn dev`; each PTY must opt into its own.
removeUnspecifiedPaneIdentityEnv(env, opts.env)
@@ -377,6 +395,14 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
addOrcaWslInteropEnv(env)
}
} else {
// Why: relay-side launch modes can ask for host defaults to stay scrubbed
// even after environment normalization above.
for (const key of opts.envToDelete ?? []) {
delete env[key]
}
if (opts.env?.TERM) {
env.TERM = opts.env.TERM
}
// Why: any Orca-injected overlay env that user rc files can clobber
// needs the wrapper so the post-rc restore line runs.
const shellLaunch = opts.command
@@ -385,7 +411,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
env.ORCA_OPENCODE_CONFIG_DIR ||
env.ORCA_PI_CODING_AGENT_DIR ||
env.ORCA_OMP_CODING_AGENT_DIR ||
env.ORCA_CODEX_HOME
env.ORCA_CODEX_HOME ||
env.ORCA_AGENT_TEAMS_SHIM_DIR
? getAttributionShellLaunchConfig(shellPath)
: null
if (shellLaunch) {
@@ -393,6 +420,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
}
shellArgs = shellLaunch?.args ?? ['-l']
}
promoteAgentTeamsShimPath(env, opts.env?.PATH)
// Why: asar packaging can strip the +x bit from node-pty's spawn-helper
// binary. The main process fixes this via LocalPtyProvider, but the daemon
@@ -407,7 +435,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
let proc: pty.IPty
try {
proc = pty.spawn(shellPath, shellArgs, {
name: 'xterm-256color',
name: env.TERM ?? 'xterm-256color',
cols: size.cols,
rows: size.rows,
cwd: spawnCwd,
+4
View File
@@ -252,6 +252,7 @@ describePosix('daemon shell-ready launch config', () => {
'[[ -n "${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="${ORCA_PI_CODING_AGENT_DIR}"'
const codexRestoreLine =
'[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"'
const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0'
const ompRestoreLine =
'if [[ -z "${ORCA_PI_CODING_AGENT_DIR:-}" && -n "${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then'
const ompWrapperLine = 'command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"'
@@ -263,6 +264,9 @@ describePosix('daemon shell-ready launch config', () => {
expect(bashRc).toContain(piRestoreLine)
expect(zshrc).toContain(codexRestoreLine)
expect(zlogin).toContain(codexRestoreLine)
expect(zshrc).toContain(agentTeamsPathRestoreLine)
expect(zlogin).toContain(agentTeamsPathRestoreLine)
expect(bashRc).toContain(agentTeamsPathRestoreLine)
expect(bashRc).toContain(codexRestoreLine)
// OMP launches use ORCA_OMP_CODING_AGENT_DIR; both restore lines must be
// present so a PTY of either kind has its overlay restored after rc files.
+24
View File
@@ -102,6 +102,14 @@ __orca_restore_attribution_path() {
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
}
__orca_restore_attribution_path
__orca_restore_agent_teams_path() {
[[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
case "$PATH" in
"\${ORCA_AGENT_TEAMS_SHIM_DIR}"|"\${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;;
esac
export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
}
__orca_restore_agent_teams_path
# Why: user startup files may set the default OpenCode config after Orca's
# spawn env; restore the PTY-scoped overlay before the first prompt.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
@@ -212,6 +220,14 @@ __orca_restore_attribution_path() {
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
}
[[ ! -o login ]] && __orca_restore_attribution_path
__orca_restore_agent_teams_path() {
[[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
case "$PATH" in
"\${ORCA_AGENT_TEAMS_SHIM_DIR}"|"\${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;;
esac
export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
}
[[ ! -o login ]] && __orca_restore_agent_teams_path
if [[ ! -o login ]]; then
# Why: ~/.zshrc can export the user's default OpenCode config after spawn.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
@@ -273,6 +289,14 @@ __orca_restore_attribution_path() {
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
}
__orca_restore_attribution_path
__orca_restore_agent_teams_path() {
[[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
case "$PATH" in
"\${ORCA_AGENT_TEAMS_SHIM_DIR}"|"\${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;;
esac
export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
}
__orca_restore_agent_teams_path
# Why: .zlogin is the final login startup file before the prompt is shown.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
+1
View File
@@ -12,6 +12,7 @@ export type EmulatorErrorCode =
| 'emulator_no_active'
| 'emulator_device_not_found'
| 'emulator_helper_failed'
| 'emulator_simctl_unavailable'
| 'emulator_not_macos'
| 'emulator_disabled'
| 'emulator_error'
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as Os from 'os'
const { execFileMock, platformMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
platformMock: vi.fn(() => 'darwin')
}))
vi.mock('child_process', () => ({
execFile: execFileMock
}))
vi.mock('os', async (importOriginal) => {
const actual = await importOriginal<typeof Os>()
return {
...actual,
platform: platformMock
}
})
vi.mock('./serve-sim-execution', () => ({
execServeSimCommand: vi.fn()
}))
import { listSimulatorDevices } from './simctl-simulator-devices'
describe('listSimulatorDevices', () => {
beforeEach(() => {
execFileMock.mockReset()
platformMock.mockReset()
platformMock.mockReturnValue('darwin')
})
it('parses simctl devices on macOS', async () => {
execFileMock.mockImplementation((_command, _args, _options, callback) => {
callback(
null,
JSON.stringify({
devices: {
'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [
{
name: 'iPhone 17 Pro',
udid: 'AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE',
state: 'Shutdown',
isAvailable: true
}
]
}
}),
''
)
})
await expect(listSimulatorDevices()).resolves.toEqual([
{
name: 'iPhone 17 Pro',
udid: 'AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE',
state: 'Shutdown',
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-18-0',
isAvailable: true
}
])
expect(execFileMock).toHaveBeenCalledWith(
'xcrun',
['simctl', 'list', 'devices', '-j'],
{ timeout: 15_000 },
expect.any(Function)
)
})
it('returns no devices on non-macOS hosts', async () => {
platformMock.mockReturnValue('linux')
await expect(listSimulatorDevices()).resolves.toEqual([])
expect(execFileMock).not.toHaveBeenCalled()
})
it('normalizes missing simctl errors for UI and CLI callers', async () => {
execFileMock.mockImplementation((_command, _args, _options, callback) => {
callback(
Object.assign(new Error('Command failed: xcrun simctl list devices -j'), { code: 72 }),
'',
'xcrun: error: unable to find utility "simctl", not a developer tool or in PATH'
)
})
const error = await listSimulatorDevices().catch((caught: unknown) => caught)
expect(error).toMatchObject({
code: 'emulator_simctl_unavailable',
message: expect.stringContaining('Xcode Simulator tools are unavailable')
})
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).not.toContain('Command failed')
})
})
+51 -23
View File
@@ -1,4 +1,5 @@
import { execFile } from 'child_process'
import type { ExecFileException } from 'child_process'
import { platform } from 'os'
import { EmulatorError } from './emulator-errors'
import { execServeSimCommand, type ServeSimExecutable } from './serve-sim-execution'
@@ -23,6 +24,8 @@ type SimctlDeviceList = {
}
const UDID_RE = /^[0-9A-F]{8}-([0-9A-F]{4}-){3}[0-9A-F]{12}$/i
const SIMCTL_UNAVAILABLE_MESSAGE =
'Xcode Simulator tools are unavailable. Install full Xcode, open it once, then select it with `sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer`.'
function parseSimctlDevices(stdout: string): SimulatorDevice[] {
const data = JSON.parse(stdout || '{}') as SimctlDeviceList
@@ -44,22 +47,42 @@ function parseSimctlDevices(stdout: string): SimulatorDevice[] {
return devices
}
function mapSimctlError(error: ExecFileException, stderr?: string | Buffer): EmulatorError {
const raw = `${error.message}\n${stderr?.toString() ?? ''}`
const lower = raw.toLowerCase()
if (
error.code === 'ENOENT' ||
(lower.includes('unable to find utility') && lower.includes('simctl')) ||
lower.includes('not a developer tool')
) {
// Why: xcrun exposes setup problems as raw execFile text; UI and CLI need
// the actionable Xcode fix instead of "Command failed: xcrun ...".
return new EmulatorError('emulator_simctl_unavailable', SIMCTL_UNAVAILABLE_MESSAGE)
}
return new EmulatorError('emulator_error', raw.trim() || 'xcrun simctl command failed.')
}
export async function listSimulatorDevices(): Promise<SimulatorDevice[]> {
if (platform() !== 'darwin') {
return []
}
return new Promise((resolve, reject) => {
execFile('xcrun', ['simctl', 'list', 'devices', '-j'], { timeout: 15_000 }, (error, stdout) => {
if (error) {
reject(error)
return
execFile(
'xcrun',
['simctl', 'list', 'devices', '-j'],
{ timeout: 15_000 },
(error, stdout, stderr) => {
if (error) {
reject(mapSimctlError(error, stderr))
return
}
try {
resolve(parseSimctlDevices(stdout))
} catch (parseError) {
reject(parseError)
}
}
try {
resolve(parseSimctlDevices(stdout))
} catch (parseError) {
reject(parseError)
}
})
)
})
}
@@ -119,14 +142,14 @@ export async function ensureSimulatorBooted(udid: string): Promise<void> {
try {
await new Promise<void>((resolve, reject) => {
execFile('xcrun', ['simctl', 'boot', udid], { timeout: 45_000 }, (error) => {
execFile('xcrun', ['simctl', 'boot', udid], { timeout: 45_000 }, (error, _stdout, stderr) => {
if (error) {
const message = error.message.toLowerCase()
if (message.includes('booted') || message.includes('current state')) {
resolve()
return
}
reject(error)
reject(mapSimctlError(error, stderr))
return
}
resolve()
@@ -159,17 +182,22 @@ export async function shutdownSimulatorDevice(udid: string): Promise<void> {
}
await new Promise<void>((resolve, reject) => {
execFile('xcrun', ['simctl', 'shutdown', udid], { timeout: 30_000 }, (error) => {
if (!error) {
resolve()
return
execFile(
'xcrun',
['simctl', 'shutdown', udid],
{ timeout: 30_000 },
(error, _stdout, stderr) => {
if (!error) {
resolve()
return
}
const message = error.message.toLowerCase()
if (message.includes('shutdown') || message.includes('current state')) {
resolve()
return
}
reject(mapSimctlError(error, stderr))
}
const message = error.message.toLowerCase()
if (message.includes('shutdown') || message.includes('current state')) {
resolve()
return
}
reject(error)
})
)
})
}
+363 -30
View File
@@ -1,5 +1,5 @@
/* eslint-disable max-lines -- Why: addWorktree has multiple code paths (no refresh,
reset --hard vs update-ref, dirty worktree, diverged branch, custom remote) that each
owner reset safety, dirty worktree, diverged branch, custom remote) that each
need dedicated test coverage. Splitting into separate files would scatter related tests
without a meaningful boundary. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -512,9 +512,14 @@ describe('addWorktree', () => {
'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /repo-other\nHEAD def456\nbranch refs/heads/feature\n'
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
.mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count
.mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit}
.mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse remote tracking ref^{commit}
.mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain (in /repo)
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list recheck
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain recheck (in /repo)
.mockResolvedValueOnce({ stdout: '' }) // reset --hard (in /repo)
.mockResolvedValueOnce({ stdout: '' }) // worktree add
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
@@ -525,10 +530,18 @@ describe('addWorktree', () => {
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }],
[['merge-base', '--is-ancestor', 'main', 'refs/remotes/origin/main'], { cwd: '/repo' }],
[
['rev-list', '--left-right', '--count', 'refs/heads/main...refs/remotes/origin/main'],
{ cwd: '/repo' }
],
[['rev-parse', '--verify', 'refs/heads/main^{commit}'], { cwd: '/repo' }],
[['rev-parse', '--verify', 'refs/remotes/origin/main^{commit}'], { cwd: '/repo' }],
[['merge-base', '--is-ancestor', 'old-main', 'remote-main'], { cwd: '/repo' }],
[['worktree', 'list', '--porcelain'], { cwd: '/repo' }],
[['status', '--porcelain', '--untracked-files=no'], { cwd: '/repo' }],
[['reset', '--hard', 'refs/remotes/origin/main'], { cwd: '/repo' }],
[['worktree', 'list', '--porcelain'], { cwd: '/repo' }],
[['status', '--porcelain', '--untracked-files=no'], { cwd: '/repo' }],
[['reset', '--hard', 'remote-main'], { cwd: '/repo' }],
[
[
'worktree',
@@ -561,9 +574,14 @@ describe('addWorktree', () => {
'worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n\nworktree /repo-main-wt\nHEAD def456\nbranch refs/heads/main\n'
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
.mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count
.mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit}
.mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse remote tracking ref^{commit}
.mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain (in /repo-main-wt)
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list recheck
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain recheck (in /repo-main-wt)
.mockResolvedValueOnce({ stdout: '' }) // reset --hard (in /repo-main-wt)
.mockResolvedValueOnce({ stdout: '' }) // worktree add
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
@@ -572,33 +590,160 @@ describe('addWorktree', () => {
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
expect(gitExecFileAsyncMock.mock.calls[3]).toEqual([
expect(gitExecFileAsyncMock.mock.calls[6]).toEqual([
['status', '--porcelain', '--untracked-files=no'],
expect.objectContaining({ cwd: '/repo-main-wt' })
])
expect(gitExecFileAsyncMock.mock.calls[4]).toEqual([
['reset', '--hard', 'refs/remotes/origin/main'],
expect(gitExecFileAsyncMock.mock.calls[8]).toEqual([
['status', '--porcelain', '--untracked-files=no'],
expect.objectContaining({ cwd: '/repo-main-wt' })
])
expect(gitExecFileAsyncMock.mock.calls[9]).toEqual([
['reset', '--hard', 'remote-main'],
expect.objectContaining({ cwd: '/repo-main-wt' })
])
})
it('uses update-ref when localBranch is not checked out in any worktree', async () => {
it('fast-forwards local base via update-ref when localBranch is not checked out', async () => {
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n'
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
.mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count
.mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit}
.mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse remote tracking ref^{commit}
.mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
.mockResolvedValueOnce({ stdout: '' }) // update-ref
.mockResolvedValueOnce({ stdout: '' }) // update-ref refs/heads/main remote-main old-main
.mockResolvedValueOnce({ stdout: '' }) // worktree add
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
expect(gitExecFileAsyncMock.mock.calls[3]).toEqual([
['update-ref', 'refs/heads/main', 'refs/remotes/origin/main'],
expect.objectContaining({ cwd: '/repo' })
expect(result.localBaseRefRefresh).toEqual({
status: 'updated',
baseRef: 'origin/main',
localBranch: 'main'
})
// Compare-and-swap form (expected old OID) so a concurrent ref move is a no-op.
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([
'update-ref',
'refs/heads/main',
'remote-main',
'old-main'
])
// No worktree owns the branch, so no working tree is reset.
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).not.toContainEqual([
'reset',
'--hard',
'remote-main'
])
})
it('skips local base refresh when the owner worktree becomes dirty before mutation', async () => {
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
.mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count
.mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit}
.mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse remote tracking ref^{commit}
.mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain during evaluation
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list before mutation
.mockResolvedValueOnce({ stdout: ' M package.json\n' }) // status --porcelain before mutation
.mockResolvedValueOnce({ stdout: '' }) // worktree add
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
expect(result.localBaseRefRefresh).toEqual({
status: 'skipped_dirty_worktree',
baseRef: 'origin/main',
localBranch: 'main',
ownerWorktreePath: '/repo'
})
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).not.toContainEqual([
'update-ref',
'refs/heads/main',
'remote-main',
'old-main'
])
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).not.toContainEqual([
'reset',
'--hard',
'refs/heads/main'
])
})
it('skips local base refresh when the owner worktree switches branches before mutation', async () => {
const firstWorktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
const secondWorktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n'
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
.mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count
.mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit}
.mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/origin/main^{commit}
.mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs
.mockResolvedValueOnce({ stdout: firstWorktreeListOutput }) // worktree list during evaluation
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain during evaluation
.mockResolvedValueOnce({ stdout: secondWorktreeListOutput }) // worktree list before mutation
.mockResolvedValueOnce({ stdout: '' }) // worktree add
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
expect(result.localBaseRefRefresh).toEqual({
status: 'skipped_error',
baseRef: 'origin/main',
localBranch: 'main'
})
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).not.toContainEqual([
'update-ref',
'refs/heads/main',
'remote-main',
'old-main'
])
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).not.toContainEqual([
'reset',
'--hard',
'refs/heads/main'
])
})
it('skips local base refresh when owner revalidation cannot list worktrees', async () => {
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
.mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count
.mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit}
.mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/origin/main^{commit}
.mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list during evaluation
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain during evaluation
.mockRejectedValueOnce(new Error('worktree list failed')) // worktree list before mutation
.mockResolvedValueOnce({ stdout: '' }) // worktree add
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
expect(result.localBaseRefRefresh).toEqual({
status: 'skipped_error',
baseRef: 'origin/main',
localBranch: 'main'
})
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).not.toContainEqual([
'update-ref',
'refs/heads/main',
'remote-main',
'old-main'
])
})
@@ -606,7 +751,10 @@ describe('addWorktree', () => {
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
.mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count
.mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit}
.mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse remote tracking ref^{commit}
.mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
.mockResolvedValueOnce({ stdout: ' M package.json\n' }) // status --porcelain (dirty)
.mockResolvedValueOnce({ stdout: '' }) // worktree add
@@ -623,15 +771,16 @@ describe('addWorktree', () => {
ownerWorktreePath: '/repo'
})
// No reset --hard or update-ref — just base resolution, merge-base, worktree list, status, worktree add, base config, config --get, config --local set
expect(gitExecFileAsyncMock.mock.calls).toHaveLength(8)
// No reset --hard or update-ref — just base resolution, drift check, local/remote
// OIDs, ancestry check, worktree list, status, worktree add, and config writes.
expect(gitExecFileAsyncMock.mock.calls).toHaveLength(11)
expect(gitExecFileAsyncMock.mock.calls[0]?.[0]).toEqual([
'rev-parse',
'--verify',
'--quiet',
'refs/remotes/origin/main^{commit}'
])
expect(gitExecFileAsyncMock.mock.calls[4]?.[0]).toEqual([
expect(gitExecFileAsyncMock.mock.calls[7]?.[0]).toEqual([
'worktree',
'add',
'--no-track',
@@ -640,19 +789,19 @@ describe('addWorktree', () => {
'/repo-feature',
'refs/remotes/origin/main'
])
expect(gitExecFileAsyncMock.mock.calls[5]?.[0]).toEqual([
expect(gitExecFileAsyncMock.mock.calls[8]?.[0]).toEqual([
'config',
'--local',
'--replace-all',
'branch.feature/test.base',
'refs/remotes/origin/main'
])
expect(gitExecFileAsyncMock.mock.calls[6]?.[0]).toEqual([
expect(gitExecFileAsyncMock.mock.calls[9]?.[0]).toEqual([
'config',
'--get',
'push.autoSetupRemote'
])
expect(gitExecFileAsyncMock.mock.calls[7]?.[0]).toEqual([
expect(gitExecFileAsyncMock.mock.calls[10]?.[0]).toEqual([
'config',
'--local',
'push.autoSetupRemote',
@@ -676,7 +825,7 @@ describe('addWorktree', () => {
expect.objectContaining({ cwd: '/repo' })
],
[
['merge-base', '--is-ancestor', 'main', 'refs/remotes/origin/main'],
['rev-list', '--left-right', '--count', 'refs/heads/main...refs/remotes/origin/main'],
expect.objectContaining({ cwd: '/repo' })
],
[
@@ -712,6 +861,185 @@ describe('addWorktree', () => {
])
})
it('skips local base refresh when captured OIDs are no longer ancestor-safe', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '0\t2\n' }) // stale rev-list result
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'new-local\n' }) // rev-parse refs/heads/main^{commit}
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/origin/main^{commit}
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not an ancestor')) // merge-base captured OIDs
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
resolveCreationBaseConfigWrite()
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
expect(result.localBaseRefRefresh).toEqual({
status: 'skipped_not_fast_forward',
baseRef: 'origin/main',
localBranch: 'main'
})
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'],
expect.objectContaining({ cwd: '/repo' })
],
[
['rev-list', '--left-right', '--count', 'refs/heads/main...refs/remotes/origin/main'],
expect.objectContaining({ cwd: '/repo' })
],
[
['rev-parse', '--verify', 'refs/heads/main^{commit}'],
expect.objectContaining({ cwd: '/repo' })
],
[
['rev-parse', '--verify', 'refs/remotes/origin/main^{commit}'],
expect.objectContaining({ cwd: '/repo' })
],
[
['merge-base', '--is-ancestor', 'new-local', 'remote-main'],
expect.objectContaining({ cwd: '/repo' })
],
[
[
'worktree',
'add',
'--no-track',
'-b',
'feature/test',
'/repo-feature',
'refs/remotes/origin/main'
],
expect.objectContaining({ cwd: '/repo' })
],
[
[
'config',
'--local',
'--replace-all',
'branch.feature/test.base',
'refs/remotes/origin/main'
],
expect.objectContaining({ cwd: '/repo-feature' })
],
[
['config', '--get', 'push.autoSetupRemote'],
expect.objectContaining({ cwd: '/repo-feature' })
],
[
['config', '--local', 'push.autoSetupRemote', 'true'],
expect.objectContaining({ cwd: '/repo-feature' })
]
])
})
it('suggests updating the local base ref when setting is off and the branch is safely behind', async () => {
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
.mockResolvedValueOnce({ stdout: '0\t2\n' }) // rev-list --left-right --count
.mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit}
.mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/origin/main^{commit}
.mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain
.mockResolvedValueOnce({ stdout: '' }) // worktree add
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
const result = await addWorktree(
'/repo',
'/repo-feature',
'feature/test',
'origin/main',
false,
false,
{ suggestLocalBaseRefUpdate: true }
)
expect(result.localBaseRefUpdateSuggestion).toEqual({
baseRef: 'origin/main',
localBranch: 'main',
behind: 2
})
expect(gitExecFileAsyncMock.mock.calls[1]).toEqual([
['rev-list', '--left-right', '--count', 'refs/heads/main...refs/remotes/origin/main'],
{ cwd: '/repo' }
])
})
it('uses normalized branch metadata for slash-containing remotes', async () => {
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/foo/bar/main^{commit}
.mockResolvedValueOnce({ stdout: '0\t2\n' }) // rev-list --left-right --count
.mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit}
.mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/foo/bar/main^{commit}
.mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain
.mockResolvedValueOnce({ stdout: '' }) // worktree add
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
const result = await addWorktree(
'/repo',
'/repo-feature',
'feature/test',
'foo/bar/main',
false,
false,
{
suggestLocalBaseRefUpdate: true,
remoteTrackingBase: {
base: 'foo/bar/main',
branch: 'main',
ref: 'refs/remotes/foo/bar/main'
}
}
)
expect(result.localBaseRefUpdateSuggestion).toEqual({
baseRef: 'foo/bar/main',
localBranch: 'main',
behind: 2
})
expect(gitExecFileAsyncMock.mock.calls[1]).toEqual([
['rev-list', '--left-right', '--count', 'refs/heads/main...refs/remotes/foo/bar/main'],
{ cwd: '/repo' }
])
})
it('does not suggest updating the local base ref when its owner worktree is dirty', async () => {
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
.mockResolvedValueOnce({ stdout: '0\t2\n' }) // rev-list --left-right --count
.mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit}
.mockResolvedValueOnce({ stdout: 'remote-upstream-main\n' }) // rev-parse refs/remotes/upstream/main^{commit}
.mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
.mockResolvedValueOnce({ stdout: ' M package.json\n' }) // status --porcelain
.mockResolvedValueOnce({ stdout: '' }) // worktree add
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
const result = await addWorktree(
'/repo',
'/repo-feature',
'feature/test',
'origin/main',
false,
false,
{ suggestLocalBaseRefUpdate: true }
)
expect(result).toEqual({})
})
it('qualifies bare branch name as refs/heads/ when a same-named tag exists', async () => {
// Why: repos that fetch with --tags can end up with a local tag named 'main',
// making `git worktree add ... main` fail with "fatal: Ambiguous object name".
@@ -821,9 +1149,14 @@ describe('addWorktree', () => {
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/upstream/main^{commit}
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
.mockResolvedValueOnce({ stdout: '0\t3\n' }) // rev-list --left-right --count
.mockResolvedValueOnce({ stdout: 'old-main\n' }) // rev-parse refs/heads/main^{commit}
.mockResolvedValueOnce({ stdout: 'remote-upstream-main\n' }) // rev-parse refs/remotes/upstream/main^{commit}
.mockResolvedValueOnce({ stdout: '' }) // merge-base captured OIDs
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list recheck
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain recheck
.mockResolvedValueOnce({ stdout: '' }) // reset --hard
.mockResolvedValueOnce({ stdout: '' }) // worktree add
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
@@ -833,15 +1166,15 @@ describe('addWorktree', () => {
await addWorktree('/repo', '/repo-feature', 'feature/test', 'upstream/main', true)
expect(gitExecFileAsyncMock.mock.calls[1]?.[0]).toEqual([
'merge-base',
'--is-ancestor',
'main',
'refs/remotes/upstream/main'
'rev-list',
'--left-right',
'--count',
'refs/heads/main...refs/remotes/upstream/main'
])
expect(gitExecFileAsyncMock.mock.calls[4]?.[0]).toEqual([
expect(gitExecFileAsyncMock.mock.calls[9]?.[0]).toEqual([
'reset',
'--hard',
'refs/remotes/upstream/main'
'remote-upstream-main'
])
})
+249 -54
View File
@@ -10,6 +10,7 @@ import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref'
import type {
GitWorktreeInfo,
LocalBaseRefRefreshResult,
LocalBaseRefUpdateSuggestion,
RemoveWorktreeResult
} from '../../shared/types'
import { gitExecFileAsync, translateWslOutputPaths } from './runner'
@@ -18,12 +19,40 @@ import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
export type AddWorktreeResult = {
localBaseRefRefresh?: LocalBaseRefRefreshResult
localBaseRefUpdateSuggestion?: LocalBaseRefUpdateSuggestion
}
type SparseWorktreeCreateError = Error & {
cleanupFailed?: boolean
}
type AddWorktreeOptions = {
checkoutExistingBranch?: boolean
suggestLocalBaseRefUpdate?: boolean
remoteTrackingBase?: {
base: string
branch: string
ref: string
}
}
type LocalBaseRefRefreshability =
| {
refreshable: true
baseRef: string
localBranch: string
fullRef: string
remoteTrackingRef: string
localOid: string
remoteOid: string
behind: number
ownerWorktreePath?: string
}
| {
refreshable: false
result: LocalBaseRefRefreshResult
}
const SPARSE_CHECKOUT_DETECTION_CONCURRENCY = 8
function getErrorCode(error: unknown): string | undefined {
@@ -60,6 +89,177 @@ function normalizeLocalBranchRef(branch: string): string {
return branch.replace(/^refs\/heads\//, '')
}
function parseRemoteTrackingLocalBaseRef(
baseBranch: string,
remoteTrackingRef: string,
remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase']
): { baseRef: string; localBranch: string; fullRef: string } | undefined {
if (remoteTrackingBase?.ref === remoteTrackingRef) {
return {
baseRef: remoteTrackingBase.base,
localBranch: remoteTrackingBase.branch,
fullRef: `refs/heads/${remoteTrackingBase.branch}`
}
}
const remoteRefPrefix = 'refs/remotes/'
if (!remoteTrackingRef.startsWith(remoteRefPrefix)) {
return undefined
}
// Why: Only refs proven to be remote-tracking refs get refresh status.
// Local branches can contain slashes (e.g. release/2026) and must not
// produce a fake "Local 2026 was not refreshed" warning.
const shortRemoteRef = remoteTrackingRef.slice(remoteRefPrefix.length)
const slashIndex = shortRemoteRef.indexOf('/')
if (slashIndex <= 0) {
return undefined
}
const localBranch = shortRemoteRef.slice(slashIndex + 1)
return {
baseRef: baseBranch,
localBranch,
fullRef: `refs/heads/${localBranch}`
}
}
function parseRevListDrift(output: string): { ahead: number; behind: number } | null {
const [aheadStr, behindStr] = output.trim().split(/\s+/)
const ahead = Number(aheadStr)
const behind = Number(behindStr)
if (!Number.isFinite(ahead) || !Number.isFinite(behind) || ahead < 0 || behind < 0) {
return null
}
return { ahead, behind }
}
async function evaluateLocalBaseRefRefreshability(
repoPath: string,
baseBranch: string,
remoteTrackingRef: string,
remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase']
): Promise<LocalBaseRefRefreshability | undefined> {
const parsed = parseRemoteTrackingLocalBaseRef(baseBranch, remoteTrackingRef, remoteTrackingBase)
if (!parsed) {
return undefined
}
const resultBase = { baseRef: parsed.baseRef, localBranch: parsed.localBranch }
let drift: { ahead: number; behind: number }
let localOid = ''
let remoteOid = ''
try {
// Why: advisory and mutating paths must agree on "safe to fast-forward".
// `rev-list A...B` proves the local branch exists, has no local-only
// commits, and tells the toast whether it is actually behind.
const { stdout } = await gitExecFileAsync(
['rev-list', '--left-right', '--count', `${parsed.fullRef}...${remoteTrackingRef}`],
{ cwd: repoPath }
)
const parsedDrift = parseRevListDrift(stdout)
if (!parsedDrift || parsedDrift.ahead !== 0) {
return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } }
}
const { stdout: localOidOutput } = await gitExecFileAsync(
['rev-parse', '--verify', `${parsed.fullRef}^{commit}`],
{ cwd: repoPath }
)
localOid = localOidOutput.trim()
if (!localOid) {
return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } }
}
const { stdout: remoteOidOutput } = await gitExecFileAsync(
['rev-parse', '--verify', `${remoteTrackingRef}^{commit}`],
{ cwd: repoPath }
)
remoteOid = remoteOidOutput.trim()
if (!remoteOid) {
return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } }
}
await gitExecFileAsync(['merge-base', '--is-ancestor', localOid, remoteOid], { cwd: repoPath })
drift = parsedDrift
} catch {
return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } }
}
try {
// Why: if the local base branch is checked out anywhere, turning on the
// setting would only update it when that owner worktree is clean.
const { stdout: worktreeListOutput } = await gitExecFileAsync(
['worktree', 'list', '--porcelain'],
{ cwd: repoPath }
)
const worktrees = parseWorktreeList(translateWslOutputPaths(worktreeListOutput, repoPath))
const ownerWorktree = worktrees.find((wt) => wt.branch === parsed.fullRef)
if (ownerWorktree) {
const { stdout: status } = await gitExecFileAsync(
['status', '--porcelain', '--untracked-files=no'],
{ cwd: ownerWorktree.path }
)
if (status.trim()) {
return {
refreshable: false,
result: {
...resultBase,
status: 'skipped_dirty_worktree',
ownerWorktreePath: ownerWorktree.path
}
}
}
return {
refreshable: true,
...resultBase,
fullRef: parsed.fullRef,
remoteTrackingRef,
localOid,
remoteOid,
behind: drift.behind,
ownerWorktreePath: ownerWorktree.path
}
}
// Why: localBranch isn't checked out anywhere, so there is no working tree
// to desync — a bare ref fast-forward (update-ref) is safe. Omitting
// ownerWorktreePath signals the mutating path to take that branch.
return {
refreshable: true,
...resultBase,
fullRef: parsed.fullRef,
remoteTrackingRef,
localOid,
remoteOid,
behind: drift.behind
}
} catch {
return { refreshable: false, result: { ...resultBase, status: 'skipped_error' } }
}
}
async function getLocalBaseRefUpdateSuggestionForWorktreeCreate(
repoPath: string,
baseBranch: string,
remoteTrackingRef: string,
remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase']
): Promise<LocalBaseRefUpdateSuggestion | undefined> {
const evaluation = await evaluateLocalBaseRefRefreshability(
repoPath,
baseBranch,
remoteTrackingRef,
remoteTrackingBase
)
if (!evaluation?.refreshable || evaluation.behind <= 0) {
return undefined
}
return {
baseRef: evaluation.baseRef,
localBranch: evaluation.localBranch,
behind: evaluation.behind
}
}
async function persistWorktreeCreationBase(
worktreePath: string,
branch: string,
@@ -283,73 +483,56 @@ async function annotateSparseCheckoutStatus(
async function refreshLocalBaseRefForWorktreeCreate(
repoPath: string,
baseBranch: string,
remoteTrackingRef: string
remoteTrackingRef: string,
remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase']
): Promise<LocalBaseRefRefreshResult | undefined> {
const remoteRefPrefix = 'refs/remotes/'
if (!remoteTrackingRef.startsWith(remoteRefPrefix)) {
const evaluation = await evaluateLocalBaseRefRefreshability(
repoPath,
baseBranch,
remoteTrackingRef,
remoteTrackingBase
)
if (!evaluation) {
return undefined
}
// Why: Only refs proven to be remote-tracking refs get refresh status.
// Local branches can contain slashes (e.g. release/2026) and must not
// produce a fake "Local 2026 was not refreshed" warning.
const shortRemoteRef = remoteTrackingRef.slice(remoteRefPrefix.length)
const slashIndex = shortRemoteRef.indexOf('/')
if (slashIndex <= 0) {
return undefined
if (!evaluation.refreshable) {
return evaluation.result
}
const localBranch = shortRemoteRef.slice(slashIndex + 1)
const fullRef = `refs/heads/${localBranch}`
const resultBase = { baseRef: baseBranch, localBranch }
const resultBase = { baseRef: evaluation.baseRef, localBranch: evaluation.localBranch }
try {
// Why: We only fast-forward the local branch pointer. A force-move (`branch -f`)
// would silently destroy unpushed local commits if the branch has diverged from
// remote. `merge-base --is-ancestor` returns exit 0 when localBranch is an
// ancestor of baseBranch — i.e. the update is a safe fast-forward.
await gitExecFileAsync(['merge-base', '--is-ancestor', localBranch, remoteTrackingRef], {
cwd: repoPath
})
} catch {
// merge-base fails if the local branch doesn't exist or has diverged.
return { ...resultBase, status: 'skipped_not_fast_forward' }
}
try {
// Why: We need to find which worktree (if any) has localBranch checked
// out, because moving the ref without updating that worktree's files would
// leave it looking massively dirty. A sibling worktree we don't control is
// just as vulnerable as the primary one.
const { stdout: worktreeListOutput } = await gitExecFileAsync(
['worktree', 'list', '--porcelain'],
{ cwd: repoPath }
)
const worktrees = parseWorktreeList(translateWslOutputPaths(worktreeListOutput, repoPath))
const ownerWorktree = worktrees.find((wt) => wt.branch === fullRef)
if (ownerWorktree) {
// Why: localBranch is checked out in a worktree. We can only safely
// update if that worktree is clean, and we must use `reset --hard`
// (run inside that worktree) so the files move with the ref.
if (evaluation.ownerWorktreePath) {
const { stdout: worktreeListOutput } = await gitExecFileAsync(
['worktree', 'list', '--porcelain'],
{ cwd: repoPath }
)
const worktrees = parseWorktreeList(translateWslOutputPaths(worktreeListOutput, repoPath))
const currentOwner = worktrees.find((wt) => wt.branch === evaluation.fullRef)
if (!currentOwner || currentOwner.path !== evaluation.ownerWorktreePath) {
return { ...resultBase, status: 'skipped_error' }
}
const { stdout: status } = await gitExecFileAsync(
['status', '--porcelain', '--untracked-files=no'],
{ cwd: ownerWorktree.path }
{ cwd: currentOwner.path }
)
if (status.trim()) {
return {
...resultBase,
status: 'skipped_dirty_worktree',
ownerWorktreePath: ownerWorktree.path
ownerWorktreePath: currentOwner.path
}
}
await gitExecFileAsync(['reset', '--hard', remoteTrackingRef], { cwd: ownerWorktree.path })
return { ...resultBase, status: 'updated', ownerWorktreePath: ownerWorktree.path }
await gitExecFileAsync(['reset', '--hard', evaluation.remoteOid], { cwd: currentOwner.path })
return { ...resultBase, status: 'updated', ownerWorktreePath: currentOwner.path }
}
// Why: localBranch is not checked out anywhere, so there is no working
// tree to desync. `update-ref` is safe here.
await gitExecFileAsync(['update-ref', fullRef, remoteTrackingRef], { cwd: repoPath })
// Why: no owner worktree — fast-forward the bare ref. The expected-old-OID
// form makes this a no-op-safe compare-and-swap if the ref moved since the
// evaluation snapshot.
await gitExecFileAsync(
['update-ref', evaluation.fullRef, evaluation.remoteOid, evaluation.localOid],
{ cwd: repoPath }
)
return { ...resultBase, status: 'updated' }
} catch {
// update-ref/reset can fail on locked refs, filesystem errors, or unusual
@@ -376,9 +559,10 @@ export async function addWorktree(
baseBranch?: string,
refreshLocalBaseRef = false,
noCheckout = false,
options: { checkoutExistingBranch?: boolean } = {}
options: AddWorktreeOptions = {}
): Promise<AddWorktreeResult> {
let localBaseRefRefresh: LocalBaseRefRefreshResult | undefined
let localBaseRefUpdateSuggestion: LocalBaseRefUpdateSuggestion | undefined
const args = ['worktree', 'add']
let effectiveBase: string | undefined
if (noCheckout) {
@@ -406,7 +590,15 @@ export async function addWorktree(
localBaseRefRefresh = await refreshLocalBaseRefForWorktreeCreate(
repoPath,
baseBranch,
effectiveBase
effectiveBase,
options.remoteTrackingBase
)
} else if (options.suggestLocalBaseRefUpdate) {
localBaseRefUpdateSuggestion = await getLocalBaseRefUpdateSuggestionForWorktreeCreate(
repoPath,
baseBranch,
effectiveBase,
options.remoteTrackingBase
)
}
args.push(effectiveBase)
@@ -478,7 +670,10 @@ export async function addWorktree(
} catch (error) {
console.warn(`addWorktree: failed to set push.autoSetupRemote for ${worktreePath}`, error)
}
return localBaseRefRefresh ? { localBaseRefRefresh } : {}
return {
...(localBaseRefRefresh ? { localBaseRefRefresh } : {}),
...(localBaseRefUpdateSuggestion ? { localBaseRefUpdateSuggestion } : {})
}
}
export async function addSparseWorktree(
@@ -488,7 +683,7 @@ export async function addSparseWorktree(
directories: string[],
baseBranch?: string,
refreshLocalBaseRef = false,
options: { checkoutExistingBranch?: boolean } = {}
options: AddWorktreeOptions = {}
): Promise<AddWorktreeResult> {
let created = false
let addResult: AddWorktreeResult = {}
+87 -1
View File
@@ -977,7 +977,12 @@ describe('registerPtyHandlers', () => {
processEnvOverrides?: Record<string, string | undefined>,
// Why: daemon spawn tests need to exercise both WSL launch metadata
// from main and PR #2662 command threading for OMP overlay selection.
spawnArgs?: { cwd?: string; shellOverride?: string; command?: string }
spawnArgs?: {
cwd?: string
shellOverride?: string
command?: string
envToDelete?: string[]
}
): Promise<DaemonSpawnCall> {
const daemonSpawn = setupDaemonAdapter()
const savedEnv: Record<string, string | undefined> = {}
@@ -1221,6 +1226,8 @@ describe('registerPtyHandlers', () => {
rows: number
worktreeId?: string
env?: Record<string, string>
envToDelete?: string[]
command?: string
}): Promise<{ id: string }>
}
const daemonSpawn = setupDaemonAdapter()
@@ -1248,6 +1255,57 @@ describe('registerPtyHandlers', () => {
expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
})
it('keeps the Agent Teams tmux shim ahead of host PATH shims for runtime-created daemon PTYs', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
worktreeId?: string
env?: Record<string, string>
envToDelete?: string[]
command?: string
}): Promise<{ id: string }>
}
const daemonSpawn = setupDaemonAdapter()
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never, undefined, (() => ({
enableGitHubAttribution: true
})) as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
await controller.spawn({
cols: 80,
rows: 24,
worktreeId: 'wt-runtime',
command: 'claude',
env: {
PATH: `/tmp/orca-agent-teams-bin${delimiter}/usr/bin`,
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test',
TERM_PROGRAM: 'Orca',
ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/stale-attribution'
},
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
})
const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall
expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/orca-agent-teams-bin')
expect(spawnOptions.env.PATH).toContain(
'/tmp/orca-user-data/orca-terminal-attribution/posix'
)
expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined()
expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining(['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'])
)
})
it('strips inherited agent-hook endpoint env from development daemon PTYs', async () => {
const { app } = await import('electron')
const mockedApp = app as unknown as { isPackaged: boolean }
@@ -1273,6 +1331,34 @@ describe('registerPtyHandlers', () => {
expect(env.PATH).toContain('/tmp/orca-user-data/orca-terminal-attribution/posix')
})
it('keeps the Agent Teams tmux shim ahead of host PATH shims on daemon pty:spawn', async () => {
const spawnOptions = await daemonSpawnAndGetOptions(
{
PATH: `/tmp/orca-agent-teams-bin${delimiter}/usr/bin`,
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test',
TERM_PROGRAM: 'Orca',
ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/stale-attribution'
},
undefined,
() => ({ enableGitHubAttribution: true }),
undefined,
{
command: 'claude',
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
}
)
expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/orca-agent-teams-bin')
expect(spawnOptions.env.PATH).toContain(
'/tmp/orca-user-data/orca-terminal-attribution/posix'
)
expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined()
expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
expect(spawnOptions.envToDelete).toEqual(
expect.arrayContaining(['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'])
)
})
it('injects dev-mode ORCA_USER_DATA_PATH + dev CLI PATH on the daemon path', async () => {
// Why: the mocked `app` (see vi.mock at the top of the file) is a
// plain object, so we can flip isPackaged for the scope of the test.
+54 -6
View File
@@ -325,6 +325,44 @@ function readInheritedPath(baseEnv: Record<string, string>): string {
return baseEnv.PATH ?? baseEnv.Path ?? process.env.PATH ?? process.env.Path ?? ''
}
function firstPathEntry(pathValue: string | undefined): string | null {
const first = pathValue?.split(delimiter).find((entry) => entry.trim().length > 0)
return first ?? null
}
function promoteAgentTeamsShimPath(
env: Record<string, string> | undefined,
requestedPath: string | undefined
): void {
if (!env?.ORCA_AGENT_TEAMS_TEAM_ID) {
return
}
const shimPath = firstPathEntry(requestedPath)
if (!shimPath) {
return
}
const currentPathKey = env.PATH !== undefined || env.Path === undefined ? 'PATH' : 'Path'
const currentPath = env[currentPathKey] ?? ''
const remaining = currentPath
.split(delimiter)
.filter((entry) => entry.length > 0 && entry !== shimPath)
// Why: host env injection can prepend Orca's attribution/dev shims. Claude
// Agent Teams must still resolve our fake tmux before any real tmux.
env[currentPathKey] = [shimPath, ...remaining].join(delimiter)
}
function deleteRequestedEnvKeys(
env: Record<string, string> | undefined,
keys: string[] | undefined
): void {
if (!env || !keys) {
return
}
for (const key of keys) {
delete env[key]
}
}
function isWslShellName(shellPath: string | undefined): boolean {
const shellName = shellPath?.replaceAll('\\', '/').split('/').pop()?.toLowerCase()
return shellName === 'wsl.exe' || shellName === 'wsl'
@@ -1587,6 +1625,7 @@ export function registerPtyHandlers(
let env: Record<string, string> | undefined = claudeAuth
? { ...sshScopedEnv, ...claudeAuth.envPatch }
: sshScopedEnv
const requestedAgentTeamsPath = env?.ORCA_AGENT_TEAMS_TEAM_ID ? env.PATH : undefined
if (args.preAllocatedHandle) {
env = { ...env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle }
}
@@ -1616,8 +1655,12 @@ export function registerPtyHandlers(
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
networkProxySettings: getSettings?.()
})
promoteAgentTeamsShimPath(env, requestedAgentTeamsPath)
}
const authEnvToDelete = claudeAuth?.stripAuthEnv
? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS']
: undefined
const spawnOptions: PtySpawnOptions = {
cols: args.cols,
rows: args.rows,
@@ -1626,10 +1669,8 @@ export function registerPtyHandlers(
...(isMintedSessionId ? { isNewSession: true } : {})
}
spawnOptions.envToDelete = mergePtyEnvDeletions(
claudeAuth?.stripAuthEnv
? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS']
: undefined,
args.connectionId ? [] : getInheritedAgentHookEnvKeysToDelete(env)
mergePtyEnvDeletions(authEnvToDelete, args.envToDelete ?? []),
isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(env) : []
)
if (skipCodexHomeEnv) {
spawnOptions.envToDelete = mergePtyEnvDeletions(
@@ -1637,6 +1678,8 @@ export function registerPtyHandlers(
CODEX_HOME_ENV_KEYS
)
}
deleteRequestedEnvKeys(env, spawnOptions.envToDelete)
promoteAgentTeamsShimPath(env, requestedAgentTeamsPath)
if (args.command !== undefined) {
spawnOptions.command = args.command
}
@@ -1939,6 +1982,7 @@ export function registerPtyHandlers(
rows: number
cwd?: string
env?: Record<string, string>
envToDelete?: string[]
command?: string
connectionId?: string | null
worktreeId?: string
@@ -2064,6 +2108,7 @@ export function registerPtyHandlers(
: null
const stablePaneKey = verifiedPaneKey ?? migrationUnsupportedPaneKey
const baseEnv = baseEnvWithAuth ? { ...baseEnvWithAuth } : undefined
const requestedAgentTeamsPath = baseEnv?.ORCA_AGENT_TEAMS_TEAM_ID ? baseEnv.PATH : undefined
if (baseEnv && stablePaneKey) {
baseEnv.ORCA_PANE_KEY = stablePaneKey
if (typeof args.tabId === 'string') {
@@ -2142,6 +2187,7 @@ export function registerPtyHandlers(
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
networkProxySettings: getSettings?.()
})
promoteAgentTeamsShimPath(env, requestedAgentTeamsPath)
} catch (err) {
// Why: buildPtyHostEnv has filesystem side-effects (Pi overlay
// materialization). If it throws before we reach provider.spawn,
@@ -2165,11 +2211,13 @@ export function registerPtyHandlers(
: undefined
const combinedEnvToDelete = mergePtyEnvDeletions(
mergePtyEnvDeletions(
envToDelete,
args.connectionId ? [] : getInheritedAgentHookEnvKeysToDelete(spawnEnv)
mergePtyEnvDeletions(envToDelete, args.envToDelete ?? []),
isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(spawnEnv) : []
),
skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : []
)
deleteRequestedEnvKeys(spawnEnv, combinedEnvToDelete)
promoteAgentTeamsShimPath(spawnEnv, requestedAgentTeamsPath)
const spawnOptions: PtySpawnOptions = {
cols: args.cols,
rows: args.rows,
+250 -79
View File
@@ -18,12 +18,13 @@ import type {
GitPushTarget,
GlobalSettings,
LocalBaseRefRefreshResult,
LocalBaseRefUpdateSuggestion,
Repo,
Worktree,
WorktreeMeta
} from '../../shared/types'
import { getPRForBranch } from '../github/client'
import { listWorktrees, addWorktree, addSparseWorktree, parseWorktreeList } from '../git/worktree'
import { listWorktrees, addWorktree, addSparseWorktree } from '../git/worktree'
import type { AddWorktreeResult } from '../git/worktree'
import { getGitUsername, getDefaultBaseRef, getBranchConflictKind } from '../git/repo'
import { validateGitPushTarget } from '../git/push-target-validation'
@@ -110,10 +111,29 @@ type StagedStartupResult = {
warning?: string
}
type RemoteLocalBaseRefRefreshability =
| {
refreshable: true
baseRef: string
localBranch: string
fullRef: string
remoteTrackingRef: string
behind: number
ownerWorktreePath?: string
}
| {
refreshable: false
result: LocalBaseRefRefreshResult
}
function appendWorktreeCreateWarning(current: string | undefined, next: string): string {
return current ? `${current} Also ${next[0]?.toLowerCase() ?? ''}${next.slice(1)}` : next
}
function countNonEmptyGitOutputLines(output: string): number {
return output.split(/\r?\n/).filter((line) => line.trim().length > 0).length
}
async function spawnLocalStartupAndSetupTerminals(args: {
runtime: OrcaRuntimeService | undefined
worktree: Pick<Worktree, 'id' | 'path'>
@@ -871,13 +891,17 @@ async function resolveRemoteTrackingBaseSsh(
return null
}
const remoteRefPrefix = 'refs/remotes/'
const shortBaseBranch = baseBranch.startsWith(remoteRefPrefix)
? baseBranch.slice(remoteRefPrefix.length)
: baseBranch
const remote = remotes
.filter((candidate) => baseBranch.startsWith(`${candidate}/`))
.filter((candidate) => shortBaseBranch.startsWith(`${candidate}/`))
.sort((a, b) => b.length - a.length)[0]
if (!remote) {
return null
}
const branch = baseBranch.slice(remote.length + 1)
const branch = shortBaseBranch.slice(remote.length + 1)
if (!branch) {
return null
}
@@ -885,7 +909,7 @@ async function resolveRemoteTrackingBaseSsh(
remote,
branch,
ref: `refs/remotes/${remote}/${branch}`,
base: baseBranch
base: `${remote}/${branch}`
}
}
@@ -973,49 +997,128 @@ async function refreshLocalBaseRefForRemoteWorktreeCreate(
repoPath: string,
remoteTrackingBase: RemoteTrackingBase
): Promise<LocalBaseRefRefreshResult> {
const evaluation = await evaluateRemoteLocalBaseRefRefreshability(
provider,
repoPath,
remoteTrackingBase
)
if (!evaluation.refreshable) {
return evaluation.result
}
const resultBase = { baseRef: evaluation.baseRef, localBranch: evaluation.localBranch }
try {
await provider.refreshLocalBaseRefForWorktreeCreate({
repoPath,
fullRef: evaluation.fullRef,
remoteTrackingRef: evaluation.remoteTrackingRef,
...(evaluation.ownerWorktreePath ? { ownerWorktreePath: evaluation.ownerWorktreePath } : {})
})
return {
...resultBase,
status: 'updated',
...(evaluation.ownerWorktreePath ? { ownerWorktreePath: evaluation.ownerWorktreePath } : {})
}
} catch {
return { ...resultBase, status: 'skipped_error' }
}
}
async function evaluateRemoteLocalBaseRefRefreshability(
provider: SshGitProvider,
repoPath: string,
remoteTrackingBase: RemoteTrackingBase
): Promise<RemoteLocalBaseRefRefreshability> {
const resultBase = {
baseRef: remoteTrackingBase.base,
localBranch: remoteTrackingBase.branch
}
const fullRef = `refs/heads/${remoteTrackingBase.branch}`
let behind = 0
try {
await provider.exec(
['merge-base', '--is-ancestor', remoteTrackingBase.branch, remoteTrackingBase.ref],
// Why: SSH generic git.exec is allowlisted. `merge-base` and `log` are
// allowed read-only probes; `rev-list` is intentionally not exposed there.
await provider.exec(['merge-base', '--is-ancestor', fullRef, remoteTrackingBase.ref], repoPath)
const { stdout } = await provider.exec(
['log', '--format=%H', `${fullRef}..${remoteTrackingBase.ref}`],
repoPath
)
behind = countNonEmptyGitOutputLines(stdout)
} catch {
return { ...resultBase, status: 'skipped_not_fast_forward' }
return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } }
}
try {
const { stdout: worktreeListOutput } = await provider.exec(
['worktree', 'list', '--porcelain'],
repoPath
)
const worktrees = parseWorktreeList(worktreeListOutput)
const worktrees = await provider.listWorktrees(repoPath)
const ownerWorktree = worktrees.find((wt) => wt.branch === fullRef)
if (ownerWorktree) {
const { stdout: status } = await provider.exec(
['status', '--porcelain', '--untracked-files=no'],
ownerWorktree.path
)
if (status.trim()) {
const status = await provider.worktreeIsClean(ownerWorktree.path, {
includeUntracked: false
})
if (!status.clean) {
return {
...resultBase,
status: 'skipped_dirty_worktree',
ownerWorktreePath: ownerWorktree.path
refreshable: false,
result: {
...resultBase,
status: 'skipped_dirty_worktree',
ownerWorktreePath: ownerWorktree.path
}
}
}
await provider.exec(['reset', '--hard', remoteTrackingBase.ref], ownerWorktree.path)
return { ...resultBase, status: 'updated', ownerWorktreePath: ownerWorktree.path }
return {
refreshable: true,
...resultBase,
fullRef,
remoteTrackingRef: remoteTrackingBase.ref,
behind,
ownerWorktreePath: ownerWorktree.path
}
}
await provider.exec(['update-ref', fullRef, remoteTrackingBase.ref], repoPath)
return { ...resultBase, status: 'updated' }
// Why: not checked out anywhere — a bare ref fast-forward is safe. Omitting
// ownerWorktreePath tells the relay to update-ref instead of reset --hard.
return {
refreshable: true,
...resultBase,
fullRef,
remoteTrackingRef: remoteTrackingBase.ref,
behind
}
} catch {
return { ...resultBase, status: 'skipped_error' }
return { refreshable: false, result: { ...resultBase, status: 'skipped_error' } }
}
}
async function getRemoteLocalBaseRefUpdateSuggestionForWorktreeCreate(
provider: SshGitProvider,
repoPath: string,
remoteTrackingBase: RemoteTrackingBase
): Promise<LocalBaseRefUpdateSuggestion | undefined> {
const evaluation = await evaluateRemoteLocalBaseRefRefreshability(
provider,
repoPath,
remoteTrackingBase
)
if (!evaluation.refreshable || evaluation.behind <= 0) {
return undefined
}
try {
await provider.refreshLocalBaseRefForWorktreeCreate({
repoPath,
fullRef: evaluation.fullRef,
remoteTrackingRef: evaluation.remoteTrackingRef,
...(evaluation.ownerWorktreePath ? { ownerWorktreePath: evaluation.ownerWorktreePath } : {}),
checkOnly: true
})
} catch {
return undefined
}
return {
baseRef: evaluation.baseRef,
localBranch: evaluation.localBranch,
behind: evaluation.behind
}
}
@@ -1031,10 +1134,11 @@ export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string
// "Creating worktree..." label if no event arrives.
export function emitCreateWorktreeProgress(
mainWindow: BrowserWindow,
phase: 'fetching' | 'creating'
phase: 'fetching' | 'creating',
creationId?: string
): void {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('createWorktree:progress', { phase })
mainWindow.webContents.send('createWorktree:progress', { creationId, phase })
}
}
@@ -1174,10 +1278,43 @@ export async function createRemoteWorktree(
/* best-effort */
}
}
const mux = getActiveMultiplexer(repo.connectionId!)
if (!mux) {
throw new Error('SSH connection is not available. Please reconnect and try again.')
}
// Why: register before the local-base advisory probe as well as addWorktree.
// Fresh/older relays may gate generic git.exec calls on registered roots; if
// the probe runs first it degrades to "no suggestion" even though create works.
try {
await Promise.all([
mux.request('session.registerRoot', { rootPath: repo.path }),
mux.request('session.registerRoot', { rootPath: remotePath })
])
} catch (err) {
if (err instanceof Error && err.message.includes('Method not found')) {
mux.notify('session.registerRoot', { rootPath: repo.path })
mux.notify('session.registerRoot', { rootPath: remotePath })
} else {
throw err
}
}
const localBaseRefRefresh =
settings.refreshLocalBaseRefOnWorktreeCreate && !checkoutExistingBranch && remoteTrackingBase
? await refreshLocalBaseRefForRemoteWorktreeCreate(provider, repo.path, remoteTrackingBase)
: undefined
const localBaseRefUpdateSuggestion =
!settings.refreshLocalBaseRefOnWorktreeCreate &&
!settings.localBaseRefSuggestionDismissed &&
!checkoutExistingBranch &&
remoteTrackingBase
? await getRemoteLocalBaseRefUpdateSuggestionForWorktreeCreate(
provider,
repo.path,
remoteTrackingBase
)
: undefined
if (fsProvider) {
const primaryHooks = await readRemoteEffectiveHooks(repo, fsProvider, repo.path)
@@ -1199,31 +1336,6 @@ export async function createRemoteWorktree(
)
}
const mux = getActiveMultiplexer(repo.connectionId!)
if (!mux) {
throw new Error('SSH connection is not available. Please reconnect and try again.')
}
// Why: kept for back-compat with old relay binaries during the upgrade
// window — those still gate git.addWorktree on registered roots, so we
// must prime them synchronously to close fresh-host / reconnect windows.
// New relays no-op these calls. Notify-fallback handles older relays that
// pre-date the request-form session.registerRoot handler. Tracked for
// removal once the relay-version floor moves past the cutover (see
// docs/relay-fs-allowlist-removal.md).
try {
await Promise.all([
mux.request('session.registerRoot', { rootPath: repo.path }),
mux.request('session.registerRoot', { rootPath: remotePath })
])
} catch (err) {
if (err instanceof Error && err.message.includes('Method not found')) {
mux.notify('session.registerRoot', { rootPath: repo.path })
mux.notify('session.registerRoot', { rootPath: remotePath })
} else {
throw err
}
}
// Create worktree via relay
try {
await timing.time('git_worktree_add', async () =>
@@ -1403,6 +1515,7 @@ export async function createRemoteWorktree(
...(setup ? { setup } : {}),
...(defaultTabs ? { defaultTabs } : {}),
...(localBaseRefRefresh ? { localBaseRefRefresh } : {}),
...(localBaseRefUpdateSuggestion ? { localBaseRefUpdateSuggestion } : {}),
timing: timing.finish()
}
}
@@ -1452,7 +1565,7 @@ export async function createLocalWorktree(
remoteTrackingBase = await runtime.resolveRemoteTrackingBase(repo.path, baseBranch)
if (remoteTrackingBase) {
const hasLocalBaseRef = await runtime.hasRemoteTrackingRef(repo.path, remoteTrackingBase)
emitCreateWorktreeProgress(mainWindow, 'fetching')
emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId)
remoteTrackingRefresh = {
base: remoteTrackingBase,
hadLocalBaseRef: hasLocalBaseRef,
@@ -1469,14 +1582,14 @@ export async function createLocalWorktree(
.fetchRemoteWithCache(repo.path, fallbackRemote)
.then(() => undefined)
.catch(() => undefined)
emitCreateWorktreeProgress(mainWindow, 'fetching')
emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId)
}
} else {
const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin'
legacyFetchPromise = gitExecFileAsync(['fetch', remote], { cwd: repo.path })
.then(() => undefined)
.catch(() => undefined)
emitCreateWorktreeProgress(mainWindow, 'fetching')
emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId)
}
const workspaceRoot = computeWorkspaceRoot(repo.path, worktreePathSettings)
@@ -1662,7 +1775,7 @@ export async function createLocalWorktree(
await legacyFetchPromise
})
}
emitCreateWorktreeProgress(mainWindow, 'creating')
emitCreateWorktreeProgress(mainWindow, 'creating', args.creationId)
let preparedPushTarget: GitPushTarget | undefined
if (args.pushTarget) {
@@ -1672,11 +1785,42 @@ export async function createLocalWorktree(
preparedPushTarget = await prepareWorktreePushTarget(repo.path, args.pushTarget, store, repo.id)
}
const existingBranchOption = { checkoutExistingBranch }
const suggestLocalBaseRefUpdate =
!settings.refreshLocalBaseRefOnWorktreeCreate &&
!settings.localBaseRefSuggestionDismissed &&
Boolean(remoteTrackingBase)
const remoteTrackingBaseOption = remoteTrackingBase ? { remoteTrackingBase } : undefined
const existingBranchOption = {
checkoutExistingBranch,
...remoteTrackingBaseOption,
...(suggestLocalBaseRefUpdate ? { suggestLocalBaseRefUpdate } : {})
}
const addResult: AddWorktreeResult =
(await timing.time('git_worktree_add', async () =>
sparseDirectories.length > 0
? checkoutExistingBranch
(await timing.time('git_worktree_add', async () => {
if (sparseDirectories.length > 0) {
if (checkoutExistingBranch) {
return addSparseWorktree(
repo.path,
worktreePath,
branchName,
sparseDirectories,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate,
existingBranchOption
)
}
if (suggestLocalBaseRefUpdate) {
return addSparseWorktree(
repo.path,
worktreePath,
branchName,
sparseDirectories,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate,
{ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate }
)
}
return remoteTrackingBaseOption
? addSparseWorktree(
repo.path,
worktreePath,
@@ -1684,7 +1828,7 @@ export async function createLocalWorktree(
sparseDirectories,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate,
existingBranchOption
remoteTrackingBaseOption
)
: addSparseWorktree(
repo.path,
@@ -1694,24 +1838,48 @@ export async function createLocalWorktree(
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate
)
: checkoutExistingBranch
? addWorktree(
repo.path,
worktreePath,
branchName,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate,
false,
existingBranchOption
)
: addWorktree(
repo.path,
worktreePath,
branchName,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate
)
)) ?? {}
}
if (checkoutExistingBranch) {
return addWorktree(
repo.path,
worktreePath,
branchName,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate,
false,
existingBranchOption
)
}
if (suggestLocalBaseRefUpdate) {
return addWorktree(
repo.path,
worktreePath,
branchName,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate,
false,
{ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate }
)
}
return remoteTrackingBaseOption
? addWorktree(
repo.path,
worktreePath,
branchName,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate,
false,
remoteTrackingBaseOption
)
: addWorktree(
repo.path,
worktreePath,
branchName,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate
)
})) ?? {}
let configuredPushTarget: GitPushTarget | undefined
if (preparedPushTarget) {
@@ -1877,6 +2045,9 @@ export async function createLocalWorktree(
...(addResult.localBaseRefRefresh
? { localBaseRefRefresh: addResult.localBaseRefRefresh }
: {}),
...(addResult.localBaseRefUpdateSuggestion
? { localBaseRefUpdateSuggestion: addResult.localBaseRefUpdateSuggestion }
: {}),
...(stagedStartup.startupTerminal ? { startupTerminal: stagedStartup.startupTerminal } : {}),
...(stagedStartup.warning ? { warning: stagedStartup.warning } : {}),
timing: timing.finish()
+388 -21
View File
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { lstat, mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import type { CreateWorktreeResult } from '../../shared/types'
const {
handleMock,
@@ -1610,28 +1611,38 @@ describe('registerWorktreeHandlers', () => {
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
}
if (args[0] === 'worktree' && args[1] === 'list') {
return {
stdout: 'worktree /remote/repo\nHEAD abc123\nbranch refs/heads/main\n',
stderr: ''
}
if (args[0] === 'merge-base') {
return { stdout: '', stderr: '' }
}
if (args[0] === 'status') {
return { stdout: ' M package.json\n', stderr: '' }
if (args[0] === 'log') {
return { stdout: 'commit-a\ncommit-b\ncommit-c\n', stderr: '' }
}
return { stdout: '', stderr: '' }
}),
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
addWorktree: vi.fn().mockResolvedValue(undefined),
listWorktrees: vi.fn().mockResolvedValue([
{
path: '/remote/improve-dashboard',
head: 'abc123',
branch: 'refs/heads/improve-dashboard',
isBare: false,
isMainWorktree: false
}
])
listWorktrees: vi
.fn()
.mockResolvedValueOnce([
{
path: '/remote/repo',
head: 'base123',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
])
.mockResolvedValueOnce([
{
path: '/remote/improve-dashboard',
head: 'abc123',
branch: 'refs/heads/improve-dashboard',
isBare: false,
isMainWorktree: false
}
]),
worktreeIsClean: vi.fn().mockResolvedValue({ clean: false, stdout: ' M package.json\n' }),
refreshLocalBaseRefForWorktreeCreate: vi.fn().mockResolvedValue(undefined)
}
const mux = {
request: vi.fn().mockResolvedValue(undefined),
@@ -1649,24 +1660,27 @@ describe('registerWorktreeHandlers', () => {
getActiveMultiplexerMock.mockReturnValue(mux)
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
const result = await handlers['worktrees:create'](null, {
const result = (await handlers['worktrees:create'](null, {
repoId: 'repo-ssh',
name: 'improve-dashboard'
})
})) as CreateWorktreeResult
expect(provider.exec).toHaveBeenCalledWith(
['merge-base', '--is-ancestor', 'main', 'refs/remotes/origin/main'],
['merge-base', '--is-ancestor', 'refs/heads/main', 'refs/remotes/origin/main'],
'/remote/repo'
)
expect(provider.exec).toHaveBeenCalledWith(['worktree', 'list', '--porcelain'], '/remote/repo')
expect(provider.exec).toHaveBeenCalledWith(
['status', '--porcelain', '--untracked-files=no'],
['log', '--format=%H', 'refs/heads/main..refs/remotes/origin/main'],
'/remote/repo'
)
expect(provider.worktreeIsClean).toHaveBeenCalledWith('/remote/repo', {
includeUntracked: false
})
expect(provider.exec).not.toHaveBeenCalledWith(
['reset', '--hard', 'refs/remotes/origin/main'],
expect.any(String)
)
expect(provider.refreshLocalBaseRefForWorktreeCreate).not.toHaveBeenCalled()
expect(result).toEqual(
expect.objectContaining({
localBaseRefRefresh: {
@@ -1679,6 +1693,297 @@ describe('registerWorktreeHandlers', () => {
)
})
it('refreshes SSH local base through the narrow relay RPC when the setting is on', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
displayName: 'ssh',
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1',
worktreeBaseRef: 'origin/main'
}
const provider = {
exec: vi.fn().mockImplementation(async (args: string[]) => {
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
}
if (args[0] === 'merge-base') {
return { stdout: '', stderr: '' }
}
if (args[0] === 'log') {
return { stdout: 'commit-a\ncommit-b\n', stderr: '' }
}
throw new Error(`unexpected generic exec: ${args.join(' ')}`)
}),
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
addWorktree: vi.fn().mockResolvedValue(undefined),
listWorktrees: vi
.fn()
.mockResolvedValueOnce([
{
path: '/remote/repo',
head: 'base123',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
])
.mockResolvedValueOnce([
{
path: '/remote/improve-dashboard',
head: 'abc123',
branch: 'refs/heads/improve-dashboard',
isBare: false,
isMainWorktree: false
}
]),
worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }),
refreshLocalBaseRefForWorktreeCreate: vi.fn().mockResolvedValue(undefined)
}
const mux = {
request: vi.fn().mockResolvedValue(undefined),
notify: vi.fn()
}
store.getSettings.mockReturnValue({
branchPrefix: 'none',
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: true,
workspaceDir: '/workspace'
})
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue(repo)
getSshGitProviderMock.mockReturnValue(provider)
getActiveMultiplexerMock.mockReturnValue(mux)
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
const result = (await handlers['worktrees:create'](null, {
repoId: 'repo-ssh',
name: 'improve-dashboard'
})) as CreateWorktreeResult
expect(provider.exec).toHaveBeenCalledWith(
['merge-base', '--is-ancestor', 'refs/heads/main', 'refs/remotes/origin/main'],
'/remote/repo'
)
expect(provider.exec).toHaveBeenCalledWith(
['log', '--format=%H', 'refs/heads/main..refs/remotes/origin/main'],
'/remote/repo'
)
expect(provider.refreshLocalBaseRefForWorktreeCreate).toHaveBeenCalledWith({
repoPath: '/remote/repo',
fullRef: 'refs/heads/main',
remoteTrackingRef: 'refs/remotes/origin/main',
ownerWorktreePath: '/remote/repo'
})
expect(provider.exec).not.toHaveBeenCalledWith(
['reset', '--hard', 'refs/remotes/origin/main'],
expect.any(String)
)
expect(provider.exec).not.toHaveBeenCalledWith(
['update-ref', 'refs/heads/main', 'refs/remotes/origin/main'],
expect.any(String)
)
expect(result).toEqual(
expect.objectContaining({
localBaseRefRefresh: {
status: 'updated',
baseRef: 'origin/main',
localBranch: 'main',
ownerWorktreePath: '/remote/repo'
}
})
)
})
it('returns SSH local base update suggestion when a full local base ref is safely behind', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
displayName: 'ssh',
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1',
worktreeBaseRef: 'refs/remotes/origin/main'
}
let registeredRoots = false
const provider = {
exec: vi.fn().mockImplementation(async (args: string[]) => {
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
}
if (args[0] === 'merge-base' || args[0] === 'log') {
if (!registeredRoots) {
throw new Error('Path outside authorized workspace')
}
return {
stdout: args[0] === 'log' ? 'commit-a\ncommit-b\ncommit-c\ncommit-d\n' : '',
stderr: ''
}
}
return { stdout: '', stderr: '' }
}),
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
addWorktree: vi.fn().mockResolvedValue(undefined),
listWorktrees: vi.fn().mockImplementation(async () => {
if (!registeredRoots) {
throw new Error('No workspace roots registered yet')
}
return [
{
path: '/remote/repo',
head: 'base123',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
},
{
path: '/remote/improve-dashboard',
head: 'abc123',
branch: 'refs/heads/improve-dashboard',
isBare: false,
isMainWorktree: false
}
]
}),
worktreeIsClean: vi.fn().mockImplementation(async () => {
if (!registeredRoots) {
throw new Error('Path outside authorized workspace')
}
return { clean: true }
}),
refreshLocalBaseRefForWorktreeCreate: vi.fn().mockResolvedValue(undefined)
}
const mux = {
request: vi.fn().mockImplementation(async (method: string) => {
if (method === 'session.registerRoot') {
registeredRoots = true
}
}),
notify: vi.fn()
}
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue(repo)
getSshGitProviderMock.mockReturnValue(provider)
getActiveMultiplexerMock.mockReturnValue(mux)
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
const result = await handlers['worktrees:create'](null, {
repoId: 'repo-ssh',
name: 'improve-dashboard'
})
expect(provider.exec).toHaveBeenCalledWith(
['merge-base', '--is-ancestor', 'refs/heads/main', 'refs/remotes/origin/main'],
'/remote/repo'
)
expect(provider.exec).toHaveBeenCalledWith(
['log', '--format=%H', 'refs/heads/main..refs/remotes/origin/main'],
'/remote/repo'
)
expect(provider.listWorktrees).toHaveBeenCalledWith('/remote/repo')
expect(provider.worktreeIsClean).toHaveBeenCalledWith('/remote/repo', {
includeUntracked: false
})
expect(provider.refreshLocalBaseRefForWorktreeCreate).toHaveBeenCalledWith({
repoPath: '/remote/repo',
fullRef: 'refs/heads/main',
remoteTrackingRef: 'refs/remotes/origin/main',
ownerWorktreePath: '/remote/repo',
checkOnly: true
})
expect(provider.exec).not.toHaveBeenCalledWith(
['reset', '--hard', 'refs/remotes/origin/main'],
expect.any(String)
)
expect(result).toEqual(
expect.objectContaining({
localBaseRefUpdateSuggestion: {
baseRef: 'origin/main',
localBranch: 'main',
behind: 4
}
})
)
})
it('does not suggest SSH local base updates when the relay cannot refresh local refs', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
displayName: 'ssh',
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1',
worktreeBaseRef: 'refs/remotes/origin/main'
}
const methodNotFound = Object.assign(
new Error('Method not found: git.refreshLocalBaseRefForWorktreeCreate'),
{ code: -32601 }
)
const provider = {
exec: vi.fn().mockImplementation(async (args: string[]) => {
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
}
if (args[0] === 'merge-base') {
return { stdout: '', stderr: '' }
}
if (args[0] === 'log') {
return { stdout: 'commit-a\n', stderr: '' }
}
return { stdout: '', stderr: '' }
}),
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
addWorktree: vi.fn().mockResolvedValue(undefined),
listWorktrees: vi
.fn()
.mockResolvedValueOnce([
{
path: '/remote/repo',
head: 'base123',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
])
.mockResolvedValueOnce([
{
path: '/remote/improve-dashboard',
head: 'abc123',
branch: 'refs/heads/improve-dashboard',
isBare: false,
isMainWorktree: false
}
]),
worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }),
refreshLocalBaseRefForWorktreeCreate: vi.fn().mockRejectedValue(methodNotFound)
}
const mux = {
request: vi.fn().mockResolvedValue(undefined),
notify: vi.fn()
}
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue(repo)
getSshGitProviderMock.mockReturnValue(provider)
getActiveMultiplexerMock.mockReturnValue(mux)
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
const result = (await handlers['worktrees:create'](null, {
repoId: 'repo-ssh',
name: 'improve-dashboard'
})) as CreateWorktreeResult
expect(provider.refreshLocalBaseRefForWorktreeCreate).toHaveBeenCalledWith({
repoPath: '/remote/repo',
fullRef: 'refs/heads/main',
remoteTrackingRef: 'refs/remotes/origin/main',
ownerWorktreePath: '/remote/repo',
checkOnly: true
})
expect(result.localBaseRefUpdateSuggestion).toBeUndefined()
})
it('reads remote orca.yaml and returns a setup launch payload during SSH create', async () => {
const repo = {
id: 'repo-ssh',
@@ -2185,6 +2490,7 @@ describe('registerWorktreeHandlers', () => {
addWorktree: vi.fn().mockResolvedValue(undefined),
listWorktrees: vi
.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
path: '/remote/first-worktree',
@@ -2194,6 +2500,7 @@ describe('registerWorktreeHandlers', () => {
isMainWorktree: false
}
])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
path: '/remote/second-worktree',
@@ -2644,6 +2951,66 @@ describe('registerWorktreeHandlers', () => {
)
})
it('threads the local base update suggestion from local create results', async () => {
const remoteBase = {
remote: 'origin',
branch: 'main',
ref: 'refs/remotes/origin/main',
base: 'origin/main'
}
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase)
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true)
addWorktreeMock.mockResolvedValue({
localBaseRefUpdateSuggestion: {
baseRef: 'origin/main',
localBranch: 'main',
behind: 2
}
})
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/improve-dashboard',
head: 'created-sha',
branch: 'improve-dashboard',
isBare: false,
isMainWorktree: false
}
])
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'created-sha\n', stderr: '' })
const result = await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard'
})
expect(addWorktreeMock).toHaveBeenCalledWith(
'/workspace/repo',
'/workspace/improve-dashboard',
'improve-dashboard',
'origin/main',
false,
false,
{
suggestLocalBaseRefUpdate: true,
remoteTrackingBase: {
remote: 'origin',
branch: 'main',
ref: 'refs/remotes/origin/main',
base: 'origin/main'
}
}
)
expect(result).toEqual(
expect.objectContaining({
localBaseRefUpdateSuggestion: {
baseRef: 'origin/main',
localBranch: 'main',
behind: 2
}
})
)
})
it('throws a clear error when no default base ref can be resolved', async () => {
// Why: guard against regressing to a silent 'origin/main' fallback. When
// getDefaultBaseRef returns null (e.g. a fresh repo with no origin/HEAD,
+1 -1
View File
@@ -2501,7 +2501,7 @@ describe('Store', () => {
)
const store = await createStore()
expect(store.getSettings().disabledTuiAgents).toEqual(['codex', 'claude'])
expect(store.getSettings().disabledTuiAgents).toEqual(['codex', 'claude', 'claude-agent-teams'])
const updated = store.updateSettings({
disabledTuiAgents: ['gemini', 'not-real', 'gemini', 'opencode'] as never
+16 -1
View File
@@ -1871,6 +1871,20 @@ export class Store {
if (!visibleTaskProvidersDefaultedForJira) {
this.loadNeedsSave = true
}
const claudeAgentTeamsDefaultDisabledMigrated =
parsed.settings?.claudeAgentTeamsDefaultDisabledMigrated === true
if (!claudeAgentTeamsDefaultDisabledMigrated) {
this.loadNeedsSave = true
}
const migratedDisabledTuiAgents = normalizeDisabledTuiAgents(
parsed.settings?.disabledTuiAgents
)
if (
!claudeAgentTeamsDefaultDisabledMigrated &&
!migratedDisabledTuiAgents.includes('claude-agent-teams')
) {
migratedDisabledTuiAgents.push('claude-agent-teams')
}
if (!autoRenameBranchFromWorkDefaultedOn) {
this.loadNeedsSave = true
}
@@ -1926,7 +1940,8 @@ export class Store {
terminalShortcutPolicy: normalizeTerminalShortcutPolicy(
parsed.settings?.terminalShortcutPolicy
),
disabledTuiAgents: normalizeDisabledTuiAgents(parsed.settings?.disabledTuiAgents),
disabledTuiAgents: migratedDisabledTuiAgents,
claudeAgentTeamsDefaultDisabledMigrated: true,
openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications, {
seedDefaults: true
}),
@@ -181,6 +181,35 @@ describe('LocalPtyProvider', () => {
expect(spawnCall[2].env.CUSTOM_VAR).toBe('custom-value')
})
it('honors explicit terminal env overrides after deleting requested defaults', async () => {
provider.configure({
buildSpawnEnv: (_id, env) => {
env.TERM_PROGRAM = 'Orca'
env.ORCA_ATTRIBUTION_SHIM_DIR = '/tmp/orca-attribution'
env.PATH = `/tmp/orca-attribution:${env.PATH ?? ''}`
return env
}
})
await provider.spawn({
cols: 80,
rows: 24,
env: {
TERM: 'screen-256color',
PATH: '/tmp/orca-agent-teams-bin:/usr/bin',
ORCA_AGENT_TEAMS_TEAM_ID: 'team-test'
},
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
})
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[2].name).toBe('screen-256color')
expect(spawnCall[2].env.TERM).toBe('screen-256color')
expect(spawnCall[2].env.PATH.split(':')[0]).toBe('/tmp/orca-agent-teams-bin')
expect(spawnCall[2].env.TERM_PROGRAM).toBeUndefined()
expect(spawnCall[2].env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined()
})
it('does not pass a Windows Codex home into WSL terminals', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
provider.configure({
+32 -2
View File
@@ -2,7 +2,7 @@
~70 lines of scanner/promise wiring to spawn(). Splitting the method would scatter
tightly coupled PTY lifecycle logic (scan → ready → write → exit cleanup) across
files without a cleaner ownership seam. */
import { basename } from 'path'
import { basename, delimiter } from 'path'
import { win32 as pathWin32 } from 'path'
import { resolveWindowsShellLaunchArgs } from './windows-shell-args'
import { resolveEffectiveWindowsPowerShell } from './windows-powershell'
@@ -88,6 +88,21 @@ function removeUnspecifiedPaneIdentityEnv(
}
}
function promoteAgentTeamsShimPath(
env: Record<string, string>,
requestedPath: string | undefined
): void {
if (!env.ORCA_AGENT_TEAMS_TEAM_ID || !requestedPath) {
return
}
const shimDir = requestedPath.split(delimiter)[0]
if (!shimDir) {
return
}
const currentParts = env.PATH?.split(delimiter).filter(Boolean) ?? []
env.PATH = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(delimiter)
}
function disposePtyListeners(id: string): void {
const disposables = ptyDisposables.get(id)
if (disposables) {
@@ -341,6 +356,9 @@ export class LocalPtyProvider implements IPtyProvider {
for (const key of args.envToDelete ?? []) {
delete spawnEnv[key]
}
if (args.env?.TERM) {
spawnEnv.TERM = args.env.TERM
}
spawnEnv.LANG ??= 'en_US.UTF-8'
@@ -369,6 +387,15 @@ export class LocalPtyProvider implements IPtyProvider {
wslDistro: launchWslDistro
})
: spawnEnv
// Why: app-level env hooks can reintroduce vars that special launch modes
// explicitly scrubbed. Apply deletions last so shims like Claude Agent
// Teams keep their PATH and terminal-detection contract.
for (const key of args.envToDelete ?? []) {
delete finalEnv[key]
}
if (args.env?.TERM) {
finalEnv.TERM = args.env.TERM
}
if (process.platform === 'win32') {
const codexHomeWslInfo = finalEnv.CODEX_HOME ? parseWslPath(finalEnv.CODEX_HOME) : null
if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') {
@@ -419,7 +446,8 @@ export class LocalPtyProvider implements IPtyProvider {
finalEnv.ORCA_OPENCODE_CONFIG_DIR ||
finalEnv.ORCA_PI_CODING_AGENT_DIR ||
finalEnv.ORCA_OMP_CODING_AGENT_DIR ||
finalEnv.ORCA_CODEX_HOME
finalEnv.ORCA_CODEX_HOME ||
finalEnv.ORCA_AGENT_TEAMS_SHIM_DIR
getFallbackShellReadyConfig = args.command
? (shell) => getShellReadyLaunchConfig(shell)
: needsNoMarkerWrapper
@@ -436,6 +464,7 @@ export class LocalPtyProvider implements IPtyProvider {
shellReadyLaunch = args.command ? shellLaunch : null
}
}
promoteAgentTeamsShimPath(finalEnv, args.env?.PATH)
// ── Worktree-scoped shell history (§7–§10 of terminal-history-scope-design) ──
// Why: without this, all worktree terminals share a single global HISTFILE
@@ -458,6 +487,7 @@ export class LocalPtyProvider implements IPtyProvider {
rows: args.rows,
cwd: effectiveCwd,
env: finalEnv,
termName: finalEnv.TERM,
ptySpawn: pty.spawn,
getShellReadyConfig: getFallbackShellReadyConfig,
// Why: if zsh failed and bash took over, HISTFILE still points to
@@ -327,6 +327,7 @@ describePosix('local PTY shell-ready launch config', () => {
'[[ -n "${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="${ORCA_PI_CODING_AGENT_DIR}"'
const codexRestoreLine =
'[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"'
const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0'
const ompRestoreLine =
'if [[ -z "${ORCA_PI_CODING_AGENT_DIR:-}" && -n "${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then'
const ompWrapperLine = 'command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"'
@@ -338,6 +339,9 @@ describePosix('local PTY shell-ready launch config', () => {
expect(bashRc).toContain(piRestoreLine)
expect(zshrc).toContain(codexRestoreLine)
expect(zlogin).toContain(codexRestoreLine)
expect(zshrc).toContain(agentTeamsPathRestoreLine)
expect(zlogin).toContain(agentTeamsPathRestoreLine)
expect(bashRc).toContain(agentTeamsPathRestoreLine)
expect(bashRc).toContain(codexRestoreLine)
expect(zshrc).toContain(ompRestoreLine)
expect(zlogin).toContain(ompRestoreLine)
@@ -163,6 +163,14 @@ __orca_restore_attribution_path() {
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
}
__orca_restore_attribution_path
__orca_restore_agent_teams_path() {
[[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
case "$PATH" in
"\${ORCA_AGENT_TEAMS_SHIM_DIR}"|"\${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;;
esac
export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
}
__orca_restore_agent_teams_path
# Why: user startup files may set the default OpenCode config after Orca's
# spawn env; restore the PTY-scoped overlay before the first prompt.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
@@ -276,6 +284,14 @@ __orca_restore_attribution_path() {
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
}
[[ ! -o login ]] && __orca_restore_attribution_path
__orca_restore_agent_teams_path() {
[[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
case "$PATH" in
"\${ORCA_AGENT_TEAMS_SHIM_DIR}"|"\${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;;
esac
export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
}
[[ ! -o login ]] && __orca_restore_agent_teams_path
if [[ ! -o login ]]; then
# Why: ~/.zshrc can export the user's default OpenCode config after spawn.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
@@ -338,6 +354,14 @@ __orca_restore_attribution_path() {
export PATH="\${ORCA_ATTRIBUTION_SHIM_DIR}:$PATH"
}
__orca_restore_attribution_path
__orca_restore_agent_teams_path() {
[[ -n "\${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
case "$PATH" in
"\${ORCA_AGENT_TEAMS_SHIM_DIR}"|"\${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;;
esac
export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
}
__orca_restore_agent_teams_path
# Why: .zlogin is the final login startup file before the prompt is shown.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
+4 -2
View File
@@ -94,6 +94,7 @@ export function validateWorkingDirectory(cwd: string): void {
export type ShellSpawnParams = {
shellPath: string
shellArgs: string[]
termName?: string
cols: number
rows: number
cwd: string
@@ -120,6 +121,7 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu
const {
shellPath,
shellArgs,
termName = 'xterm-256color',
cols,
rows,
cwd,
@@ -137,7 +139,7 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu
if (!primaryError) {
try {
return {
process: ptySpawn(shellPath, shellArgs, { name: 'xterm-256color', cols, rows, cwd, env }),
process: ptySpawn(shellPath, shellArgs, { name: termName, cols, rows, cwd, env }),
shellPath
}
} catch (err) {
@@ -158,7 +160,7 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu
onBeforeFallbackSpawn?.(env, fallback)
Object.assign(env, fallbackReady?.env ?? {})
const proc = ptySpawn(fallback, fallbackReady?.args ?? ['-l'], {
name: 'xterm-256color',
name: termName,
cols,
rows,
cwd,
@@ -721,6 +721,51 @@ describe('SshGitProvider', () => {
expect(result).toEqual(cleanResult)
})
it('worktreeIsClean can ignore untracked files', async () => {
const cleanResult = { clean: true }
mux.request.mockResolvedValue(cleanResult)
const result = await provider.worktreeIsClean('/home/user/feat', { includeUntracked: false })
expect(mux.request).toHaveBeenCalledWith('git.worktreeIsClean', {
worktreePath: '/home/user/feat',
includeUntracked: false
})
expect(result).toEqual(cleanResult)
})
it('worktreeIsClean filters untracked stdout when old relays ignore the option', async () => {
mux.request.mockResolvedValue({ clean: false, stdout: '?? scratch.txt\n' })
const result = await provider.worktreeIsClean('/home/user/feat', { includeUntracked: false })
expect(result).toEqual({ clean: true })
})
it('worktreeIsClean keeps dirty results without stdout dirty for tracked-only checks', async () => {
mux.request.mockResolvedValue({ clean: false })
const result = await provider.worktreeIsClean('/home/user/feat', { includeUntracked: false })
expect(result).toEqual({ clean: false })
})
it('refreshLocalBaseRefForWorktreeCreate sends the narrow refresh request', async () => {
await provider.refreshLocalBaseRefForWorktreeCreate({
repoPath: '/home/user/repo',
fullRef: 'refs/heads/main',
remoteTrackingRef: 'refs/remotes/origin/main',
ownerWorktreePath: '/home/user/repo'
})
expect(mux.request).toHaveBeenCalledWith('git.refreshLocalBaseRefForWorktreeCreate', {
repoPath: '/home/user/repo',
fullRef: 'refs/heads/main',
remoteTrackingRef: 'refs/remotes/origin/main',
ownerWorktreePath: '/home/user/repo'
})
})
it('worktreeIsClean falls back to git.status for old relays', async () => {
const methodNotFound = Object.assign(new Error('Method not found: git.worktreeIsClean'), {
code: -32601
@@ -746,6 +791,28 @@ describe('SshGitProvider', () => {
}
})
it('worktreeIsClean filters untracked entries in old-relay fallback', async () => {
const methodNotFound = Object.assign(new Error('Method not found: git.worktreeIsClean'), {
code: -32601
})
mux.request.mockRejectedValueOnce(methodNotFound).mockResolvedValueOnce({
entries: [{ path: 'scratch.txt', status: 'untracked', area: 'untracked' }],
conflictOperation: 'unknown'
})
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
await expect(
provider.worktreeIsClean('/home/user/feat', { includeUntracked: false })
).resolves.toEqual({ clean: true })
expect(mux.request).toHaveBeenNthCalledWith(2, 'git.status', {
worktreePath: '/home/user/feat'
})
} finally {
warnSpy.mockRestore()
}
})
it('renameCurrentBranch sends the narrow branch-rename request', async () => {
await provider.renameCurrentBranch('/home/user/feat', 'you/fix-auth')
expect(mux.request).toHaveBeenCalledWith('git.renameCurrentBranch', {
+39 -4
View File
@@ -43,6 +43,13 @@ function formatStatusEntriesForCleanCheck(entries: GitStatusResult['entries']):
return entries.map((entry) => `${entry.area} ${entry.status}: ${entry.path}`).join('\n')
}
function filterUntrackedPorcelainStatus(stdout: string | undefined): string | undefined {
const trackedLines = (stdout ?? '')
.split(/\r?\n/)
.filter((line) => line.trim().length > 0 && !line.startsWith('?? '))
return trackedLines.length > 0 ? trackedLines.join('\n') : undefined
}
export class SshGitProvider implements IGitProvider {
private connectionId: string
private mux: SshChannelMultiplexer
@@ -458,12 +465,26 @@ export class SshGitProvider implements IGitProvider {
})) ?? {}) as RemoveWorktreeResult
}
async worktreeIsClean(worktreePath: string): Promise<{ clean: boolean; stdout?: string }> {
async worktreeIsClean(
worktreePath: string,
options: { includeUntracked?: boolean } = {}
): Promise<{ clean: boolean; stdout?: string }> {
try {
return (await this.mux.request('git.worktreeIsClean', { worktreePath })) as {
const result = (await this.mux.request('git.worktreeIsClean', {
worktreePath,
...(options.includeUntracked === false ? { includeUntracked: false } : {})
})) as {
clean: boolean
stdout?: string
}
if (options.includeUntracked === false) {
if (!result.clean && result.stdout === undefined) {
return result
}
const trackedStdout = filterUntrackedPorcelainStatus(result.stdout)
return { clean: !trackedStdout, ...(trackedStdout ? { stdout: trackedStdout } : {}) }
}
return result
} catch (error) {
if (!isJsonRpcMethodNotFoundError(error)) {
throw error
@@ -477,11 +498,25 @@ export class SshGitProvider implements IGitProvider {
// Why: existing SSH relays may predate git.worktreeIsClean, but git.status
// is a narrow relay RPC and avoids the generic git.exec allowlist.
const status = await this.getStatus(worktreePath)
const clean = status.entries.length === 0
return { clean, stdout: formatStatusEntriesForCleanCheck(status.entries) }
const entries =
options.includeUntracked === false
? status.entries.filter((entry) => entry.area !== 'untracked')
: status.entries
const clean = entries.length === 0
return { clean, stdout: formatStatusEntriesForCleanCheck(entries) }
}
}
async refreshLocalBaseRefForWorktreeCreate(args: {
repoPath: string
fullRef: string
remoteTrackingRef: string
ownerWorktreePath?: string
checkOnly?: boolean
}): Promise<void> {
await this.mux.request('git.refreshLocalBaseRefForWorktreeCreate', args)
}
async renameCurrentBranch(worktreePath: string, newBranch: string): Promise<void> {
await this.mux.request('git.renameCurrentBranch', { worktreePath, newBranch })
}
+4 -1
View File
@@ -218,7 +218,10 @@ export type IGitProvider = {
isGitRepoAsync(dirPath: string): Promise<{ isRepo: boolean; rootPath: string | null }>
exec(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }>
getRemoteFileUrl(worktreePath: string, relativePath: string, line: number): Promise<string | null>
worktreeIsClean(worktreePath: string): Promise<{ clean: boolean; stdout?: string }>
worktreeIsClean(
worktreePath: string,
options?: { includeUntracked?: boolean }
): Promise<{ clean: boolean; stdout?: string }>
}
// ─── Provider Registry ──────────────────────────────────────────────
@@ -0,0 +1,154 @@
import { describe, expect, it, vi } from 'vitest'
import { ClaudeAgentTeamsService, type AgentTeamsTerminalApi } from './claude-agent-teams-service'
function createServiceWithLeader(): {
service: ClaudeAgentTeamsService
teamId: string
token: string
leaderPane: string
api: AgentTeamsTerminalApi
splitCalls: { handle: string; direction?: string; command?: string; envPane?: string }[]
} {
const service = new ClaudeAgentTeamsService()
const launch = service.createLaunchEnv({
leaderHandle: 'leader-handle',
baseEnv: { PATH: '/usr/bin' },
shimDir: '/tmp/orca-shim',
shimBin: '/usr/bin/orca'
})
expect(launch.env.ORCA_AGENT_TEAMS_SHIM_DIR).toBe('/tmp/orca-shim')
const splitCalls: { handle: string; direction?: string; command?: string; envPane?: string }[] =
[]
let splitCount = 0
const api: AgentTeamsTerminalApi = {
splitTerminal: vi.fn(async (handle, opts) => {
splitCount += 1
splitCalls.push({
handle,
direction: opts.direction,
command: opts.command,
envPane: opts.env?.TMUX_PANE
})
return { handle: `teammate-${splitCount}`, tabId: 'tab-1', paneRuntimeId: -1 }
}),
readTerminal: vi.fn(async (handle) => ({
handle,
status: 'running' as const,
tail: ['line one', 'line two'],
truncated: false,
nextCursor: null
})),
sendTerminal: vi.fn(async (handle, action) => ({
handle,
accepted: Boolean(action.text),
bytesWritten: action.text?.length ?? 0
})),
focusTerminal: vi.fn(async (handle) => ({ handle, tabId: 'tab-1', worktreeId: 'wt-1' })),
closeTerminal: vi.fn(async (handle) => ({ handle, tabId: 'tab-1', ptyKilled: true })),
showTerminal: vi.fn(async (handle) => ({
handle,
worktreeId: 'wt-1',
worktreePath: '/tmp/wt',
branch: 'main',
tabId: 'tab-1',
leafId: 'leaf-1',
title: null,
connected: true,
writable: true,
lastOutputAt: null,
preview: '',
paneRuntimeId: -1,
ptyId: 'pty-1',
rendererGraphEpoch: 1
}))
}
return {
service,
teamId: launch.teamId,
token: launch.token,
leaderPane: launch.leaderPane,
api,
splitCalls
}
}
describe('ClaudeAgentTeamsService', () => {
it('supports Claude core tmux teammate sequence with native splits', async () => {
const { service, teamId, token, leaderPane, api, splitCalls } = createServiceWithLeader()
const request = (argv: string[]) =>
service.handleTmuxCompat({ teamId, token, envPane: leaderPane, argv }, api)
await expect(
request(['display-message', '-t', leaderPane, '-p', '#{session_name}:#{window_index}'])
).resolves.toMatchObject({ stdout: 'orca:0\n', exitCode: 0 })
await expect(
request(['split-window', '-t', leaderPane, '-h', '-l', '70%', '-P', '-F', '#{pane_id}'])
).resolves.toMatchObject({ stdout: '%2\n', exitCode: 0 })
await request(['select-layout', '-t', 'orca:0', 'main-vertical'])
await request(['resize-pane', '-t', leaderPane, '-x', '30%'])
await expect(
request(['list-panes', '-t', 'orca:0', '-F', '#{pane_id}'])
).resolves.toMatchObject({
stdout: '%1\n%2\n'
})
expect(splitCalls).toEqual([
{ handle: 'leader-handle', direction: 'vertical', command: undefined, envPane: '%2' }
])
})
it('puts the first teammate on the right, then stacks repeated main-vertical teammates downward', async () => {
const { service, teamId, token, leaderPane, api, splitCalls } = createServiceWithLeader()
const request = (argv: string[]) =>
service.handleTmuxCompat({ teamId, token, envPane: leaderPane, argv }, api)
await request(['split-window', '-t', leaderPane, '-h', '-l', '70%', '-P', '-F', '#{pane_id}'])
await request(['select-layout', '-t', 'orca:0', 'main-vertical'])
await request(['split-window', '-t', leaderPane, '-h', '-l', '70%', '-P', '-F', '#{pane_id}'])
await request(['split-window', '-t', leaderPane, '-h', '-l', '70%', '-P', '-F', '#{pane_id}'])
expect(splitCalls.map((call) => [call.handle, call.direction, call.envPane])).toEqual([
['leader-handle', 'vertical', '%2'],
['teammate-1', 'horizontal', '%3'],
['teammate-2', 'horizontal', '%4']
])
})
it('does not recycle fake pane ids after a teammate closes', async () => {
const { service, teamId, token, leaderPane, api, splitCalls } = createServiceWithLeader()
const request = (argv: string[], envPane = leaderPane) =>
service.handleTmuxCompat({ teamId, token, envPane, argv }, api)
await request(['split-window', '-t', leaderPane, '-h', '-P', '-F', '#{pane_id}'])
await request(['select-layout', '-t', 'orca:0', 'main-vertical'])
await request(['split-window', '-t', leaderPane, '-h', '-P', '-F', '#{pane_id}'])
await request(['kill-pane', '-t', '%3'])
await expect(
request(['split-window', '-t', leaderPane, '-h', '-P', '-F', '#{pane_id}'])
).resolves.toMatchObject({ stdout: '%4\n', exitCode: 0 })
await expect(
request(['list-panes', '-t', 'orca:0', '-F', '#{pane_id}'])
).resolves.toMatchObject({
stdout: '%1\n%2\n%4\n'
})
expect(splitCalls.map((call) => [call.handle, call.direction, call.envPane])).toEqual([
['leader-handle', 'vertical', '%2'],
['teammate-1', 'horizontal', '%3'],
['teammate-1', 'horizontal', '%4']
])
})
it('rejects stale or unauthorized shim calls', async () => {
const { service, teamId, leaderPane, api } = createServiceWithLeader()
await expect(
service.handleTmuxCompat(
{ teamId, token: 'wrong', envPane: leaderPane, argv: ['list-panes'] },
api
)
).resolves.toMatchObject({ ok: false, exitCode: 1 })
})
})
@@ -0,0 +1,109 @@
import { randomBytes, randomUUID } from 'crypto'
import { splitTmuxCommand } from '../../shared/claude-agent-teams-tmux-compat'
import { ClaudeAgentTeamsTmuxDispatcher } from './claude-agent-teams-tmux-dispatcher'
import type {
AgentTeam,
AgentTeamsLaunchEnv,
AgentTeamsTerminalApi,
AgentTeamsTmuxCompatRequest,
AgentTeamsTmuxCompatResponse,
TeamPane
} from './claude-agent-teams-types'
export type {
AgentTeamsLaunchEnv,
AgentTeamsTerminalApi,
AgentTeamsTmuxCompatRequest,
AgentTeamsTmuxCompatResponse
} from './claude-agent-teams-types'
export class ClaudeAgentTeamsService {
private readonly teams = new Map<string, AgentTeam>()
private readonly dispatcher = new ClaudeAgentTeamsTmuxDispatcher()
createLaunchEnv(args: {
leaderHandle: string
baseEnv: Record<string, string | undefined>
shimDir: string
shimBin: string
}): AgentTeamsLaunchEnv {
const teamId = `team-${randomUUID()}`
const token = randomBytes(32).toString('base64url')
const leaderPane = '%1'
const pathValue = [args.shimDir, args.baseEnv.PATH]
.filter(Boolean)
.join(process.platform === 'win32' ? ';' : ':')
const tmuxValue = `/tmp/orca-claude-agent-teams/${teamId},0,1`
const env: Record<string, string> = {
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
PATH: pathValue,
TMUX: tmuxValue,
TMUX_PANE: leaderPane,
TERM: 'screen-256color',
COLORTERM: args.baseEnv.COLORTERM || 'truecolor',
ORCA_AGENT_TEAMS_TEAM_ID: teamId,
ORCA_AGENT_TEAMS_TOKEN: token,
ORCA_AGENT_TEAMS_LEADER_PANE: leaderPane,
ORCA_AGENT_TEAMS_SHIM_DIR: args.shimDir,
ORCA_AGENT_TEAMS_SHIM_BIN: args.shimBin
}
if (args.baseEnv.ORCA_PAIRING_CODE) {
env.ORCA_PAIRING_CODE = args.baseEnv.ORCA_PAIRING_CODE
}
if (args.baseEnv.ORCA_ENVIRONMENT) {
env.ORCA_ENVIRONMENT = args.baseEnv.ORCA_ENVIRONMENT
}
const leader: TeamPane = { fakePaneId: leaderPane, handle: args.leaderHandle, index: 0 }
this.teams.set(teamId, {
teamId,
token,
leaderPane,
leaderHandle: args.leaderHandle,
sessionName: 'orca',
windowIndex: '0',
tmuxValue,
baseEnv: env,
panes: new Map([[leaderPane, leader]]),
paneOrder: [leaderPane],
nextPaneNumber: 2,
mainVertical: null,
previouslyFocusedPane: null
})
return { teamId, token, leaderPane, env }
}
removeTeamForLeaderHandle(handle: string): void {
for (const [teamId, team] of this.teams) {
if (team.leaderHandle === handle) {
this.teams.delete(teamId)
}
}
}
async handleTmuxCompat(
request: AgentTeamsTmuxCompatRequest,
api: AgentTeamsTerminalApi
): Promise<AgentTeamsTmuxCompatResponse> {
try {
const team = this.resolveTeam(request)
const { command, args } = splitTmuxCommand(request.argv)
const stdout = await this.dispatcher.dispatch(team, command, args, request.envPane, api)
return { ok: true, stdout, stderr: '', exitCode: 0 }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { ok: false, stdout: '', stderr: `tmux: ${message}\n`, exitCode: 1 }
}
}
private resolveTeam(request: AgentTeamsTmuxCompatRequest): AgentTeam {
const team = this.teams.get(request.teamId)
if (!team || team.token !== request.token) {
throw new Error('stale or unauthorized agent team')
}
if (!team.panes.has(request.envPane)) {
throw new Error(`unknown pane: ${request.envPane}`)
}
return team
}
}
@@ -0,0 +1,82 @@
import { chmod, mkdtemp, readFile, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import {
buildClaudeAgentTeamsLaunchPlan,
ensureClaudeAgentTeamsShimDir,
resolveClaudeAgentTeamsShimBin
} from './claude-agent-teams-shim-env'
const roots: string[] = []
afterEach(async () => {
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })))
roots.length = 0
})
describe('claude agent teams shim env', () => {
it('writes a private tmux shim that calls the Orca shim command', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-shim-'))
roots.push(root)
await ensureClaudeAgentTeamsShimDir(root)
await expect(readFile(join(root, 'tmux'), 'utf8')).resolves.toContain('agent-teams-tmux "$@"')
})
it('builds native shim env only for direct Claude commands', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-cli-'))
roots.push(root)
const cliName = process.platform === 'win32' ? 'orca-dev.cmd' : 'orca-dev'
const cliPath = join(root, cliName)
await writeFile(cliPath, '#!/usr/bin/env sh\n', 'utf8')
if (process.platform !== 'win32') {
await chmod(cliPath, 0o755)
}
let capturedShimBin = ''
const plan = await buildClaudeAgentTeamsLaunchPlan({
command: "claude 'hello'",
mode: 'native-panes-shim',
baseEnv: { PATH: root },
createTeamEnv: (shimDir, shimBin) => {
capturedShimBin = shimBin
return {
PATH: `${shimDir}:/usr/bin`,
TMUX: '/tmp/orca/fake,0,0',
TMUX_PANE: '%1'
}
}
})
expect(plan).toMatchObject({
command: "claude --teammate-mode auto 'hello'",
env: expect.objectContaining({ TMUX_PANE: '%1' }),
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
})
expect(capturedShimBin).toBe(cliPath)
await expect(
buildClaudeAgentTeamsLaunchPlan({
command: "echo ok; claude 'hello'",
mode: 'native-panes-shim',
baseEnv: {},
createTeamEnv: () => ({})
})
).resolves.toBeNull()
})
it('resolves the dev CLI wrapper for the tmux callback binary', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-agent-teams-cli-'))
roots.push(root)
const cliName = process.platform === 'win32' ? 'orca-dev.cmd' : 'orca-dev'
const cliPath = join(root, cliName)
await writeFile(cliPath, '#!/usr/bin/env sh\n', 'utf8')
if (process.platform !== 'win32') {
await chmod(cliPath, 0o755)
}
expect(resolveClaudeAgentTeamsShimBin({ PATH: root })).toBe(cliPath)
})
})
@@ -0,0 +1,169 @@
import { chmod, mkdir, readFile, rename, rm, writeFile } from 'fs/promises'
import { accessSync, constants, existsSync } from 'fs'
import { homedir } from 'os'
import { delimiter, dirname, join } from 'path'
import {
addClaudeTeammateModeAuto,
addClaudeTeammateModeInProcess,
isDirectClaudeCommand,
type ClaudeAgentTeamsMode
} from '../../shared/claude-agent-teams-tmux-compat'
export type ClaudeAgentTeamsLaunchPlan = {
command: string
env: Record<string, string>
envToDelete?: string[]
}
export async function ensureClaudeAgentTeamsShimDir(root = defaultShimRoot()): Promise<string> {
await mkdir(root, { recursive: true })
await writeIfChanged(join(root, 'tmux'), unixShimScript())
if (process.platform === 'win32') {
await writeIfChanged(join(root, 'tmux.cmd'), windowsShimScript())
}
return root
}
export async function buildClaudeAgentTeamsLaunchPlan(args: {
command: string | undefined
mode: ClaudeAgentTeamsMode | undefined
baseEnv: Record<string, string | undefined>
createTeamEnv: (shimDir: string, shimBin: string) => Record<string, string>
}): Promise<ClaudeAgentTeamsLaunchPlan | null> {
const mode = args.mode ?? 'off'
if (!args.command || mode === 'off' || !isDirectClaudeCommand(args.command)) {
return null
}
if (mode === 'in-process' || process.platform === 'win32') {
return {
command: addClaudeTeammateModeInProcess(args.command),
env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' }
}
}
const shimDir = await ensureClaudeAgentTeamsShimDir()
const shimBin = resolveClaudeAgentTeamsShimBin(args.baseEnv)
const env = args.createTeamEnv(shimDir, shimBin)
return {
command: addClaudeTeammateModeAuto(args.command),
env,
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR']
}
}
export function resolveClaudeAgentTeamsShimBin(
env: Record<string, string | undefined> = process.env
): string {
if (env.ORCA_AGENT_TEAMS_SHIM_BIN) {
return env.ORCA_AGENT_TEAMS_SHIM_BIN
}
const bundled = bundledLauncherPath()
if (bundled && isExecutableFile(bundled)) {
return bundled
}
return (
findExecutableOnPath(process.platform === 'win32' ? 'orca-dev.cmd' : 'orca-dev', env.PATH) ??
findExecutableOnPath(platformCliCommandName(), env.PATH) ??
platformCliCommandName()
)
}
function defaultShimRoot(): string {
return join(homedir(), '.orca', 'claude-agent-teams-bin')
}
function bundledLauncherPath(): string | null {
if (!process.resourcesPath) {
return null
}
if (process.platform === 'darwin') {
return join(process.resourcesPath, 'bin', 'orca')
}
if (process.platform === 'linux') {
return join(process.resourcesPath, 'bin', 'orca-ide')
}
if (process.platform === 'win32') {
return join(process.resourcesPath, 'bin', 'orca.cmd')
}
return null
}
function platformCliCommandName(): string {
if (process.platform === 'linux') {
return 'orca-ide'
}
if (process.platform === 'win32') {
return 'orca.cmd'
}
return 'orca'
}
function findExecutableOnPath(command: string, pathValue: string | undefined): string | null {
for (const directory of pathValue?.split(delimiter) ?? []) {
if (!directory) {
continue
}
const candidate = join(directory, command)
if (isExecutableFile(candidate)) {
return candidate
}
}
return null
}
function isExecutableFile(candidate: string): boolean {
try {
if (!existsSync(candidate)) {
return false
}
accessSync(candidate, process.platform === 'win32' ? constants.F_OK : constants.X_OK)
return true
} catch {
return false
}
}
function unixShimScript(): string {
return [
'#!/usr/bin/env sh',
'set -eu',
'exec "${ORCA_AGENT_TEAMS_SHIM_BIN:-orca}" agent-teams-tmux "$@"',
''
].join('\n')
}
function windowsShimScript(): string {
return [
'@echo off',
'setlocal',
'if "%ORCA_AGENT_TEAMS_SHIM_BIN%"=="" (',
' set "ORCA_AGENT_TEAMS_SHIM_BIN=orca"',
')',
'"%ORCA_AGENT_TEAMS_SHIM_BIN%" agent-teams-tmux %*',
''
].join('\r\n')
}
async function writeIfChanged(path: string, content: string): Promise<void> {
try {
if ((await readFile(path, 'utf8')) === content) {
return
}
} catch {
// rewrite below
}
await mkdir(dirname(path), { recursive: true })
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
let renamed = false
try {
await writeFile(tmp, content, 'utf8')
if (process.platform !== 'win32') {
await chmod(tmp, 0o755)
}
await rename(tmp, path)
renamed = true
} finally {
if (!renamed) {
await rm(tmp, { force: true })
}
}
}
@@ -0,0 +1,312 @@
import {
parseTmuxArgs,
renderTmuxFormat,
tmuxSendKeysText,
tmuxValue
} from '../../shared/claude-agent-teams-tmux-compat'
import type { AgentTeam, AgentTeamsTerminalApi, TeamPane } from './claude-agent-teams-types'
type ResolvedTarget = { type: 'pane'; pane: TeamPane } | { type: 'window' }
export class ClaudeAgentTeamsTmuxDispatcher {
async dispatch(
team: AgentTeam,
command: string,
args: string[],
envPane: string,
api: AgentTeamsTerminalApi
): Promise<string> {
switch (command) {
case '-V':
case '-v':
return 'tmux 3.4\n'
case 'show-options':
case 'show-option':
case 'show':
return this.showOptions(args)
case 'display-message':
case 'display':
case 'displayp':
return this.displayMessage(team, args, envPane)
case 'split-window':
case 'splitw':
return await this.splitWindow(team, args, envPane, api)
case 'select-layout':
return this.selectLayout(team, args, envPane)
case 'resize-pane':
case 'resizep':
return ''
case 'list-panes':
case 'lsp':
return this.listPanes(team, args, envPane)
case 'send-keys':
case 'send':
return await this.sendKeys(team, args, envPane, api)
case 'capture-pane':
case 'capturep':
return await this.capturePane(team, args, envPane, api)
case 'select-pane':
case 'selectp':
return await this.selectPane(team, args, envPane, api)
case 'kill-pane':
case 'killp':
return await this.killPane(team, args, envPane, api)
case 'last-pane':
return await this.lastPane(team, args, api)
case 'set-option':
case 'set':
case 'set-window-option':
case 'setw':
case 'set-hook':
case 'refresh-client':
case 'attach-session':
case 'detach-client':
case 'source-file':
case 'wait-for':
case 'has-session':
case 'has':
return ''
default:
throw new Error(`unsupported command: ${command}`)
}
}
private showOptions(args: string[]): string {
const parsed = parseTmuxArgs(args, ['-t'], ['-g', '-q', '-s', '-v', '-w'])
const optionName = parsed.positional.at(-1) ?? ''
if (optionName !== 'extended-keys') {
throw new Error(`unsupported option: ${optionName}`)
}
return parsed.flags.has('-v') ? 'on\n' : 'extended-keys on\n'
}
private displayMessage(team: AgentTeam, args: string[], envPane: string): string {
const parsed = parseTmuxArgs(args, ['-F', '-t'], ['-p'])
const target = this.resolvePaneOrWindow(team, tmuxValue(parsed, '-t') ?? envPane)
const pane = target.type === 'window' ? this.resolvePane(team, envPane) : target.pane
const format =
parsed.positional.length > 0 ? parsed.positional.join(' ') : tmuxValue(parsed, '-F')
return `${renderTmuxFormat(format, this.formatContext(team, pane), '')}\n`
}
private async splitWindow(
team: AgentTeam,
args: string[],
envPane: string,
api: AgentTeamsTerminalApi
): Promise<string> {
const parsed = parseTmuxArgs(
args,
['-c', '-F', '-l', '-t'],
['-P', '-b', '-d', '-f', '-h', '-v']
)
const targetPane = this.resolvePane(team, tmuxValue(parsed, '-t') ?? envPane)
const fakePaneId = `%${team.nextPaneNumber}`
team.nextPaneNumber += 1
const splitTarget = this.resolveSplitTarget(team, targetPane, parsed.flags.has('-h'))
const env = {
...team.baseEnv,
TMUX_PANE: fakePaneId,
ORCA_AGENT_TEAMS_LEADER_PANE: team.leaderPane
}
const split = await api.splitTerminal(splitTarget.pane.handle, {
direction: splitTarget.direction,
command: parsed.positional.join(' ') || undefined,
env,
envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'],
activate: false
})
const pane: TeamPane = {
fakePaneId,
handle: split.handle,
index: team.paneOrder.length
}
team.panes.set(fakePaneId, pane)
team.paneOrder.push(fakePaneId)
this.updateMainVerticalAfterSplit(team, fakePaneId, splitTarget)
if (!parsed.flags.has('-P')) {
return ''
}
return `${renderTmuxFormat(tmuxValue(parsed, '-F'), this.formatContext(team, pane), fakePaneId)}\n`
}
private selectLayout(team: AgentTeam, args: string[], envPane: string): string {
const parsed = parseTmuxArgs(args, ['-t'], [])
const layout = parsed.positional[0] ?? ''
if (layout === 'main-vertical') {
const target = this.resolvePaneOrWindow(team, tmuxValue(parsed, '-t') ?? envPane)
const targetPane = target.type === 'pane' ? target.pane : null
team.mainVertical = {
mainPane: team.leaderPane,
lastColumnPane:
team.mainVertical?.lastColumnPane ??
(targetPane && targetPane.fakePaneId !== team.leaderPane ? targetPane.fakePaneId : null)
}
} else if (layout) {
team.mainVertical = null
}
return ''
}
private listPanes(team: AgentTeam, args: string[], envPane: string): string {
const parsed = parseTmuxArgs(args, ['-F', '-t'], [])
this.resolvePaneOrWindow(team, tmuxValue(parsed, '-t') ?? envPane)
return team.paneOrder
.map((paneId) => {
const pane = team.panes.get(paneId)!
return renderTmuxFormat(
tmuxValue(parsed, '-F'),
this.formatContext(team, pane),
pane.fakePaneId
)
})
.join('\n')
.concat('\n')
}
private async sendKeys(
team: AgentTeam,
args: string[],
envPane: string,
api: AgentTeamsTerminalApi
): Promise<string> {
const parsed = parseTmuxArgs(args, ['-t'], ['-l'])
const pane = this.resolvePane(team, tmuxValue(parsed, '-t') ?? envPane)
const text = tmuxSendKeysText(parsed.positional, parsed.flags.has('-l'))
if (text) {
await api.sendTerminal(pane.handle, { text })
}
return ''
}
private async capturePane(
team: AgentTeam,
args: string[],
envPane: string,
api: AgentTeamsTerminalApi
): Promise<string> {
const parsed = parseTmuxArgs(args, ['-E', '-S', '-t'], ['-J', '-N', '-p'])
const pane = this.resolvePane(team, tmuxValue(parsed, '-t') ?? envPane)
const read = await api.readTerminal(pane.handle, { limit: 1000 })
const text = read.tail.join('\n')
return parsed.flags.has('-p') ? `${text}\n` : ''
}
private async selectPane(
team: AgentTeam,
args: string[],
envPane: string,
api: AgentTeamsTerminalApi
): Promise<string> {
const parsed = parseTmuxArgs(args, ['-P', '-T', '-t'], [])
if (tmuxValue(parsed, '-P') || tmuxValue(parsed, '-T')) {
return ''
}
const pane = this.resolvePane(team, tmuxValue(parsed, '-t') ?? envPane)
team.previouslyFocusedPane = envPane
await api.focusTerminal(pane.handle)
return ''
}
private async killPane(
team: AgentTeam,
args: string[],
envPane: string,
api: AgentTeamsTerminalApi
): Promise<string> {
const parsed = parseTmuxArgs(args, ['-t'], [])
const pane = this.resolvePane(team, tmuxValue(parsed, '-t') ?? envPane)
if (pane.fakePaneId === team.leaderPane) {
throw new Error('refusing to kill leader pane')
}
await api.closeTerminal(pane.handle)
team.panes.delete(pane.fakePaneId)
team.paneOrder = team.paneOrder.filter((id) => id !== pane.fakePaneId)
if (team.mainVertical?.lastColumnPane === pane.fakePaneId) {
team.mainVertical.lastColumnPane =
[...team.paneOrder].reverse().find((id) => id !== team.leaderPane) ?? null
}
return ''
}
private async lastPane(
team: AgentTeam,
args: string[],
api: AgentTeamsTerminalApi
): Promise<string> {
parseTmuxArgs(args, ['-t'], [])
const pane = team.previouslyFocusedPane ? team.panes.get(team.previouslyFocusedPane) : null
if (pane) {
await api.focusTerminal(pane.handle)
}
return ''
}
private updateMainVerticalAfterSplit(
team: AgentTeam,
fakePaneId: string,
splitTarget: { pane: TeamPane; direction: 'horizontal' | 'vertical' }
): void {
if (team.mainVertical) {
team.mainVertical.lastColumnPane = fakePaneId
} else if (
splitTarget.direction === 'vertical' &&
splitTarget.pane.fakePaneId === team.leaderPane
) {
team.mainVertical = { mainPane: team.leaderPane, lastColumnPane: fakePaneId }
}
}
private resolveSplitTarget(
team: AgentTeam,
targetPane: TeamPane,
horizontal: boolean
): { pane: TeamPane; direction: 'horizontal' | 'vertical' } {
if (horizontal && team.mainVertical?.lastColumnPane) {
return {
pane: team.panes.get(team.mainVertical.lastColumnPane) ?? targetPane,
direction: 'horizontal'
}
}
// Why: tmux `split-window -h` means left/right panes; Orca names that
// layout by the vertical divider it creates.
return { pane: targetPane, direction: horizontal ? 'vertical' : 'horizontal' }
}
private resolvePaneOrWindow(team: AgentTeam, target: string): ResolvedTarget {
if (target.includes(':') || target === team.sessionName || target.startsWith('@')) {
return { type: 'window' }
}
return { type: 'pane', pane: this.resolvePane(team, target) }
}
private resolvePane(team: AgentTeam, target: string): TeamPane {
const pane = team.panes.get(target)
if (!pane) {
throw new Error(`unknown pane: ${target}`)
}
return pane
}
private formatContext(team: AgentTeam, pane: TeamPane): Record<string, string> {
return {
session_name: team.sessionName,
session_id: '$0',
window_id: '@0',
window_index: team.windowIndex,
window_name: 'agent-teams',
window_active: '1',
window_flags: '*',
pane_id: pane.fakePaneId,
pane_index: String(pane.index),
pane_active: pane.fakePaneId === team.leaderPane ? '1' : '0',
pane_title: '',
pane_width: '',
pane_height: '',
pane_left: '',
pane_top: '',
window_width: '',
window_height: ''
}
}
}
@@ -0,0 +1,76 @@
import type {
RuntimeTerminalClose,
RuntimeTerminalFocus,
RuntimeTerminalRead,
RuntimeTerminalSend,
RuntimeTerminalShow,
RuntimeTerminalSplit
} from '../../shared/runtime-types'
export type AgentTeamsTmuxCompatRequest = {
teamId: string
token: string
envPane: string
cwd?: string
argv: string[]
}
export type AgentTeamsTmuxCompatResponse = {
ok: boolean
stdout: string
stderr: string
exitCode: number
}
export type AgentTeamsLaunchEnv = {
teamId: string
token: string
leaderPane: string
env: Record<string, string>
}
export type AgentTeamsTerminalApi = {
splitTerminal(
handle: string,
opts: {
direction?: 'horizontal' | 'vertical'
command?: string
env?: Record<string, string>
envToDelete?: string[]
activate?: boolean
}
): Promise<RuntimeTerminalSplit>
readTerminal(handle: string, opts?: { limit?: number }): Promise<RuntimeTerminalRead>
sendTerminal(
handle: string,
action: { text?: string; enter?: boolean; interrupt?: boolean }
): Promise<RuntimeTerminalSend>
focusTerminal(handle: string): Promise<RuntimeTerminalFocus>
closeTerminal(handle: string): Promise<RuntimeTerminalClose>
showTerminal(handle: string): Promise<RuntimeTerminalShow>
}
export type TeamPane = {
fakePaneId: string
handle: string
index: number
}
export type AgentTeam = {
teamId: string
token: string
leaderPane: string
leaderHandle: string
sessionName: string
windowIndex: string
tmuxValue: string
baseEnv: Record<string, string>
panes: Map<string, TeamPane>
paneOrder: string[]
nextPaneNumber: number
mainVertical: {
mainPane: string
lastColumnPane: string | null
} | null
previouslyFocusedPane: string | null
}
@@ -148,6 +148,20 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => {
})
})
it('resolves full remote-tracking refs with longest configured remote matching', async () => {
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'foo\nfoo/bar\norigin\n', stderr: '' })
const runtime = new OrcaRuntimeService(null)
await expect(
runtime.resolveRemoteTrackingBase('/repo/e', 'refs/remotes/foo/bar/main')
).resolves.toEqual({
remote: 'foo/bar',
branch: 'main',
ref: 'refs/remotes/foo/bar/main',
base: 'foo/bar/main'
})
})
it('refreshes a remote-tracking base with an exact no-tags refspec', async () => {
mockFetchResults([{ stdout: '', stderr: '' }])
const runtime = new OrcaRuntimeService(null)
+114 -1
View File
@@ -1377,7 +1377,17 @@ describe('OrcaRuntimeService', () => {
createdWorktree.path,
'cli-fresh-base',
'origin/main',
false
false,
false,
{
suggestLocalBaseRefUpdate: true,
remoteTrackingBase: {
remote: 'origin',
branch: 'main',
ref: 'refs/remotes/origin/main',
base: 'origin/main'
}
}
)
expect(result.worktree).toMatchObject({ path: createdWorktree.path })
} finally {
@@ -1385,6 +1395,56 @@ describe('OrcaRuntimeService', () => {
}
})
it('returns runtime local base update suggestions from addWorktree', async () => {
const runtime = new OrcaRuntimeService(store)
const createdWorktree = {
path: '/tmp/workspaces/cli-stale-main',
head: 'def',
branch: 'cli-stale-main',
isBare: false,
isMainWorktree: false
}
computeWorktreePathMock.mockReturnValue(createdWorktree.path)
ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path)
vi.mocked(addWorktree).mockResolvedValueOnce({
localBaseRefUpdateSuggestion: {
baseRef: 'origin/main',
localBranch: 'main',
behind: 5
}
})
vi.mocked(listWorktrees).mockResolvedValueOnce([createdWorktree])
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => {
if (args[0] === 'rev-parse' && args.includes('refs/heads/cli-stale-main^{commit}')) {
throw new Error('branch not found')
}
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) {
return { stdout: '/tmp/repo/.git\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args[1] === '--verify') {
return { stdout: 'base-sha\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
try {
const result = await runtime.createManagedWorktree({
repoSelector: 'id:repo-1',
name: 'cli-stale-main'
})
expect(result.localBaseRefUpdateSuggestion).toEqual({
baseRef: 'origin/main',
localBranch: 'main',
behind: 5
})
} finally {
gitSpy.mockRestore()
}
})
it('does not create runtime local worktrees when remote-tracking base refresh fails', async () => {
const runtime = new OrcaRuntimeService(store)
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => {
@@ -3852,6 +3912,59 @@ describe('OrcaRuntimeService', () => {
})
})
it('enables Claude Agent Teams only for direct Claude launches when configured in-process', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
const runtimeStore = {
...store,
getSettings: () => ({
...store.getSettings(),
claudeAgentTeamsMode: 'in-process' as const
})
}
const runtime = new OrcaRuntimeService(runtimeStore)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
command: "claude 'hello'"
})
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
command: "echo ok; claude 'hello'"
})
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
command: 'codex'
})
const directClaude = spawn.mock.calls[0]?.[0] as {
command?: string
env?: Record<string, string>
}
const compoundClaude = spawn.mock.calls[1]?.[0] as {
command?: string
env?: Record<string, string>
}
const normalAgent = spawn.mock.calls[2]?.[0] as {
command?: string
env?: Record<string, string>
}
expect(directClaude.command).toBe("claude --teammate-mode in-process 'hello'")
expect(directClaude.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBe('1')
expect(directClaude.env?.TMUX).toBeUndefined()
expect(compoundClaude.command).toBe("echo ok; claude 'hello'")
expect(compoundClaude.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBeUndefined()
expect(compoundClaude.env?.TMUX).toBeUndefined()
expect(normalAgent.command).toBe('codex')
expect(normalAgent.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBeUndefined()
expect(normalAgent.env?.TMUX).toBeUndefined()
})
it('adopts renderer pane identity for remote runtime terminal creates', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
const runtime = new OrcaRuntimeService(store)
+151 -21
View File
@@ -182,6 +182,16 @@ import { serveSimStateWatcher } from '../emulator/serve-sim-state-watcher'
import type { EmulatorBridge } from '../emulator/emulator-bridge'
import { RuntimeFileCommands } from './orca-runtime-files'
import { RuntimeGitCommands } from './orca-runtime-git'
import { ClaudeAgentTeamsService } from './claude-agent-teams-service'
import type {
AgentTeamsTmuxCompatRequest,
AgentTeamsTmuxCompatResponse
} from './claude-agent-teams-service'
import {
buildClaudeAgentTeamsLaunchPlan,
ensureClaudeAgentTeamsShimDir,
resolveClaudeAgentTeamsShimBin
} from './claude-agent-teams-shim-env'
import { joinWorktreeRelativePath } from './runtime-relative-paths'
import { collectMemorySnapshot } from '../memory/collector'
import { BrowserWindow, ipcMain } from 'electron'
@@ -541,6 +551,7 @@ type RuntimeStore = {
workspaceDir: string
nestWorkspaces: boolean
refreshLocalBaseRefOnWorktreeCreate: boolean
localBaseRefSuggestionDismissed?: boolean
branchPrefix: string
branchPrefixCustom: string
defaultTuiAgent?: GlobalSettings['defaultTuiAgent']
@@ -559,6 +570,7 @@ type RuntimeStore = {
mobileEmulatorEnabled?: boolean
mobileEmulatorDefaultDeviceUdid?: string | null
voice?: VoiceSettings
claudeAgentTeamsMode?: GlobalSettings['claudeAgentTeamsMode']
}
// Why: narrow to `unknown` return so test mocks can return void without
// a cast. The runtime never reads the return value — the persisted value
@@ -720,6 +732,7 @@ type RuntimePtyController = {
cwd?: string
command?: string
env?: Record<string, string>
envToDelete?: string[]
telemetry?: WorktreeStartupLaunch['telemetry']
connectionId?: string | null
worktreeId?: string
@@ -1570,6 +1583,7 @@ export class OrcaRuntimeService {
private accountServices: RuntimeAccountServices | null = null
private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null
private automationService: AutomationService | null = null
private readonly claudeAgentTeams = new ClaudeAgentTeamsService()
private mobileDictation: {
id: string
owner: string
@@ -8990,7 +9004,16 @@ export class OrcaRuntimeService {
)
}
const existingBranchOption = { checkoutExistingBranch }
const suggestLocalBaseRefUpdate =
!settings.refreshLocalBaseRefOnWorktreeCreate &&
!settings.localBaseRefSuggestionDismissed &&
Boolean(remoteTrackingBase)
const remoteTrackingBaseOption = remoteTrackingBase ? { remoteTrackingBase } : undefined
const existingBranchOption = {
checkoutExistingBranch,
...remoteTrackingBaseOption,
...(suggestLocalBaseRefUpdate ? { suggestLocalBaseRefUpdate } : {})
}
const addResult: AddWorktreeResult =
(await (sparseDirectories.length > 0
? checkoutExistingBranch
@@ -9003,14 +9026,34 @@ export class OrcaRuntimeService {
settings.refreshLocalBaseRefOnWorktreeCreate,
existingBranchOption
)
: addSparseWorktree(
repo.path,
worktreePath,
branchName,
sparseDirectories,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate
)
: suggestLocalBaseRefUpdate
? addSparseWorktree(
repo.path,
worktreePath,
branchName,
sparseDirectories,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate,
{ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate }
)
: remoteTrackingBaseOption
? addSparseWorktree(
repo.path,
worktreePath,
branchName,
sparseDirectories,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate,
remoteTrackingBaseOption
)
: addSparseWorktree(
repo.path,
worktreePath,
branchName,
sparseDirectories,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate
)
: checkoutExistingBranch
? addWorktree(
repo.path,
@@ -9021,13 +9064,33 @@ export class OrcaRuntimeService {
false,
existingBranchOption
)
: addWorktree(
repo.path,
worktreePath,
branchName,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate
))) ?? {}
: suggestLocalBaseRefUpdate
? addWorktree(
repo.path,
worktreePath,
branchName,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate,
false,
{ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate }
)
: remoteTrackingBaseOption
? addWorktree(
repo.path,
worktreePath,
branchName,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate,
false,
remoteTrackingBaseOption
)
: addWorktree(
repo.path,
worktreePath,
branchName,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate
))) ?? {}
let configuredPushTarget: GitPushTarget | undefined
if (preparedPushTarget) {
@@ -9307,6 +9370,9 @@ export class OrcaRuntimeService {
...(warning ? { warning } : {}),
...(addResult.localBaseRefRefresh
? { localBaseRefRefresh: addResult.localBaseRefRefresh }
: {}),
...(addResult.localBaseRefUpdateSuggestion
? { localBaseRefUpdateSuggestion: addResult.localBaseRefUpdateSuggestion }
: {})
}
}
@@ -9690,13 +9756,17 @@ export class OrcaRuntimeService {
return null
}
const remoteRefPrefix = 'refs/remotes/'
const shortBaseBranch = baseBranch.startsWith(remoteRefPrefix)
? baseBranch.slice(remoteRefPrefix.length)
: baseBranch
const remote = remotes
.filter((candidate) => baseBranch.startsWith(`${candidate}/`))
.filter((candidate) => shortBaseBranch.startsWith(`${candidate}/`))
.sort((a, b) => b.length - a.length)[0]
if (!remote) {
return null
}
const branch = baseBranch.slice(remote.length + 1)
const branch = shortBaseBranch.slice(remote.length + 1)
if (!branch) {
return null
}
@@ -9704,7 +9774,7 @@ export class OrcaRuntimeService {
remote,
branch,
ref: `refs/remotes/${remote}/${branch}`,
base: baseBranch
base: `${remote}/${branch}`
}
}
@@ -10768,8 +10838,28 @@ export class OrcaRuntimeService {
const tabId = canAdoptPaneIdentity ? (hintedTabId as string) : randomUUID()
const leafId = canAdoptPaneIdentity ? (opts.leafId as string) : randomUUID()
const paneKey = makePaneKey(tabId, leafId)
const baseEnv = opts.env ?? {}
const agentTeamsPlan = await buildClaudeAgentTeamsLaunchPlan({
command: opts.command,
mode: this.store?.getSettings?.().claudeAgentTeamsMode,
baseEnv: {
...process.env,
...baseEnv
},
createTeamEnv: (shimDir, shimBin) =>
this.claudeAgentTeams.createLaunchEnv({
leaderHandle: preAllocatedHandle,
baseEnv: {
...process.env,
...baseEnv
},
shimDir,
shimBin
}).env
})
const env = {
...opts.env,
...baseEnv,
...agentTeamsPlan?.env,
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: tabId,
ORCA_WORKTREE_ID: worktree.id
@@ -10778,8 +10868,9 @@ export class OrcaRuntimeService {
cols: 120,
rows: 40,
cwd: worktree.path,
command: opts.command,
command: agentTeamsPlan?.command ?? opts.command,
env,
envToDelete: agentTeamsPlan?.envToDelete,
telemetry: opts.telemetry,
connectionId: repo?.connectionId ?? null,
worktreeId: worktree.id,
@@ -11293,6 +11384,7 @@ export class OrcaRuntimeService {
async closeTerminal(handle: string): Promise<RuntimeTerminalClose> {
this.assertGraphReady()
const pty = this.getLivePtyForHandle(handle)
this.claudeAgentTeams.removeTeamForLeaderHandle(handle)
if (pty) {
const ptyKilled = this.ptyController?.kill(pty.pty.ptyId) ?? false
return { handle, tabId: pty.record.tabId, ptyKilled }
@@ -11322,6 +11414,7 @@ export class OrcaRuntimeService {
direction?: 'horizontal' | 'vertical'
command?: string
env?: Record<string, string>
envToDelete?: string[]
activate?: boolean
telemetrySource?: TerminalPaneSplitSource
} = {}
@@ -11359,6 +11452,7 @@ export class OrcaRuntimeService {
direction?: 'horizontal' | 'vertical'
command?: string
env?: Record<string, string>
envToDelete?: string[]
activate?: boolean
telemetrySource?: TerminalPaneSplitSource
} = {}
@@ -11391,6 +11485,7 @@ export class OrcaRuntimeService {
ORCA_TAB_ID: parentTabId,
ORCA_WORKTREE_ID: worktree.id
},
envToDelete: opts.envToDelete,
connectionId: repo?.connectionId ?? null,
worktreeId: worktree.id,
preAllocatedHandle
@@ -11422,6 +11517,41 @@ export class OrcaRuntimeService {
return { handle: this.issuePtyHandle(createdPty ?? pty), tabId: parentTabId, paneRuntimeId: -1 }
}
async handleAgentTeamsTmuxCompat(
request: AgentTeamsTmuxCompatRequest
): Promise<AgentTeamsTmuxCompatResponse> {
return await this.claudeAgentTeams.handleTmuxCompat(request, {
splitTerminal: (handle, opts) => this.splitTerminal(handle, opts),
readTerminal: (handle, opts) => this.readTerminal(handle, opts),
sendTerminal: (handle, action) => this.sendTerminal(handle, action),
focusTerminal: (handle) => this.focusTerminal(handle),
closeTerminal: (handle) => this.closeTerminal(handle),
showTerminal: (handle) => this.showTerminal(handle)
})
}
async prepareClaudeAgentTeamsLeader(args: {
paneKey: string
baseEnv?: Record<string, string>
}): Promise<{ env: Record<string, string> }> {
const handle = this.getTerminalHandleForPaneKey(args.paneKey)
if (!handle) {
throw new Error('claude_agent_teams_requires_orca_terminal')
}
const baseEnv = {
...process.env,
...args.baseEnv
}
const shimDir = await ensureClaudeAgentTeamsShimDir()
const shimBin = resolveClaudeAgentTeamsShimBin(baseEnv)
return this.claudeAgentTeams.createLaunchEnv({
leaderHandle: handle,
baseEnv,
shimDir,
shimBin
})
}
private waitForNewLeafInTab(
tabId: string,
existingLeafKeys: Set<string>,
+30
View File
@@ -485,6 +485,19 @@ const TerminalStop = z.object({
worktree: requiredString('Missing worktree selector')
})
const AgentTeamsTmuxCompat = z.object({
teamId: requiredString('Missing agent team ID'),
token: requiredString('Missing agent team token'),
envPane: requiredString('Missing tmux pane identity'),
cwd: OptionalString,
argv: z.array(z.string())
})
const AgentTeamsPrepareLaunch = z.object({
paneKey: requiredString('Missing pane key'),
env: z.record(z.string(), z.string()).optional()
})
const TerminalResizeForClient = z.discriminatedUnion('mode', [
z.object({
terminal: requiredString('Missing terminal handle'),
@@ -771,6 +784,23 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
close: await runtime.closeTerminal(params.terminal)
})
}),
defineMethod({
name: 'agentTeams.tmuxCompat',
params: AgentTeamsTmuxCompat,
handler: async (params, { runtime }) => ({
tmux: await runtime.handleAgentTeamsTmuxCompat(params)
})
}),
defineMethod({
name: 'agentTeams.prepareLaunch',
params: AgentTeamsPrepareLaunch,
handler: async (params, { runtime }) => ({
launch: await runtime.prepareClaudeAgentTeamsLeader({
paneKey: params.paneKey,
baseEnv: params.env
})
})
}),
defineMethod({
name: 'terminal.setDisplayMode',
params: TerminalSetDisplayMode,
+2
View File
@@ -277,6 +277,8 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'speech.dictation.start',
'stats.summary',
'status.get',
'agentTeams.prepareLaunch',
'agentTeams.tmuxCompat',
'terminal.clearBuffer',
'terminal.close',
'terminal.create',
@@ -240,6 +240,51 @@ describe('run-electron-vite-dev', () => {
await stopWrapperAndTrackedPids(wrapper, trackedPids)
})
it.skipIf(process.platform === 'win32')(
'prepares userData orca and orca-dev wrappers for dev terminals',
async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'orca-dev-wrapper-'))
const userDataPath = join(tempDir, 'userData')
const pidFile = join(tempDir, 'grandchild.pid')
const envFile = join(tempDir, 'env.json')
const wrapperPath = resolve('config/scripts/run-electron-vite-dev.mjs')
const fakeCliPath = resolve('src/main/startup/__fixtures__/fake-electron-vite-dev-cli.mjs')
const wrapper = spawn(process.execPath, [wrapperPath], {
cwd: resolve('.'),
env: devWrapperTestEnv({
ORCA_DEV_USER_DATA_PATH: userDataPath,
ORCA_ELECTRON_VITE_CLI: fakeCliPath,
ORCA_SKIP_DEV_ELECTRON_APP_PREPARE: '1',
ORCA_SKIP_DEV_WEB_PREPARE: '1',
ORCA_DEV_WRAPPER_TEST_PID_FILE: pidFile,
ORCA_DEV_WRAPPER_TEST_ENV_FILE: envFile
}),
stdio: 'ignore'
})
expect(wrapper.pid).toBeTypeOf('number')
processesToCleanUp.add(wrapper.pid!)
await waitFor(() => {
try {
return readFileSync(envFile, 'utf8').trim().length > 0
} catch {
return false
}
})
const trackedPids = trackPidFile(pidFile)
const devWrapper = readFileSync(join(userDataPath, 'cli', 'bin', 'orca-dev'), 'utf8')
const publicAliasWrapper = readFileSync(join(userDataPath, 'cli', 'bin', 'orca'), 'utf8')
expect(publicAliasWrapper).toBe(devWrapper)
expect(publicAliasWrapper).toContain('ORCA_USER_DATA_PATH')
expect(publicAliasWrapper).toContain('out/cli/index.js')
await stopWrapperAndTrackedPids(wrapper, trackedPids)
}
)
it('consumes the stable-name flag before forwarding args to electron-vite', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'orca-dev-wrapper-'))
const pidFile = join(tempDir, 'grandchild.pid')
+7
View File
@@ -821,6 +821,13 @@ export type PreloadApi = {
listDetected: (args: { repoId: string }) => Promise<DetectedWorktreeListResult>
listAll: () => Promise<Worktree[]>
create: (args: CreateWorktreeArgs) => Promise<CreateWorktreeResult>
/** Two-phase progress for a background `create`, correlated by
* `creationId`. Renderer routes each event to its pending creation's
* status surface; the remote/runtime create path emits nothing, so the
* surface falls back to an indeterminate spinner. */
onCreateProgress: (
callback: (data: { creationId?: string; phase: 'fetching' | 'creating' }) => void
) => () => void
prefetchCreateBase: (args: { repoId: string; baseBranch?: string }) => Promise<void>
resolvePrBase: (args: {
repoId: string
+11
View File
@@ -544,6 +544,17 @@ const api = {
create: (args) => ipcRenderer.invoke('worktrees:create', args),
onCreateProgress: (
callback: (data: { creationId?: string; phase: 'fetching' | 'creating' }) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: { creationId?: string; phase: 'fetching' | 'creating' }
) => callback(data)
ipcRenderer.on('createWorktree:progress', listener)
return () => ipcRenderer.removeListener('createWorktree:progress', listener)
},
prefetchCreateBase: (args) => ipcRenderer.invoke('worktrees:prefetchCreateBase', args),
resolvePrBase: (args) => ipcRenderer.invoke('worktrees:resolvePrBase', args),
@@ -0,0 +1,86 @@
import type { GitExec } from './git-handler-ops'
import { areRelayWorktreePathsEqual, readRelayWorktreeList } from './git-handler-worktree-ops'
export async function refreshLocalBaseRefForWorktreeCreateOp(
git: GitExec,
params: Record<string, unknown>
): Promise<void> {
const repoPath = params.repoPath as string
const fullRef = params.fullRef as string
const remoteTrackingRef = params.remoteTrackingRef as string
const ownerWorktreePath = params.ownerWorktreePath as string | undefined
const checkOnly = params.checkOnly === true
if (
typeof repoPath !== 'string' ||
typeof fullRef !== 'string' ||
typeof remoteTrackingRef !== 'string' ||
(ownerWorktreePath !== undefined && typeof ownerWorktreePath !== 'string')
) {
throw new Error('Invalid local base ref refresh request.')
}
if (!fullRef.startsWith('refs/heads/') || !remoteTrackingRef.startsWith('refs/remotes/')) {
throw new Error('Invalid local base ref refresh refs.')
}
await git(['check-ref-format', fullRef], repoPath)
await git(['check-ref-format', remoteTrackingRef], repoPath)
const localOid = await revParseCommit(git, repoPath, fullRef, 'Local base ref is missing.')
const remoteOid = await revParseCommit(
git,
repoPath,
remoteTrackingRef,
'Remote-tracking base ref is missing.'
)
// Why: this RPC mutates refs/worktrees, so the relay repeats main-process
// safety checks at mutation time to close stale-preflight and direct-call gaps.
try {
await git(['merge-base', '--is-ancestor', localOid, remoteOid], repoPath)
} catch {
throw new Error('Local base ref is not a fast-forward update.')
}
const worktrees = await readRelayWorktreeList(git, repoPath)
const ownerWorktree = worktrees.find((worktree) => worktree.branch === fullRef)
if (ownerWorktree) {
if (ownerWorktreePath && !areRelayWorktreePathsEqual(ownerWorktree.path, ownerWorktreePath)) {
throw new Error('Local base ref is checked out in a different worktree.')
}
const { stdout } = await git(
['status', '--porcelain', '--untracked-files=no'],
ownerWorktree.path
)
if (stdout.trim()) {
throw new Error('Local base ref worktree has tracked changes.')
}
if (checkOnly) {
return
}
await git(['reset', '--hard', remoteOid], ownerWorktree.path)
return
}
// Why: not checked out anywhere — fast-forward the bare ref. The
// expected-old-OID form is a no-op-safe compare-and-swap if the ref moved
// since the caller's evaluation snapshot.
if (checkOnly) {
return
}
await git(['update-ref', fullRef, remoteOid, localOid], repoPath)
}
async function revParseCommit(
git: GitExec,
repoPath: string,
ref: string,
missingMessage: string
): Promise<string> {
const { stdout } = await git(['rev-parse', '--verify', `${ref}^{commit}`], repoPath)
const oid = stdout.trim()
if (!oid) {
throw new Error(missingMessage)
}
return oid
}
+10 -3
View File
@@ -216,7 +216,10 @@ async function listRelayWorktrees(git: GitExec, repoPath: string): Promise<Relay
}
}
async function readRelayWorktreeList(git: GitExec, repoPath: string): Promise<RelayWorktreeInfo[]> {
export async function readRelayWorktreeList(
git: GitExec,
repoPath: string
): Promise<RelayWorktreeInfo[]> {
try {
const { stdout } = await git(['worktree', 'list', '--porcelain', '-z'], repoPath)
return normalizeRelayWorktrees(parseWorktreeList(stdout, { nulDelimited: true }))
@@ -245,7 +248,7 @@ function normalizeLocalBranchRef(branch: string): string {
return branch.replace(/^refs\/heads\//, '')
}
function areRelayWorktreePathsEqual(leftPath: string, rightPath: string): boolean {
export function areRelayWorktreePathsEqual(leftPath: string, rightPath: string): boolean {
const left = path.normalize(path.resolve(leftPath))
const right = path.normalize(path.resolve(rightPath))
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right
@@ -256,7 +259,11 @@ export async function worktreeIsCleanOp(
params: Record<string, unknown>
): Promise<{ clean: boolean; stdout?: string }> {
const worktreePath = params.worktreePath as string
const { stdout } = await git(['status', '--porcelain', '--untracked-files=all'], worktreePath)
const includeUntracked = params.includeUntracked !== false
const { stdout } = await git(
['status', '--porcelain', includeUntracked ? '--untracked-files=all' : '--untracked-files=no'],
worktreePath
)
const clean = !stdout.trim()
return { clean, stdout: clean ? undefined : stdout }
}
+376
View File
@@ -34,6 +34,30 @@ describe('GitHandler', () => {
await fs.rm(tmpDir, { recursive: true, force: true })
})
function currentBranch(cwd: string): string {
return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd,
encoding: 'utf-8'
}).trim()
}
function currentBranchFullRef(cwd: string): string {
return `refs/heads/${currentBranch(cwd)}`
}
function reportedWorktreePath(cwd: string): string {
return (
execFileSync('git', ['worktree', 'list', '--porcelain'], {
cwd,
encoding: 'utf-8'
})
.split(/\r?\n/)
.find((line) => line.startsWith('worktree '))
?.slice('worktree '.length)
.trim() ?? cwd
)
}
it('registers all expected handlers', () => {
const methods = Array.from(dispatcher._requestHandlers.keys())
expect(methods).toContain('git.status')
@@ -63,6 +87,7 @@ describe('GitHandler', () => {
expect(methods).toContain('git.addWorktree')
expect(methods).toContain('git.removeWorktree')
expect(methods).toContain('git.worktreeIsClean')
expect(methods).toContain('git.refreshLocalBaseRefForWorktreeCreate')
expect(methods).toContain('git.renameCurrentBranch')
expect(methods).toContain('git.exec')
expect(methods).toContain('git.isGitRepo')
@@ -1102,6 +1127,357 @@ describe('GitHandler', () => {
)
})
describe('worktreeIsClean', () => {
it('can ignore untracked files', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'tracked.txt'), 'initial')
gitCommit(tmpDir, 'initial')
writeFileSync(path.join(tmpDir, 'scratch.txt'), 'untracked')
await expect(
dispatcher.callRequest('git.worktreeIsClean', { worktreePath: tmpDir })
).resolves.toEqual({
clean: false,
stdout: expect.stringContaining('scratch.txt')
})
await expect(
dispatcher.callRequest('git.worktreeIsClean', {
worktreePath: tmpDir,
includeUntracked: false
})
).resolves.toEqual({ clean: true })
})
})
describe('refreshLocalBaseRefForWorktreeCreate', () => {
function setupMockedRefreshHandler() {
const localDispatcher = createMockDispatcher()
const localHandler = new GitHandler(
localDispatcher as unknown as RelayDispatcher,
new RelayContext()
)
const gitMock =
vi.fn<
(
args: string[],
cwd: string,
opts?: { maxBuffer?: number }
) => Promise<{ stdout: string; stderr: string }>
>()
;(localHandler as unknown as { git: typeof gitMock }).git = gitMock
return { localDispatcher, gitMock }
}
it('resets the owning worktree to the remote-tracking ref', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
gitCommit(tmpDir, 'initial')
const branchRef = currentBranchFullRef(tmpDir)
const ownerPath = reportedWorktreePath(tmpDir)
const firstSha = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
writeFileSync(path.join(tmpDir, 'base.txt'), 'remote')
gitCommit(tmpDir, 'remote update')
const remoteSha = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['update-ref', 'refs/remotes/origin/main', remoteSha], {
cwd: tmpDir,
stdio: 'pipe'
})
execFileSync('git', ['reset', '--hard', firstSha], { cwd: tmpDir, stdio: 'pipe' })
await dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
repoPath: tmpDir,
fullRef: branchRef,
remoteTrackingRef: 'refs/remotes/origin/main',
ownerWorktreePath: ownerPath
})
const actual = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
expect(actual).toBe(remoteSha)
await expect(fs.readFile(path.join(tmpDir, 'base.txt'), 'utf-8')).resolves.toBe('remote')
})
it('fast-forwards a non-checked-out local branch via update-ref', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
gitCommit(tmpDir, 'initial')
execFileSync('git', ['branch', 'main-copy'], { cwd: tmpDir, stdio: 'pipe' })
writeFileSync(path.join(tmpDir, 'base.txt'), 'remote')
gitCommit(tmpDir, 'remote update')
const remoteSha = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['update-ref', 'refs/remotes/origin/main', remoteSha], {
cwd: tmpDir,
stdio: 'pipe'
})
await dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
repoPath: tmpDir,
fullRef: 'refs/heads/main-copy',
remoteTrackingRef: 'refs/remotes/origin/main'
})
// No working tree owns main-copy, so the bare ref fast-forwards.
const actual = execFileSync('git', ['rev-parse', 'refs/heads/main-copy'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
expect(actual).toBe(remoteSha)
})
it('does not move a non-checked-out local branch when checkOnly is set', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
gitCommit(tmpDir, 'initial')
execFileSync('git', ['branch', 'main-copy'], { cwd: tmpDir, stdio: 'pipe' })
const originalSha = execFileSync('git', ['rev-parse', 'refs/heads/main-copy'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
writeFileSync(path.join(tmpDir, 'base.txt'), 'remote')
gitCommit(tmpDir, 'remote update')
const remoteSha = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['update-ref', 'refs/remotes/origin/main', remoteSha], {
cwd: tmpDir,
stdio: 'pipe'
})
await dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
repoPath: tmpDir,
fullRef: 'refs/heads/main-copy',
remoteTrackingRef: 'refs/remotes/origin/main',
checkOnly: true
})
const actual = execFileSync('git', ['rev-parse', 'refs/heads/main-copy'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
expect(actual).toBe(originalSha)
})
it('rejects invalid local base ref refresh refs', async () => {
gitInit(tmpDir)
await expect(
dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
repoPath: tmpDir,
fullRef: 'refs/tags/main',
remoteTrackingRef: 'refs/remotes/origin/main'
})
).rejects.toThrow('Invalid local base ref refresh refs.')
})
it('rejects a dirty owner worktree before resetting', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
gitCommit(tmpDir, 'initial')
const branchRef = currentBranchFullRef(tmpDir)
const ownerPath = reportedWorktreePath(tmpDir)
const firstSha = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
writeFileSync(path.join(tmpDir, 'base.txt'), 'remote')
gitCommit(tmpDir, 'remote update')
const remoteSha = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['update-ref', 'refs/remotes/origin/main', remoteSha], {
cwd: tmpDir,
stdio: 'pipe'
})
execFileSync('git', ['reset', '--hard', firstSha], { cwd: tmpDir, stdio: 'pipe' })
writeFileSync(path.join(tmpDir, 'base.txt'), 'local dirty')
await expect(
dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
repoPath: tmpDir,
fullRef: branchRef,
remoteTrackingRef: 'refs/remotes/origin/main',
ownerWorktreePath: ownerPath
})
).rejects.toThrow('Local base ref worktree has tracked changes.')
const actual = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
expect(actual).toBe(firstSha)
await expect(fs.readFile(path.join(tmpDir, 'base.txt'), 'utf-8')).resolves.toBe('local dirty')
})
it('rejects when the caller-supplied owner path is not the checked-out branch owner', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
gitCommit(tmpDir, 'initial')
const branchRef = currentBranchFullRef(tmpDir)
const headSha = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['update-ref', 'refs/remotes/origin/main', headSha], {
cwd: tmpDir,
stdio: 'pipe'
})
await expect(
dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
repoPath: tmpDir,
fullRef: branchRef,
remoteTrackingRef: 'refs/remotes/origin/main',
ownerWorktreePath: path.join(path.dirname(tmpDir), 'different-owner')
})
).rejects.toThrow('Local base ref is checked out in a different worktree.')
})
it('rejects diverged local refs before mutating', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'base.txt'), 'base')
gitCommit(tmpDir, 'initial')
execFileSync('git', ['branch', 'main-copy'], { cwd: tmpDir, stdio: 'pipe' })
writeFileSync(path.join(tmpDir, 'remote.txt'), 'remote')
gitCommit(tmpDir, 'remote update')
const remoteSha = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
execFileSync('git', ['update-ref', 'refs/remotes/origin/main', remoteSha], {
cwd: tmpDir,
stdio: 'pipe'
})
execFileSync('git', ['checkout', 'main-copy'], { cwd: tmpDir, stdio: 'pipe' })
writeFileSync(path.join(tmpDir, 'local.txt'), 'local')
gitCommit(tmpDir, 'local update')
const localSha = execFileSync('git', ['rev-parse', 'refs/heads/main-copy'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
await expect(
dispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
repoPath: tmpDir,
fullRef: 'refs/heads/main-copy',
remoteTrackingRef: 'refs/remotes/origin/main',
ownerWorktreePath: tmpDir
})
).rejects.toThrow('Local base ref is not a fast-forward update.')
const actual = execFileSync('git', ['rev-parse', 'refs/heads/main-copy'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
expect(actual).toBe(localSha)
})
it('resets owner worktree to captured remote OID without update-ref', async () => {
const { localDispatcher, gitMock } = setupMockedRefreshHandler()
gitMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'check-ref-format') {
return { stdout: '', stderr: '' }
}
if (args[0] === 'rev-parse' && args[2] === 'refs/remotes/origin/main^{commit}') {
return { stdout: 'remote-oid\n', stderr: '' }
}
if (args[0] === 'rev-parse') {
return { stdout: 'old-local-oid\n', stderr: '' }
}
if (args[0] === 'merge-base') {
return { stdout: '', stderr: '' }
}
if (args[0] === 'worktree') {
return {
stdout: 'worktree /repo\nHEAD old-local-oid\nbranch refs/heads/main\n',
stderr: ''
}
}
if (args[0] === 'status') {
return { stdout: '', stderr: '' }
}
if (args[0] === 'reset') {
return { stdout: '', stderr: '' }
}
throw new Error(`unexpected git call: ${args.join(' ')}`)
})
await expect(
localDispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
repoPath: '/repo',
fullRef: 'refs/heads/main',
remoteTrackingRef: 'refs/remotes/origin/main'
})
).resolves.toBeUndefined()
expect(gitMock).toHaveBeenCalledWith(
['merge-base', '--is-ancestor', 'old-local-oid', 'remote-oid'],
'/repo'
)
expect(gitMock).toHaveBeenCalledWith(['reset', '--hard', 'remote-oid'], '/repo')
expect(gitMock.mock.calls.map((call) => call[0])).not.toContainEqual([
'update-ref',
'refs/heads/main',
'remote-oid',
'old-local-oid'
])
})
it('fails closed when worktree ownership cannot be listed', async () => {
const { localDispatcher, gitMock } = setupMockedRefreshHandler()
gitMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'check-ref-format') {
return { stdout: '', stderr: '' }
}
if (args[0] === 'rev-parse' && args[2] === 'refs/remotes/origin/main^{commit}') {
return { stdout: 'remote-oid\n', stderr: '' }
}
if (args[0] === 'rev-parse') {
return { stdout: 'old-local-oid\n', stderr: '' }
}
if (args[0] === 'merge-base') {
return { stdout: '', stderr: '' }
}
if (args[0] === 'worktree') {
throw new Error('worktree list failed')
}
throw new Error(`unexpected git call: ${args.join(' ')}`)
})
await expect(
localDispatcher.callRequest('git.refreshLocalBaseRefForWorktreeCreate', {
repoPath: '/repo',
fullRef: 'refs/heads/main',
remoteTrackingRef: 'refs/remotes/origin/main'
})
).rejects.toThrow('worktree list failed')
expect(gitMock.mock.calls.map((call) => call[0])).not.toContainEqual([
'update-ref',
'refs/heads/main',
'refs/remotes/origin/main',
'old-local-oid'
])
expect(gitMock.mock.calls.map((call) => call[0])).not.toContainEqual([
'reset',
'--hard',
'refs/heads/main'
])
})
})
describe('addWorktree', () => {
// Why: relay handler tests for addWorktree use a mock-injection approach
// to deterministically control git exit codes (in particular `--get` exit
+8
View File
@@ -25,6 +25,7 @@ import {
removeWorktreeOp,
worktreeIsCleanOp
} from './git-handler-worktree-ops'
import { refreshLocalBaseRefForWorktreeCreateOp } from './git-handler-local-base-ref-refresh'
import { checkIgnoredPathsOp, detectConflictOperation, getStatusOp } from './git-handler-status-ops'
import { resolveRelayPushTarget } from './git-handler-push-target'
import { normalizeGitErrorMessage, isNoUpstreamError } from '../shared/git-remote-error'
@@ -88,6 +89,9 @@ export class GitHandler {
this.dispatcher.onRequest('git.addWorktree', (p) => this.addWorktree(p))
this.dispatcher.onRequest('git.removeWorktree', (p) => this.removeWorktree(p))
this.dispatcher.onRequest('git.worktreeIsClean', (p) => this.worktreeIsClean(p))
this.dispatcher.onRequest('git.refreshLocalBaseRefForWorktreeCreate', (p) =>
this.refreshLocalBaseRefForWorktreeCreate(p)
)
this.dispatcher.onRequest('git.renameCurrentBranch', (p) => this.renameCurrentBranch(p))
this.dispatcher.onRequest('git.exec', (p) => this.exec(p))
this.dispatcher.onRequest('git.isGitRepo', (p) => this.isGitRepo(p))
@@ -651,4 +655,8 @@ export class GitHandler {
private async worktreeIsClean(params: Record<string, unknown>) {
return worktreeIsCleanOp(this.git.bind(this), params)
}
private async refreshLocalBaseRefForWorktreeCreate(params: Record<string, unknown>) {
return refreshLocalBaseRefForWorktreeCreateOp(this.git.bind(this), params)
}
}
+26 -2
View File
@@ -214,6 +214,9 @@ function WindowControls(): React.JSX.Element {
}
const Landing = lazy(() => import('./components/Landing'))
const WorktreeCreationPanel = lazy(
() => import('./components/worktree-creation/WorktreeCreationPanel')
)
const TaskPage = lazy(() => import('./components/TaskPage'))
const AutomationsPage = lazy(() => import('./components/automations/AutomationsPage'))
const ActivityPrototypePage = lazy(() => import('./components/activity/ActivityPrototypePage'))
@@ -325,6 +328,16 @@ function App(): React.JSX.Element {
const featureInteractions = useAppStore((s) => s.featureInteractions)
const contextualToursAutoEligible = useAppStore((s) => s.contextualToursAutoEligible)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const activePendingCreationId = useAppStore((s) => s.activePendingCreationId)
// Why: the creation loader is debounced — a fast create resolves before its
// entry's loaderVisible flips, so the content area keeps showing the prior
// workspace (or Landing) and never flashes a loader. Only a create still
// pending past the debounce gates the loader and hides the terminal.
const activeCreationLoaderVisible = useAppStore(
(s) =>
s.activePendingCreationId != null &&
s.pendingWorktreeCreations[s.activePendingCreationId]?.loaderVisible === true
)
// Why: App swaps the sidebar between workspace and landing layouts when the
// active workspace is slept/deleted. Keep virtualized scroll memory above
// that remount so the left workspace list doesn't restart at scrollTop 0.
@@ -1797,7 +1810,9 @@ function App(): React.JSX.Element {
<div className="flex flex-1 min-w-0 min-h-0 flex-col">
<div
className={
activeView !== 'terminal' || !activeWorktreeId
activeView !== 'terminal' ||
!activeWorktreeId ||
activeCreationLoaderVisible
? 'hidden flex-1 min-w-0 min-h-0'
: 'flex flex-1 min-w-0 min-h-0'
}
@@ -1827,7 +1842,16 @@ function App(): React.JSX.Element {
{activeView === 'activity' ? <ActivityPrototypePage /> : null}
{activeView === 'space' ? <WorkspaceSpacePage /> : null}
{activeView === 'mobile' ? <MobilePage /> : null}
{activeView === 'terminal' && !activeWorktreeId ? <Landing /> : null}
{activeView === 'terminal' &&
activeCreationLoaderVisible &&
activePendingCreationId ? (
<WorktreeCreationPanel creationId={activePendingCreationId} />
) : null}
{activeView === 'terminal' &&
!activeWorktreeId &&
!activeCreationLoaderVisible ? (
<Landing />
) : null}
</RecoverableRenderErrorBoundary>
</Suspense>
</div>
@@ -722,9 +722,6 @@ export default function BrowserPane({
const setBrowserPageUrl = useAppStore((s) => s.setBrowserPageUrl)
const runtimeEnvironmentActive = Boolean(activeRuntimeEnvironmentId?.trim())
const activeBrowserPageId = activeBrowserPage?.id ?? null
const activeBrowserPageIsBlank =
!runtimeEnvironmentActive &&
(activeBrowserPage?.url === 'about:blank' || activeBrowserPage?.url === ORCA_BROWSER_BLANK_URL)
const browserPageIds = useMemo(() => browserPages.map((page) => page.id), [browserPages])
const automationVisiblePageIds = useBrowserAutomationVisiblePageIds(browserPageIds)
// Why: inactive Electron webviews must stay mounted in their original DOM
@@ -759,10 +756,7 @@ export default function BrowserPane({
useContextualTour(
'browser',
isActive &&
activeBrowserPage !== null &&
!runtimeEnvironmentActive &&
!activeBrowserPageIsBlank,
isActive && activeBrowserPage !== null && !runtimeEnvironmentActive,
'browser_visible'
)
@@ -4619,6 +4613,7 @@ function BrowserPagePane({
browserPageId={browserTab.id}
viewportPresetId={browserTab.viewportPresetId ?? null}
onDestroyWebview={() => destroyPersistentWebview(browserTab.id)}
isActive={isActive}
/>
</div>
{downloadState ? (
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useLayoutEffect, useState } from 'react'
import { Check, Ellipsis, Import, Monitor, Plus, Settings } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
@@ -40,6 +40,7 @@ type BrowserToolbarMenuProps = {
browserPageId: string
viewportPresetId: BrowserViewportPresetId | null
onDestroyWebview: () => void
isActive: boolean
}
export function BrowserToolbarMenu({
@@ -47,7 +48,8 @@ export function BrowserToolbarMenu({
workspaceId,
browserPageId,
viewportPresetId,
onDestroyWebview
onDestroyWebview,
isActive
}: BrowserToolbarMenuProps): React.JSX.Element {
const browserSessionProfiles = useAppStore((s) => s.browserSessionProfiles)
const detectedBrowsers = useAppStore((s) => s.detectedBrowsers)
@@ -58,6 +60,10 @@ export function BrowserToolbarMenu({
const fetchDetectedBrowsers = useAppStore((s) => s.fetchDetectedBrowsers)
const browserSessionImportState = useAppStore((s) => s.browserSessionImportState)
const setBrowserPageViewportPreset = useAppStore((s) => s.setBrowserPageViewportPreset)
const browserCookieTourStepActive = useAppStore(
(s) => s.activeContextualTourId === 'browser' && s.activeContextualTourStepIndex === 2
)
const shouldForceMenuOpen = browserCookieTourStepActive && isActive
const applyViewportPreset = (nextId: BrowserViewportPresetId | null): void => {
setBrowserPageViewportPreset(browserPageId, nextId)
@@ -72,8 +78,22 @@ export function BrowserToolbarMenu({
const [pendingSwitchProfileId, setPendingSwitchProfileId] = useState<string | null | undefined>(
undefined
)
const [menuOpen, setMenuOpen] = useState(false)
const mountedRef = useMountedRef()
useLayoutEffect(() => {
// Why: step 3 anchors on Import Cookies inside this menu, so open it only
// once the tour reaches the final browser step.
setMenuOpen(shouldForceMenuOpen)
}, [shouldForceMenuOpen])
const handleMenuOpenChange = (open: boolean): void => {
if (shouldForceMenuOpen && !open) {
return
}
setMenuOpen(open)
}
const effectiveProfileId = currentProfileId ?? 'default'
const defaultProfile = browserSessionProfiles.find((p) => p.id === 'default')
@@ -165,7 +185,7 @@ export function BrowserToolbarMenu({
return (
<>
<DropdownMenu>
<DropdownMenu modal={false} open={menuOpen} onOpenChange={handleMenuOpenChange}>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost" className="h-8 w-8" title="Browser menu">
<Ellipsis className="size-4" />
@@ -173,14 +193,14 @@ export function BrowserToolbarMenu({
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{allProfiles.map((profile) => {
const isActive = profile.id === effectiveProfileId
const isSelectedProfile = profile.id === effectiveProfileId
return (
<DropdownMenuItem
key={profile.id}
onSelect={() => handleSwitchProfile(profile.id === 'default' ? null : profile.id)}
>
<Check
className={`mr-2 size-3.5 shrink-0 ${isActive ? 'opacity-100' : 'opacity-0'}`}
className={`mr-2 size-3.5 shrink-0 ${isSelectedProfile ? 'opacity-100' : 'opacity-0'}`}
/>
<span className="truncate">{profile.label}</span>
{profile.source?.browserFamily && (
@@ -211,7 +231,13 @@ export function BrowserToolbarMenu({
}
}}
>
<DropdownMenuSubTrigger disabled={browserSessionImportState?.status === 'importing'}>
<DropdownMenuSubTrigger
disabled={
browserSessionImportState?.profileId === effectiveProfileId &&
browserSessionImportState.status === 'importing'
}
data-contextual-tour-target="browser-import-cookies-control"
>
<Import className="mr-2 size-3.5" />
Import Cookies
</DropdownMenuSubTrigger>
@@ -10,16 +10,11 @@ import {
trackContextualTourOutcome,
trackContextualTourShown
} from '@/lib/feature-education-telemetry'
import { formatShortcutLabel } from '@/hooks/useShortcutLabel'
import { isContextualTourAllowedForModal } from './contextual-tour-gate'
import {
getContextualTourStepCopy,
getContextualTourStepProgress,
getContextualTourOutcomeStepTotal,
getMeasurableContextualTourTarget,
getContextualTourPanelHost,
getVisibleContextualTourStepIndexes,
isContextualTourAllowedForModal
} from './contextual-tour-gate'
getContextualTourCleanupOutcome,
measureContextualTourOverlayRenderState
} from './contextual-tour-overlay-measurement'
import { getContextualTourOverlayPanelPosition } from './contextual-tour-overlay-position'
import {
ContextualTourOverlaySurface,
@@ -167,62 +162,33 @@ export function ContextualTourOverlay(): JSX.Element | null {
return
}
const targetExists = (selector: string): boolean =>
getMeasurableContextualTourTarget(selector) !== null
const visibleStepIndexes = getVisibleContextualTourStepIndexes(activeTour, targetExists)
telemetryDefinedStepCountRef.current = activeTour.steps.length
const measurement = measureContextualTourOverlayRenderState({
tour: activeTour,
activeStepIndex,
sidebarOpen,
keybindings,
previousTelemetryTotalSteps: telemetryTotalStepsRef.current
})
telemetryTotalStepsRef.current = Math.max(
telemetryTotalStepsRef.current,
getContextualTourOutcomeStepTotal(visibleStepIndexes)
measurement.kind === 'render' ? measurement.telemetryTotalSteps : 0
)
const activeStep = activeTour.steps[activeStepIndex]
const target = activeStep ? getMeasurableContextualTourTarget(activeStep.targetSelector) : null
const progress = getContextualTourStepProgress({
visibleStepIndexes,
stepIndex: activeStepIndex
})
if (visibleStepIndexes.length === 0) {
if (measurement.kind === 'advance') {
advanceContextualTour()
return
}
if (measurement.kind === 'wait') {
return
}
if (measurement.kind === 'cancel') {
emitContextualTourOutcome('cancelled')
cancelContextualTour(activeTourId)
return
}
if (!activeStep || !target || !progress) {
const hasLaterStep = visibleStepIndexes.some((index) => index > activeStepIndex)
if (hasLaterStep) {
advanceContextualTour()
} else {
emitContextualTourOutcome('cancelled')
cancelContextualTour(activeTourId)
}
return
}
// Why: the "Show worktrees" CTA only opens the sidebar; when it's already
// open the button is a no-op, so fall back to a plain Next and drop the Skip.
const sidebarAlreadyVisible = activeStep.primaryAction?.kind === 'show-worktrees' && sidebarOpen
const primaryAction = sidebarAlreadyVisible
? ({ kind: 'next', label: 'Next' } as const)
: activeStep.primaryAction
const secondaryAction = sidebarAlreadyVisible ? undefined : activeStep.secondaryAction
setRenderState({
rect: target.rect,
targetElement: target.element,
progress,
title: activeStep.title,
body: formatContextualTourStepCopy(getContextualTourStepCopy(activeStep), keybindings),
control: activeStep.control,
primaryAction,
secondaryAction,
preferredPlacement: activeStep.preferredPlacement,
targetPulse: activeStep.targetPulse,
hidePrimaryAction: activeStep.hidePrimaryAction,
isLastStep: progress.current === progress.total,
isFirstStep: progress.current === 1,
panelHost: getContextualTourPanelHost(target.element)
})
setRenderState(measurement.renderState)
}, [
activeStepIndex,
activeTour,
@@ -408,20 +374,4 @@ export function ContextualTourOverlay(): JSX.Element | null {
)
}
export function getContextualTourCleanupOutcome(
activeTourId: ContextualTourId
): ContextualTourOutcome {
return useAppStore.getState().lastCompletedContextualTourId === activeTourId
? 'completed'
: 'cancelled'
}
function formatContextualTourStepCopy(
copy: string,
keybindings: Parameters<typeof formatShortcutLabel>[1]
): string {
return copy.replace(
'{terminal.splitRight}',
formatShortcutLabel('terminal.splitRight', keybindings)
)
}
export { getContextualTourCleanupOutcome } from './contextual-tour-overlay-measurement'
@@ -267,6 +267,26 @@ describe('contextual tour gate', () => {
})
})
it('hides the browser cookie step until the Import Cookies menu row is measurable', () => {
const tour = getContextualTour('browser')
const cookieTarget = tour.steps[2]!.targetSelector
const targetExists = (selector: string): boolean => selector !== cookieTarget
const visibleStepIndexes = getVisibleContextualTourStepIndexes(tour, targetExists)
expect(visibleStepIndexes).toEqual([0, 1])
expect(
getNextVisibleContextualTourStepIndex({
tour,
currentStepIndex: 1,
targetExists
})
).toBeNull()
expect(getContextualTourStepProgress({ visibleStepIndexes, stepIndex: 1 })).toEqual({
current: 2,
total: 2
})
})
it('cancels the workspace-agent-sessions tour when the new-worktree button is absent', () => {
const tour = getContextualTour('workspace-agent-sessions')
// Only the split step's target is present; the create-worktree button is not.
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { getContextualTour } from '../../../../shared/contextual-tours'
import {
getContextualTourDisplayProgress,
getContextualTourMeasurementAction,
isContextualTourLastDisplayStep
} from './contextual-tour-overlay-measurement'
describe('contextual tour overlay measurement', () => {
it('shows all defined browser steps in progress even when step 3 is hidden', () => {
const tour = getContextualTour('browser')
expect(
getContextualTourDisplayProgress({
tour,
visibleStepIndexes: [0, 1],
stepIndex: 1,
activeStep: tour.steps[1]
})
).toEqual({ current: 2, total: 3 })
})
it('waits for the browser cookie step target instead of cancelling', () => {
const tour = getContextualTour('browser')
expect(
getContextualTourMeasurementAction({
tour,
visibleStepIndexes: [0, 1],
activeStepIndex: 2
})
).toEqual({ kind: 'wait' })
})
it('treats browser step 3 as the last display step', () => {
const tour = getContextualTour('browser')
expect(
isContextualTourLastDisplayStep({
tour,
activeStepIndex: 2,
progress: { current: 3, total: 3 }
})
).toBe(true)
})
})
@@ -0,0 +1,166 @@
import { formatShortcutLabel } from '@/hooks/useShortcutLabel'
import type { ContextualTour, ContextualTourId } from '../../../../shared/contextual-tours'
import type { ContextualTourOutcome } from '../../../../shared/feature-education-telemetry'
import { useAppStore } from '@/store'
import {
getContextualTourOutcomeStepTotal,
getContextualTourPanelHost,
getContextualTourStepCopy,
getContextualTourStepProgress,
getMeasurableContextualTourTarget,
getVisibleContextualTourStepIndexes
} from './contextual-tour-gate'
import type { ActiveTourRenderState } from './ContextualTourOverlaySurface'
export type ContextualTourMeasurementAction =
| { kind: 'wait' }
| { kind: 'advance' }
| { kind: 'cancel' }
export type ContextualTourOverlayMeasurementResult =
| { kind: 'wait' }
| { kind: 'advance' }
| { kind: 'cancel' }
| {
kind: 'render'
renderState: ActiveTourRenderState
telemetryTotalSteps: number
}
export function getContextualTourDisplayProgress(args: {
tour: ContextualTour
visibleStepIndexes: readonly number[]
stepIndex: number
activeStep: ContextualTour['steps'][number] | undefined
}): { current: number; total: number } | null {
if (!args.activeStep) {
return null
}
if (args.tour.id === 'browser') {
return { current: args.stepIndex + 1, total: args.tour.steps.length }
}
return getContextualTourStepProgress({
visibleStepIndexes: args.visibleStepIndexes,
stepIndex: args.stepIndex
})
}
export function getContextualTourMeasurementAction(args: {
tour: ContextualTour
visibleStepIndexes: readonly number[]
activeStepIndex: number
}): ContextualTourMeasurementAction {
if (args.visibleStepIndexes.some((index) => index > args.activeStepIndex)) {
return { kind: 'advance' }
}
// Why: browser step 3's Import Cookies row appears only after that step is
// active and the toolbar menu opens; keep remeasuring instead of cancelling.
if (args.activeStepIndex < args.tour.steps.length - 1 || args.tour.id === 'browser') {
return { kind: 'wait' }
}
return { kind: 'cancel' }
}
export function isContextualTourLastDisplayStep(args: {
tour: ContextualTour
activeStepIndex: number
progress: { current: number; total: number }
}): boolean {
if (args.tour.id === 'browser') {
return args.activeStepIndex === args.tour.steps.length - 1
}
return args.progress.current === args.progress.total
}
export function measureContextualTourOverlayRenderState(args: {
tour: ContextualTour
activeStepIndex: number
sidebarOpen: boolean
keybindings: Parameters<typeof formatShortcutLabel>[1]
previousTelemetryTotalSteps: number
}): ContextualTourOverlayMeasurementResult {
const targetExists = (selector: string): boolean =>
getMeasurableContextualTourTarget(selector) !== null
const visibleStepIndexes = getVisibleContextualTourStepIndexes(args.tour, targetExists)
const telemetryTotalSteps = Math.max(
args.previousTelemetryTotalSteps,
getContextualTourOutcomeStepTotal(visibleStepIndexes)
)
const activeStep = args.tour.steps[args.activeStepIndex]
const target = activeStep ? getMeasurableContextualTourTarget(activeStep.targetSelector) : null
const progress = getContextualTourDisplayProgress({
tour: args.tour,
visibleStepIndexes,
stepIndex: args.activeStepIndex,
activeStep
})
if (visibleStepIndexes.length === 0 || !activeStep || !progress) {
return { kind: 'cancel' }
}
if (!target) {
const measurementAction = getContextualTourMeasurementAction({
tour: args.tour,
visibleStepIndexes,
activeStepIndex: args.activeStepIndex
})
if (measurementAction.kind === 'advance') {
return { kind: 'advance' }
}
if (measurementAction.kind === 'wait') {
return { kind: 'wait' }
}
return { kind: 'cancel' }
}
const sidebarAlreadyVisible =
activeStep.primaryAction?.kind === 'show-worktrees' && args.sidebarOpen
const primaryAction = sidebarAlreadyVisible
? ({ kind: 'next', label: 'Next' } as const)
: activeStep.primaryAction
const secondaryAction = sidebarAlreadyVisible ? undefined : activeStep.secondaryAction
return {
kind: 'render',
telemetryTotalSteps,
renderState: {
rect: target.rect,
targetElement: target.element,
progress,
title: activeStep.title,
body: formatContextualTourStepCopy(getContextualTourStepCopy(activeStep), args.keybindings),
control: activeStep.control,
primaryAction,
secondaryAction,
preferredPlacement: activeStep.preferredPlacement,
targetPulse: activeStep.targetPulse,
hidePrimaryAction: activeStep.hidePrimaryAction,
isLastStep: isContextualTourLastDisplayStep({
tour: args.tour,
activeStepIndex: args.activeStepIndex,
progress
}),
isFirstStep: progress.current === 1,
panelHost: getContextualTourPanelHost(target.element)
}
}
}
export function getContextualTourCleanupOutcome(
activeTourId: ContextualTourId
): ContextualTourOutcome {
return useAppStore.getState().lastCompletedContextualTourId === activeTourId
? 'completed'
: 'cancelled'
}
function formatContextualTourStepCopy(
copy: string,
keybindings: Parameters<typeof formatShortcutLabel>[1]
): string {
return copy.replace(
'{terminal.splitRight}',
formatShortcutLabel('terminal.splitRight', keybindings)
)
}
@@ -284,13 +284,14 @@ export function ConflictReviewPanel({
</TooltipContent>
</Tooltip>
)}
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-1.5">
<span className="text-sm font-medium text-foreground">
{unresolvedCount} unresolved conflict{unresolvedCount === 1 ? '' : 's'}
</div>
<div className="mt-1 text-xs text-muted-foreground">
</span>
<span className="text-muted-foreground/50">·</span>
<span className="text-xs text-muted-foreground">
Snapshot captured at {snapshotTime}.
</div>
</span>
</div>
</div>
<Button type="button" size="sm" variant="outline" onClick={onRefreshSnapshot}>
@@ -0,0 +1,3 @@
export function emulatorPaneErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message ? error.message : fallback
}
@@ -61,6 +61,15 @@ function runtimeSuccess<T>(result: T) {
}
}
function runtimeFailure(code: string, message: string) {
return {
id: 'test',
ok: false,
error: { code, message },
_meta: { runtimeId: 'test-runtime' }
}
}
function Probe(): React.JSX.Element {
const state = useEmulatorPaneSession({
worktreeId: WORKTREE_ID,
@@ -74,6 +83,14 @@ function Probe(): React.JSX.Element {
)
}
function AutoAttachProbe(): React.JSX.Element | null {
latest = useEmulatorPaneSession({
worktreeId: WORKTREE_ID,
autoAttachOnMount: true
})
return null
}
async function flushEffects(): Promise<void> {
await act(async () => {
await Promise.resolve()
@@ -169,4 +186,33 @@ describe('useEmulatorPaneSession', () => {
expect(latest?.isLive).toBe(true)
expect(latest?.previewUrl).toBe('http://127.0.0.1:3200/stream.mjpeg')
})
it('keeps simulator discovery setup errors during auto attach', async () => {
const message =
'Xcode Simulator tools are unavailable. Install full Xcode, open it once, then select it with `sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer`.'
consumePrelaunchedSimulatorSession(WORKTREE_ID)
const runtimeCall = vi.fn(async ({ method }: RuntimeCallRequest) => {
if (method === 'emulator.listSimulators') {
return runtimeFailure('emulator_simctl_unavailable', message)
}
if (method === 'emulator.attach') {
throw new Error('attach should not run without a discovered target')
}
throw new Error(`Unexpected RPC method: ${method}`)
})
Object.defineProperty(window, 'api', {
configurable: true,
value: { runtime: { call: runtimeCall } }
})
await act(async () => {
root.render(<AutoAttachProbe />)
})
await vi.waitFor(() => expect(latest?.error).toBe(message))
expect(latest?.loading).toBe(false)
expect(runtimeCall).not.toHaveBeenCalledWith(
expect.objectContaining({ method: 'emulator.attach' })
)
})
})
@@ -9,10 +9,7 @@ import {
} from './emulator-pane-types'
import { markSimulatorDeviceBooted, markSimulatorDeviceShutdown } from './emulator-device-state'
import { useEmulatorPaneControls } from './use-emulator-pane-controls'
import {
EMULATOR_LOCAL_SHUTDOWN_EVENT,
useEmulatorPaneSessionEvents
} from './use-emulator-pane-session-events'
import { useEmulatorPaneSessionEvents } from './use-emulator-pane-session-events'
import {
consumePrelaunchedSimulatorSession,
isManualSimulatorLaunchPending
@@ -22,6 +19,8 @@ import { useEmulatorPaneManualLaunchEvents } from './use-emulator-pane-manual-la
import { buildEmulatorPaneSessionView } from './emulator-pane-session-view'
import { resolveEmulatorAttachTarget } from './emulator-attach-target'
import { useEmulatorPaneLifecycle } from './use-emulator-pane-lifecycle'
import { useEmulatorPaneShutdown } from './use-emulator-pane-shutdown'
import { emulatorPaneErrorMessage } from './emulator-pane-error-message'
type UseEmulatorPaneSessionArgs = {
worktreeId: string
@@ -54,6 +53,7 @@ export function useEmulatorPaneSession({
const [streamKey, setStreamKey] = useState<string | null>(prelaunchedState.streamKey)
const mountedRef = useRef(true)
const liveTargetRef = useRef<string | null>(prelaunchedState.liveTarget)
const deviceRefreshErrorRef = useRef<unknown>(null)
const suppressAutoAttachRef = useRef(false)
const { sendTap, sendButton, sendGesture, sendRotate } = useEmulatorPaneControls(worktreeId)
@@ -68,9 +68,19 @@ export function useEmulatorPaneSession({
if (!mountedRef.current) {
return next
}
const hadRefreshError = deviceRefreshErrorRef.current !== null
deviceRefreshErrorRef.current = null
setDevices(next)
if (hadRefreshError) {
setError(null)
}
return next
} catch {
} catch (error) {
deviceRefreshErrorRef.current = error
if (mountedRef.current) {
setDevices([])
setError(emulatorPaneErrorMessage(error, 'Could not list emulator devices.'))
}
return []
}
}, [])
@@ -146,6 +156,9 @@ export function useEmulatorPaneSession({
if (list.length === 0) {
list = (await refreshDevices()) ?? []
}
if (list.length === 0 && deviceRefreshErrorRef.current) {
throw deviceRefreshErrorRef.current
}
const target = resolveEmulatorAttachTarget({
configuredDefaultUdid,
devices: list,
@@ -188,10 +201,13 @@ export function useEmulatorPaneSession({
if (requestedTarget && liveTargetRef.current === requestedTarget) {
return
}
const msg =
e instanceof Error
? e.message
: 'Could not start the emulator. Check that Xcode is installed and try another device.'
// Why: setup failures otherwise trigger the mount auto-attach loop again
// and erase the actionable error before the user can read it.
suppressAutoAttachRef.current = true
const msg = emulatorPaneErrorMessage(
e,
'Could not start the emulator. Check that Xcode is installed and try another device.'
)
setError(msg)
if (tabId) {
useAppStore.getState().setTabLabel(tabId, 'Mobile Emulator')
@@ -220,42 +236,15 @@ export function useEmulatorPaneSession({
}
}, [configuredDefaultUdid, selectedUdid])
const shutdown = useCallback(
async (deviceTarget?: string) => {
if (loading) {
return
}
setLoading(true)
setError(null)
if (tabId) {
useAppStore.getState().setTabLabel(tabId, 'Shutting down…')
}
try {
const res = (await callRuntimeRpc({ kind: 'local' }, 'emulator.shutdown', {
...(deviceTarget ? { device: deviceTarget } : {}),
worktree: worktreeId
})) as { deviceUdid?: string }
const shutdownTarget = res?.deviceUdid || deviceTarget
window.dispatchEvent(
new CustomEvent(EMULATOR_LOCAL_SHUTDOWN_EVENT, {
detail: { worktreeId, deviceUdid: shutdownTarget }
})
)
void refreshDevices()
} catch (e: unknown) {
const msg =
e instanceof Error
? e.message
: 'Could not shut down the emulator. Try again from Xcode Simulator.'
setError(msg)
} finally {
if (mountedRef.current) {
setLoading(false)
}
}
},
[loading, refreshDevices, tabId, worktreeId]
)
const shutdown = useEmulatorPaneShutdown({
loading,
mountedRef,
refreshDevices,
setError,
setLoading,
tabId,
worktreeId
})
useEmulatorPaneLifecycle({ mountedRef, refreshDevices, tabId, worktreeId })
@@ -0,0 +1,62 @@
import { useCallback, type Dispatch, type RefObject, type SetStateAction } from 'react'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { useAppStore } from '@/store'
import { EMULATOR_LOCAL_SHUTDOWN_EVENT } from './use-emulator-pane-session-events'
import { emulatorPaneErrorMessage } from './emulator-pane-error-message'
type UseEmulatorPaneShutdownArgs = {
loading: boolean
mountedRef: RefObject<boolean>
refreshDevices: () => Promise<unknown[]>
setError: Dispatch<SetStateAction<string | null>>
setLoading: Dispatch<SetStateAction<boolean>>
tabId?: string
worktreeId: string
}
export function useEmulatorPaneShutdown({
loading,
mountedRef,
refreshDevices,
setError,
setLoading,
tabId,
worktreeId
}: UseEmulatorPaneShutdownArgs) {
return useCallback(
async (deviceTarget?: string) => {
if (loading) {
return
}
setLoading(true)
setError(null)
if (tabId) {
useAppStore.getState().setTabLabel(tabId, 'Shutting down…')
}
try {
const res = (await callRuntimeRpc({ kind: 'local' }, 'emulator.shutdown', {
...(deviceTarget ? { device: deviceTarget } : {}),
worktree: worktreeId
})) as { deviceUdid?: string }
const shutdownTarget = res?.deviceUdid || deviceTarget
window.dispatchEvent(
new CustomEvent(EMULATOR_LOCAL_SHUTDOWN_EVENT, {
detail: { worktreeId, deviceUdid: shutdownTarget }
})
)
void refreshDevices()
} catch (e: unknown) {
const msg = emulatorPaneErrorMessage(
e,
'Could not shut down the emulator. Try again from Xcode Simulator.'
)
setError(msg)
} finally {
if (mountedRef.current) {
setLoading(false)
}
}
},
[loading, mountedRef, refreshDevices, setError, setLoading, tabId, worktreeId]
)
}
@@ -1238,12 +1238,15 @@ export function FloatingTerminalPanel({
return (
// Why: root notification cards use z-40; keep the floating workspace below
// them so alerts are never hidden behind an open terminal panel.
// Drop shadow on the outer shell, border on an inner shell — mixing both on
// one rounded node made corners look stubby. Floating tabs skip their top
// border so the titlebar curve stays clean.
<div
ref={setPanelNode}
data-floating-terminal-panel
aria-hidden={!open}
tabIndex={-1}
className={`fixed z-30 flex min-h-[280px] min-w-[420px] overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-[0_10px_24px_rgba(0,0,0,0.18)] outline-none ${open ? 'opacity-100' : 'invisible pointer-events-none opacity-0'}`}
className={`fixed z-30 flex min-h-[280px] min-w-[420px] rounded-lg bg-transparent text-card-foreground shadow-[0_4px_12px_rgba(0,0,0,0.16),0_24px_64px_rgba(0,0,0,0.32)] outline-none dark:shadow-[0_8px_20px_rgba(0,0,0,0.35),0_28px_72px_rgba(0,0,0,0.58)] ${open ? 'opacity-100' : 'invisible pointer-events-none opacity-0'}`}
style={{
visibility: open ? 'visible' : 'hidden',
left: bounds.left,
@@ -1262,7 +1265,7 @@ export function FloatingTerminalPanel({
onBlurCapture={(event) => setFloatingTerminalInputFocused(event.relatedTarget)}
onKeyDownCapture={handleShortcutSurfaceKeyDown}
>
<div className="flex min-h-0 flex-1 flex-col">
<div className="relative flex h-full w-full min-h-0 flex-col overflow-hidden rounded-lg border border-black/14 bg-card dark:border-white/14">
<div
className="flex h-9 shrink-0 cursor-grab items-center border-b border-border bg-[var(--bg-titlebar,var(--card))] active:cursor-grabbing"
data-floating-terminal-shortcut-surface
@@ -1330,6 +1333,7 @@ export function FloatingTerminalPanel({
onMakePreviewFilePermanent={makePreviewFilePermanent}
onPinFile={pinFile}
tabBarOrder={tabBarOrder}
tabStripChrome="floating-panel"
/>
</div>
<FloatingTerminalWindowControls
@@ -112,8 +112,6 @@ const MODES: {
}[] = [
{ id: 'smart', label: 'Smart', Icon: Sparkles },
{ id: 'github', label: 'GitHub', Icon: Github },
{ id: 'gitlab', label: 'GitLab', Icon: Gitlab },
{ id: 'branches', label: 'Branch', Icon: GitBranch },
{
id: 'linear',
label: 'Linear',
@@ -123,6 +121,8 @@ const MODES: {
</svg>
)
},
{ id: 'gitlab', label: 'GitLab', Icon: Gitlab },
{ id: 'branches', label: 'Branch', Icon: GitBranch },
{ id: 'text', label: 'Name', Icon: CaseSensitive }
]
@@ -33,9 +33,6 @@ export function ExperimentalPane({
const showWorktreeSymlinks = matchesSettingsSearch(searchQuery, [
EXPERIMENTAL_SEARCH_ENTRY.symlinks
])
const showUnifiedNewTabLauncher = matchesSettingsSearch(searchQuery, [
EXPERIMENTAL_SEARCH_ENTRY.unifiedNewTabLauncher
])
return (
<div className="space-y-4">
@@ -233,46 +230,6 @@ export function ExperimentalPane({
</SearchableSetting>
) : null}
{showUnifiedNewTabLauncher ? (
<SearchableSetting
title="Smart New Tab menu"
description="Type in the New Tab menu to open a terminal, launch an agent, visit a URL, or open/create a file."
keywords={EXPERIMENTAL_SEARCH_ENTRY.unifiedNewTabLauncher.keywords}
className="space-y-3 py-2"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 shrink space-y-0.5">
<Label>Smart New Tab menu</Label>
<p className="text-xs text-muted-foreground">
Type in the New Tab menu to open a terminal, launch an agent, visit a URL, or
open/create a file.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={settings.experimentalUnifiedNewTabLauncher}
onClick={() =>
updateSettings({
experimentalUnifiedNewTabLauncher: !settings.experimentalUnifiedNewTabLauncher
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.experimentalUnifiedNewTabLauncher
? 'bg-foreground'
: 'bg-muted-foreground/30'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow-sm transition-transform ${
settings.experimentalUnifiedNewTabLauncher ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
</SearchableSetting>
) : null}
{hiddenExperimentalUnlocked ? <HiddenExperimentalGroup /> : null}
</div>
)
@@ -59,4 +59,13 @@ describe('GitPane', () => {
expect(shouldOpenAutoRenameBranchAdvanced('')).toBe(false)
expect(shouldOpenAutoRenameBranchAdvanced('creature name')).toBe(false)
})
it('renders the local main freshness setting with outcome-focused copy', () => {
const markup = renderGitPane('behind main')
expect(markup).toContain('Keep Local Main Up to Date')
expect(markup).toContain('git diff main...HEAD')
expect(markup).toContain('local-only commits')
expect(markup).not.toContain('Refresh Local Base Ref')
})
})
@@ -13,6 +13,24 @@ import { AUTO_RENAME_BRANCH_SEARCH_ENTRIES } from './auto-rename-branch-search'
export { GIT_PANE_SEARCH_ENTRIES }
const KEEP_LOCAL_MAIN_UP_TO_DATE_TITLE = 'Keep Local Main Up to Date'
const KEEP_LOCAL_MAIN_UP_TO_DATE_DESCRIPTION =
'When you create a workspace, Orca refreshes the remote base and safely fast-forwards your matching local branch, such as main or master. This keeps commands like git diff main...HEAD from comparing against stale history. Orca skips the update if that branch has uncommitted changes or local-only commits.'
const KEEP_LOCAL_MAIN_UP_TO_DATE_KEYWORDS = [
'main',
'master',
'origin/main',
'git diff',
'behind main',
'up to date',
'stale main',
'refresh local main',
'base ref',
'fresh base',
'safely',
'worktree'
]
export function shouldShowAutoRenameBranchSetting(
searchQuery: string,
hasUnsavedBranchPromptChanges: boolean
@@ -101,43 +119,24 @@ export function GitPane({
</SearchableSetting>
) : null,
matchesSettingsSearch(searchQuery, {
title: 'Refresh Local Base Ref',
description:
'Safely fast-forward local main or master so AI tools and diffs use a fresh base.',
keywords: [
'main',
'master',
'origin/main',
'git diff',
'base ref',
'fresh base',
'safely',
'worktree'
]
title: KEEP_LOCAL_MAIN_UP_TO_DATE_TITLE,
description: KEEP_LOCAL_MAIN_UP_TO_DATE_DESCRIPTION,
keywords: KEEP_LOCAL_MAIN_UP_TO_DATE_KEYWORDS
}) ? (
<SearchableSetting
key="refresh-base-ref"
title="Refresh Local Base Ref"
description="Safely fast-forward local main or master so AI tools and diffs use a fresh base."
keywords={[
'main',
'master',
'origin/main',
'git diff',
'base ref',
'fresh base',
'safely',
'worktree'
]}
title={KEEP_LOCAL_MAIN_UP_TO_DATE_TITLE}
description={KEEP_LOCAL_MAIN_UP_TO_DATE_DESCRIPTION}
keywords={KEEP_LOCAL_MAIN_UP_TO_DATE_KEYWORDS}
className="flex items-center justify-between gap-4 py-2"
>
<div className="space-y-0.5">
<Label>Refresh Local Base Ref</Label>
<Label>{KEEP_LOCAL_MAIN_UP_TO_DATE_TITLE}</Label>
<p className="text-xs text-muted-foreground">
Turn this on if you or AI tools use commands like <code>git diff main...HEAD</code>.
Orca first refreshes the remote base, then safely fast-forwards the matching local{' '}
<code>main</code> or <code>master</code> so those commands do not compare against stale
history. Orca skips the update if the local branch is dirty or diverged.
When you create a workspace, Orca refreshes the remote base and safely fast-forwards
your matching local branch, such as <code>main</code> or <code>master</code>. This keeps
commands like <code>git diff main...HEAD</code> from comparing against stale history.
Orca skips the update if that branch has uncommitted changes or local-only commits.
</p>
</div>
<button
@@ -79,26 +79,6 @@ export const EXPERIMENTAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
'env',
'node_modules'
]
},
{
title: 'Smart New Tab menu',
description:
'Type in the New Tab menu to open a terminal, launch an agent, visit a URL, or open/create a file.',
keywords: [
'experimental',
'smart',
'new tab',
'new tab menu',
'launcher',
'unified',
'plus',
'terminal',
'agents',
'claude',
'codex',
'url',
'file'
]
}
]
@@ -118,6 +98,5 @@ export const EXPERIMENTAL_SEARCH_ENTRY = {
activity: findEntry('Agents View'),
terminalAttention: findEntry('Terminal attention'),
compactWorktreeCards: findEntry('Compact worktree cards'),
symlinks: findEntry('Symlinks on worktrees'),
unifiedNewTabLauncher: findEntry('Smart New Tab menu')
symlinks: findEntry('Symlinks on worktrees')
} as const
@@ -8,13 +8,18 @@ export const GIT_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
keywords: ['branch naming', 'git username', 'custom']
},
{
title: 'Refresh Local Base Ref',
description: 'Safely fast-forward local main or master so AI tools and diffs use a fresh base.',
title: 'Keep Local Main Up to Date',
description:
'When you create a workspace, Orca refreshes the remote base and safely fast-forwards your matching local branch, such as main or master. This keeps commands like git diff main...HEAD from comparing against stale history. Orca skips the update if that branch has uncommitted changes or local-only commits.',
keywords: [
'main',
'master',
'origin/main',
'git diff',
'behind main',
'up to date',
'stale main',
'refresh local main',
'base ref',
'fresh base',
'safely',
@@ -0,0 +1,90 @@
import React from 'react'
import { AlertTriangle, Loader2, X } from 'lucide-react'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import {
getCreationProgressLabel,
type PendingWorktreeCreation
} from '@/lib/pending-worktree-creation'
function statusLabel(entry: PendingWorktreeCreation): string {
if (entry.status === 'error') {
return entry.error ?? 'Creation failed'
}
return getCreationProgressLabel(entry)
}
/**
* Sidebar row for an in-progress (or failed) worktree create. Rendered inline in
* the worktree list under its target repo, so the new workspace appears where it
* will land. Self-contained: reads its own entry + active state by creationId.
*/
export function PendingWorktreeRow({
creationId
}: {
creationId: string
}): React.JSX.Element | null {
const entry = useAppStore((s) => s.pendingWorktreeCreations[creationId])
const active = useAppStore((s) => s.activePendingCreationId === creationId)
if (!entry) {
return null
}
const isError = entry.status === 'error'
return (
<div
className={cn(
'group flex w-full items-center gap-1 rounded-md transition-colors',
active
? 'border border-sidebar-ring/35 bg-sidebar-accent/70 ring-1 ring-sidebar-ring/30'
: 'border border-transparent hover:bg-sidebar-accent/60'
)}
>
<button
type="button"
// Why: never route this through setActiveWorktree — there is no real
// worktree yet. activePendingCreationId drives the content loader instead.
onClick={() => {
const store = useAppStore.getState()
store.setActivePendingWorktreeCreation(creationId)
store.updatePendingWorktreeCreation(creationId, { loaderVisible: true })
store.setActiveView('terminal')
}}
className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left"
>
<span className="flex size-4 shrink-0 items-center justify-center">
{isError ? (
<AlertTriangle className="size-3.5 text-destructive" />
) : (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium text-sidebar-foreground">
{entry.request.displayName || entry.request.name}
</span>
<span
className={cn(
'block truncate text-[11px]',
isError ? 'text-destructive/90' : 'text-muted-foreground'
)}
>
{statusLabel(entry)}
</span>
</span>
</button>
<button
type="button"
title="Cancel"
aria-label="Cancel worktree creation"
onClick={() => useAppStore.getState().removePendingWorktreeCreation(creationId)}
className={cn(
'mr-1 flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-opacity hover:bg-sidebar-accent hover:text-foreground focus-visible:opacity-100',
isError ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
)}
>
<X className="size-3.5" />
</button>
</div>
)
}
@@ -20,6 +20,7 @@ import {
Workflow
} from 'lucide-react'
import { useAppStore } from '@/store'
import { useShallow } from 'zustand/react/shallow'
import type { AppState } from '@/store/types'
import {
getAllWorktreesFromState,
@@ -28,6 +29,7 @@ import {
useWorktreeMap
} from '@/store/selectors'
import WorktreeCard from './WorktreeCard'
import { PendingWorktreeRow } from './PendingWorktreeRow'
import WorktreeCardAgents, {
SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT
} from './WorktreeCardAgents'
@@ -639,6 +641,9 @@ export function getRenderRowKey(row: RenderRow): string {
if (row.type === 'imported-worktrees-card') {
return `imported:${row.key}`
}
if (row.type === 'pending-creation') {
return `pending:${row.creationId}`
}
return `wt:${row.worktree.id}`
}
@@ -652,7 +657,7 @@ export function getWorktreeDragGroups(rows: Row[]): WorktreeDragGroup[] {
groups.push({ key: current.key, worktreeIds: current.ids })
continue
}
if (row.type === 'imported-worktrees-card') {
if (row.type === 'imported-worktrees-card' || row.type === 'pending-creation') {
continue
}
if (!current) {
@@ -3563,6 +3568,24 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
)
}
if (row.type === 'pending-creation') {
return (
<div
key={vItem.key}
role="presentation"
data-worktree-virtual-row
data-worktree-virtual-row-key={String(vItem.key)}
data-worktree-virtual-row-start={vItem.start}
data-index={vItem.index}
ref={measureVirtualRowElement}
className="absolute left-0 right-0 top-0 px-2 pb-1.5"
style={{ transform: getVirtualRowTransform(vItem.start) }}
>
<PendingWorktreeRow creationId={row.creationId} />
</div>
)
}
const itemWorkspaceStatus =
groupBy === 'workspace-status'
? getWorkspaceStatus(row.worktree, workspaceStatuses)
@@ -4082,6 +4105,27 @@ const WorktreeList = React.memo(function WorktreeList({
}, [filterRepoIds, groupBy, repos, worktreesByRepo])
const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos])
// Why: buildRows only needs which creates exist and their repo. Subscribe on a
// flat key array (value-compared by useShallow) so progress updates
// (phase/loaderVisible) don't churn it and rebuild the whole sidebar row model
// on every creation tick. Split on the first space — the creationId is a UUID,
// so it has none and the repoId (which may contain spaces) stays intact.
const pendingCreationKeys = useAppStore(
useShallow((s) =>
Object.values(s.pendingWorktreeCreations ?? {}).map(
(creation) => `${creation.creationId} ${creation.request.repoId}`
)
)
)
const pendingCreations = useMemo(
() =>
pendingCreationKeys.map((key) => {
const separator = key.indexOf(' ')
return { creationId: key.slice(0, separator), repoId: key.slice(separator + 1) }
}),
[pendingCreationKeys]
)
// Build flat row list for rendering
const rows: Row[] = useMemo(
() =>
@@ -4100,7 +4144,8 @@ const WorktreeList = React.memo(function WorktreeList({
settings,
projectGroups,
placeholderRepoIds,
importedWorktreesByRepo
importedWorktreesByRepo,
pendingCreations
),
[
groupBy,
@@ -4116,7 +4161,8 @@ const WorktreeList = React.memo(function WorktreeList({
settings,
projectGroups,
placeholderRepoIds,
importedWorktreesByRepo
importedWorktreesByRepo,
pendingCreations
]
)
// Why: status headers change during wake (inactive -> active). Key only on
@@ -9,6 +9,7 @@ type WorktreeDragUnitRow =
| { type: 'header'; key: string }
| { type: 'item'; worktree: { id: string }; depth: number }
| { type: 'imported-worktrees-card' }
| { type: 'pending-creation' }
export function getWorktreeDragUnitGroups(
rows: readonly WorktreeDragUnitRow[]
@@ -26,7 +27,7 @@ export function getWorktreeDragUnitGroups(
})
continue
}
if (row.type === 'imported-worktrees-card') {
if (row.type === 'imported-worktrees-card' || row.type === 'pending-creation') {
continue
}
if (!current) {
@@ -9,7 +9,8 @@ import {
getGroupKeysForWorktree,
getLineageGroupKey,
getLineageRenderInfo,
getPRGroupKey
getPRGroupKey,
type PendingCreationRef
} from './worktree-list-groups'
import type {
DetectedWorktree,
@@ -1663,3 +1664,112 @@ describe('WorktreeList header styles', () => {
expect(source).toContain('color={repoHeaderColor}')
})
})
describe('buildRows pending creations', () => {
function makePendingCreation(creationId: string, repoId: string): PendingCreationRef {
return { creationId, repoId }
}
it('nests a pending creation under its repo, above the repo worktrees', () => {
const rows = buildRows(
'repo',
[worktree],
repoMap,
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map([[worktree.id, worktree]]),
false,
undefined,
[],
new Set(),
new Map(),
[makePendingCreation('c1', repo.id)]
)
const types = rows.map((row) => row.type)
const headerIndex = types.indexOf('header')
const pendingIndex = rows.findIndex(
(row) => row.type === 'pending-creation' && row.creationId === 'c1'
)
const itemIndex = types.indexOf('item')
expect(headerIndex).toBeGreaterThanOrEqual(0)
expect(pendingIndex).toBe(headerIndex + 1)
expect(pendingIndex).toBeLessThan(itemIndex)
})
it('creates a repo group for a pending creation in a repo with no worktrees yet', () => {
const rows = buildRows(
'repo',
[],
repoMap,
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map(),
false,
undefined,
[],
new Set(),
new Map(),
[makePendingCreation('c1', repo.id)]
)
expect(rows.map((row) => row.type)).toEqual(['header', 'pending-creation'])
})
it('keeps a pending creation visible when its repo metadata is temporarily missing', () => {
const rows = buildRows(
'repo',
[],
new Map(),
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map(),
false,
undefined,
[],
new Set(),
new Map(),
[makePendingCreation('c1', repo.id)]
)
expect(rows).toMatchObject([
{ type: 'header', key: `repo:${repo.id}`, label: 'Unknown' },
{ type: 'pending-creation', creationId: 'c1', repo: undefined }
])
})
it('surfaces pending creations at the top for non-repo groupings', () => {
const rows = buildRows(
'none',
[worktree],
repoMap,
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map([[worktree.id, worktree]]),
false,
undefined,
[],
new Set(),
new Map(),
[makePendingCreation('c1', repo.id)]
)
expect(rows[0]).toMatchObject({ type: 'pending-creation', creationId: 'c1' })
})
})
@@ -70,7 +70,32 @@ export type ImportedWorktreesCardRow = {
placement: 'repo-group' | 'pinned-fallback'
}
export type Row = GroupHeaderRow | WorktreeRow | ImportedWorktreesCardRow
export type PendingCreationRow = {
type: 'pending-creation'
key: string
creationId: string
repo: Repo | undefined
}
/** Minimal shape buildRows needs for an in-flight create. Deliberately not the
* full PendingWorktreeCreation: row identity depends only on which creates
* exist and their repo, so callers can subscribe on this stable shape and keep
* progress-field churn (phase/loaderVisible) from rebuilding the whole list. */
export type PendingCreationRef = { creationId: string; repoId: string }
export type Row = GroupHeaderRow | WorktreeRow | ImportedWorktreesCardRow | PendingCreationRow
function buildPendingCreationRow(
creation: PendingCreationRef,
repoMap: Map<string, Repo>
): PendingCreationRow {
return {
type: 'pending-creation',
key: `pending:${creation.creationId}`,
creationId: creation.creationId,
repo: repoMap.get(creation.repoId)
}
}
type OrderedGroupEntry = [string, { label: string; items: Worktree[]; repo?: Repo }]
@@ -506,10 +531,27 @@ export function buildRows(
settings?: AppState['settings'],
projectGroups: readonly ProjectGroup[] = [],
placeholderRepoIds: ReadonlySet<string> = new Set(),
importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate> = new Map()
importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate> = new Map(),
pendingCreations: readonly PendingCreationRef[] = []
): Row[] {
const result: Row[] = []
const pendingByRepo = new Map<string, PendingCreationRef[]>()
for (const creation of pendingCreations) {
const list = pendingByRepo.get(creation.repoId) ?? []
list.push(creation)
pendingByRepo.set(creation.repoId, list)
}
// Why: non-repo groupings have no repo section to nest an in-progress create
// under, so surface them at the very top (where the old global strip sat)
// rather than dropping them. Repo grouping nests them under their repo below.
if (groupBy !== 'repo' && pendingCreations.length > 0) {
for (const creation of pendingCreations) {
result.push(buildPendingCreationRow(creation, repoMap))
}
}
const visibleUnpinnedRepoIds = new Set(
worktrees.filter((worktree) => !worktree.isPinned).map((worktree) => worktree.repoId)
)
@@ -597,6 +639,18 @@ export function buildRows(
}
}
}
if (groupBy === 'repo') {
for (const repoId of pendingByRepo.keys()) {
const key = `repo:${repoId}`
if (!grouped.has(key)) {
// Why: creating the first worktree in a repo leaves it with no group yet;
// ensure one so the in-progress row nests under its repo instead of being
// dropped.
const repo = repoMap.get(repoId)
grouped.set(key, { label: repo?.displayName ?? 'Unknown', items: [], repo })
}
}
}
const orderedGroups: OrderedGroupEntry[] = []
if (groupBy === 'pr-status') {
@@ -680,11 +734,18 @@ export function buildRows(
result.push(header)
if (!isCollapsed) {
if (groupBy === 'repo' && repo) {
const candidate = importedWorktreesByRepo.get(repo.id)
if (groupBy === 'repo') {
const repoId = repo?.id ?? key.slice('repo:'.length)
const candidate = importedWorktreesByRepo.get(repoId)
if (candidate) {
result.push(buildImportedWorktreesCardRow(candidate, 'repo-group'))
}
// Why: surface in-progress creates at the top of their own repo so the
// new workspace appears where it will land, not flashed to the very top
// of the sidebar.
for (const creation of pendingByRepo.get(repoId) ?? []) {
result.push(buildPendingCreationRow(creation, repoMap))
}
}
const items = groupBy === 'repo' ? orderMainWorktreeFirst(group.items) : group.items
appendWorktreeRows(result, items, repoMap, lineageById, worktreeMap, {
@@ -6,6 +6,7 @@ import { PINNED_GROUP_KEY } from './worktree-list-groups'
export const GROUP_HEADER_ROW_HEIGHT = 28
const SECONDARY_GROUP_HEADER_TOP_MARGIN = 4
const IMPORTED_WORKTREES_LINE_ROW_HEIGHT = 36
const PENDING_CREATION_ROW_HEIGHT = 56
type WorktreeItemRow = Extract<Row, { type: 'item' }>
export type RenderRow = Row | { type: 'lineage-group'; key: string; rows: WorktreeItemRow[] }
@@ -46,6 +47,9 @@ export function estimateRenderRowSize(
if (row?.type === 'imported-worktrees-card') {
return IMPORTED_WORKTREES_LINE_ROW_HEIGHT
}
if (row?.type === 'pending-creation') {
return PENDING_CREATION_ROW_HEIGHT
}
return 116
}
@@ -46,6 +46,7 @@ import { ResourceUsageStatusSegment } from './ResourceUsageStatusSegment'
import { PortsStatusSegment } from './PortsStatusSegment'
import { isStatusBarItemAvailable } from './status-bar-agent-gating'
import { isProviderConfigured } from './status-bar-provider-visibility'
import { StatusBarUsageEmptyCta } from './StatusBarUsageEmptyCta'
import { shouldOpenStatusBarContextMenu } from './status-bar-context-menu-policy'
import { PetStatusSegment } from './PetStatusSegment'
import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal'
@@ -1621,6 +1622,17 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
floatingTerminalEnabled && floatingTerminalTriggerLocation === 'status-bar'
const anyVisible =
showClaude || showCodex || showGemini || showOpencodeGo || showKimi || showResourceUsage
// Why: a brand-new user with no provider configured would otherwise see an
// empty left side of the status bar and wonder what's missing. The CTA
// names the surface and points to the setup path. Detection is on
// configuration (not user toggles) so users who intentionally hid a
// provider's bar don't get the teaching prompt back.
const isEmptyUsageState =
!isProviderConfigured(claude) &&
!isProviderConfigured(codex) &&
!isProviderConfigured(gemini) &&
!isProviderConfigured(opencodeGo) &&
!isProviderConfigured(kimi)
const anyFetching =
claude?.status === 'fetching' ||
codex?.status === 'fetching' ||
@@ -1656,33 +1668,41 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
}}
>
<div className="flex items-center gap-3">
{showClaude && <ClaudeSwitcherMenu claude={claude} compact={compact} iconOnly={iconOnly} />}
{showCodex && <CodexSwitcherMenu codex={codex} compact={compact} iconOnly={iconOnly} />}
{showGemini && (
<ProviderDetailsMenu
provider={gemini}
compact={compact}
iconOnly={iconOnly}
ariaLabel="Open Gemini usage details"
/>
{isEmptyUsageState ? (
<StatusBarUsageEmptyCta />
) : (
<>
{showClaude && (
<ClaudeSwitcherMenu claude={claude} compact={compact} iconOnly={iconOnly} />
)}
{showCodex && <CodexSwitcherMenu codex={codex} compact={compact} iconOnly={iconOnly} />}
{showGemini && (
<ProviderDetailsMenu
provider={gemini}
compact={compact}
iconOnly={iconOnly}
ariaLabel="Open Gemini usage details"
/>
)}
{showOpencodeGo && (
<ProviderDetailsMenu
provider={opencodeGo}
compact={compact}
iconOnly={iconOnly}
ariaLabel="Open OpenCode Go usage details"
/>
)}
{showKimi && (
<ProviderDetailsMenu
provider={kimi}
compact={compact}
iconOnly={iconOnly}
ariaLabel="Open Kimi usage details"
/>
)}
</>
)}
{showOpencodeGo && (
<ProviderDetailsMenu
provider={opencodeGo}
compact={compact}
iconOnly={iconOnly}
ariaLabel="Open OpenCode Go usage details"
/>
)}
{showKimi && (
<ProviderDetailsMenu
provider={kimi}
compact={compact}
iconOnly={iconOnly}
ariaLabel="Open Kimi usage details"
/>
)}
{anyVisible && (
{anyVisible && !isEmptyUsageState && (
<Tooltip>
<TooltipTrigger asChild>
<button
@@ -0,0 +1,69 @@
import React, { useCallback } from 'react'
import { BarChart3 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { AgentIcon } from '@/lib/agent-catalog'
import { useAppStore } from '../../store'
import { ClaudeIcon, GeminiIcon, OpenAIIcon, OpenCodeGoIcon } from './icons'
// Why: a brand-new user has no configured provider, so the bottom-left would
// otherwise be empty but for a refresh icon with nothing to refresh. This CTA
// names the surface and routes to AI Provider Accounts — connecting an account
// there is what makes the usage chips (and account switching) appear here.
export function StatusBarUsageEmptyCta(): React.JSX.Element {
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
// Why: the accounts pane lists every provider, so we don't preselect a
// section ID — a brand-new user has no "Claude" or "Codex" yet.
const handleOpenSettings = useCallback(() => {
recordFeatureInteraction('usage-tracking')
openSettingsTarget({ pane: 'accounts', repoId: null })
openSettingsPage()
}, [openSettingsPage, openSettingsTarget, recordFeatureInteraction])
return (
<HoverCard openDelay={150} closeDelay={80}>
<HoverCardTrigger asChild>
<button
type="button"
onClick={handleOpenSettings}
aria-label="Connect AI accounts to see usage"
className="inline-flex h-5 cursor-pointer items-center gap-1.5 rounded px-1.5 text-xs font-normal text-muted-foreground transition-colors hover:bg-accent/70 hover:text-foreground"
>
<BarChart3 className="size-3.5" />
<span>Connect AI accounts to see usage</span>
</button>
</HoverCardTrigger>
<HoverCardContent side="top" align="start" sideOffset={8} className="w-[260px] p-2.5">
<div className="space-y-2 text-xs leading-[1.45]">
<div className="font-semibold text-foreground">Agent usage limits</div>
<p className="text-muted-foreground">
Connect your AI provider accounts to see their usage in real time and easily switch
between accounts.
</p>
{/* Why: name the full provider set so the feature doesn't read as
support for just one agent. */}
<div className="flex items-center gap-1.5 text-muted-foreground">
<span>Supports:</span>
<ClaudeIcon size={13} />
<OpenAIIcon size={13} />
<GeminiIcon size={13} />
<OpenCodeGoIcon size={13} />
<AgentIcon agent="kimi" size={13} />
</div>
<Button
type="button"
variant="default"
size="sm"
onClick={handleOpenSettings}
className="mt-0.5 h-7 w-full text-xs"
>
Connect an account
</Button>
</div>
</HoverCardContent>
</HoverCard>
)
}
@@ -19,6 +19,7 @@ import {
ACTIVE_TAB_INDICATOR_CLASSES,
getDropIndicatorClasses,
getTabRootStateClasses,
getTabStripBorderClasses,
type DropIndicator
} from './drop-indicator'
import { preventMiddleButtonDefault } from './middle-button-default-guard'
@@ -108,7 +109,8 @@ export default function BrowserTab({
onDuplicate,
onTogglePin,
dragData,
dropIndicator
dropIndicator,
includeTopTabBorder = true
}: {
tab: BrowserTabState
isActive: boolean
@@ -122,6 +124,7 @@ export default function BrowserTab({
onTogglePin: () => void
dragData: TabDragItemData
dropIndicator?: DropIndicator
includeTopTabBorder?: boolean
}): React.JSX.Element {
// Why: no transform/transition/isDragging styling — the drag design is
// that tabs stay visually anchored; only the blue insertion bar moves.
@@ -171,7 +174,7 @@ export default function BrowserTab({
data-pinned={isPinned ? 'true' : 'false'}
{...attributes}
{...listeners}
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none shrink-0 outline-none focus:outline-none focus-visible:outline-none border-t ${hasTabsToRight ? 'border-r' : ''} border-border ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`}
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none shrink-0 outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`}
onPointerDown={(e) => {
if (e.button !== 0) {
return
@@ -160,6 +160,7 @@ vi.mock('./SortableTab', () => ({
vi.mock('./drop-indicator', () => ({
ACTIVE_TAB_INDICATOR_CLASSES: 'active-tab-indicator',
getDropIndicatorClasses: () => '',
getTabStripBorderClasses: () => '',
getTabRootStateClasses: () => ''
}))
@@ -21,6 +21,7 @@ import {
ACTIVE_TAB_INDICATOR_CLASSES,
getDropIndicatorClasses,
getTabRootStateClasses,
getTabStripBorderClasses,
type DropIndicator
} from './drop-indicator'
import { canOpenMarkdownPreview } from '@/components/editor/markdown-preview-controls'
@@ -40,7 +41,8 @@ export default function EditorFileTab({
onTogglePin,
onSplitGroup,
dragData,
dropIndicator
dropIndicator,
includeTopTabBorder = true
}: {
file: OpenFile & { tabId?: string }
isActive: boolean
@@ -56,6 +58,7 @@ export default function EditorFileTab({
onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void
dragData: TabDragItemData
dropIndicator?: DropIndicator
includeTopTabBorder?: boolean
}): React.JSX.Element {
const worktree = useWorktreeById(file.worktreeId)
const repo = useRepoById(worktree?.repoId ?? null)
@@ -200,7 +203,7 @@ export default function EditorFileTab({
data-pinned={isPinned ? 'true' : 'false'}
{...attributes}
{...listeners}
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none shrink-0 outline-none focus:outline-none focus-visible:outline-none border-t ${hasTabsToRight ? 'border-r' : ''} border-border ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`}
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none shrink-0 outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`}
onPointerDown={(e) => {
if (e.button !== 0) {
return
@@ -129,6 +129,7 @@ vi.mock('../sidebar/WorktreeCardHelpers', () => ({
vi.mock('./drop-indicator', () => ({
ACTIVE_TAB_INDICATOR_CLASSES: 'active-tab-indicator',
getDropIndicatorClasses: () => '',
getTabStripBorderClasses: () => '',
getTabRootStateClasses: () => ''
}))
@@ -15,6 +15,7 @@ import {
ACTIVE_TAB_INDICATOR_CLASSES,
getDropIndicatorClasses,
getTabRootStateClasses,
getTabStripBorderClasses,
type DropIndicator
} from './drop-indicator'
import { preventMiddleButtonDefault } from './middle-button-default-guard'
@@ -38,6 +39,7 @@ type SortableTabProps = {
onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void
dragData: TabDragItemData
dropIndicator?: DropIndicator
includeTopTabBorder?: boolean
}
export const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
@@ -59,7 +61,8 @@ export default function SortableTab({
onToggleExpand,
onSplitGroup,
dragData,
dropIndicator
dropIndicator,
includeTopTabBorder = true
}: SortableTabProps): React.JSX.Element {
// Why: subscribe to the per-tab boolean directly so only the tab whose unread
// status actually flipped re-renders. Reading the whole `unreadTerminalTabs`
@@ -213,7 +216,7 @@ export default function SortableTab({
// tab still reads as "selected + has activity". The wash is
// rendered as an absolutely-positioned child below so the ::after
// pseudo-element stays free for the drop indicator.
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none shrink-0 outline-none focus:outline-none focus-visible:outline-none border-t ${hasTabsToRight ? 'border-r' : ''} border-border ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`}
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none shrink-0 outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`}
onDoubleClick={(e) => {
if (isEditing) {
return
+11 -9
View File
@@ -108,6 +108,8 @@ type TabBarProps = {
sourceVisibleTabId?: string
) => void
hoveredTabInsertion?: HoveredTabInsertion | null
/** Floating workspace panels are rounded; skip tab top borders that clash with the curve. */
tabStripChrome?: 'default' | 'floating-panel'
}
type TabItem =
@@ -206,8 +208,10 @@ function TabBarInner({
onPinFile,
tabBarOrder,
onCreateSplitGroup,
hoveredTabInsertion
hoveredTabInsertion,
tabStripChrome = 'default'
}: TabBarProps): React.JSX.Element {
const includeTopTabBorder = tabStripChrome !== 'floating-panel'
const newTerminalShortcut = useShortcutLabel('tab.newTerminal')
const newBrowserShortcut = useShortcutLabel('tab.newBrowser')
const newSimulatorShortcut = useShortcutLabel('tab.newSimulator')
@@ -237,17 +241,11 @@ function TabBarInner({
const repo = worktree ? s.repos?.find((entry) => entry.id === worktree.repoId) : null
return Boolean(repo?.connectionId)
})
const unifiedNewTabLauncherEnabled = useAppStore(
(s) => s.settings?.experimentalUnifiedNewTabLauncher === true
)
const defaultAgent = useAppStore((s) => s.settings?.defaultTuiAgent)
const agentCmdOverrides = useAppStore(
(s) => s.settings?.agentCmdOverrides ?? EMPTY_AGENT_CMD_OVERRIDES
)
const connectionId = useAppStore((s) => {
if (!unifiedNewTabLauncherEnabled) {
return undefined
}
const allWorktrees = Object.values(s.worktreesByRepo ?? {}).flat()
const worktree = allWorktrees.find((w) => w.id === worktreeId)
if (!worktree) {
@@ -865,6 +863,7 @@ function TabBarInner({
}
dragData={dragData}
dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null}
includeTopTabBorder={includeTopTabBorder}
/>
)
}
@@ -886,6 +885,7 @@ function TabBarInner({
onTogglePin={() => togglePinned(item)}
dragData={dragData}
dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null}
includeTopTabBorder={includeTopTabBorder}
/>
)
}
@@ -921,6 +921,7 @@ function TabBarInner({
}
dragData={dragData}
dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null}
includeTopTabBorder={includeTopTabBorder}
/>
)
}
@@ -946,6 +947,7 @@ function TabBarInner({
}
dragData={dragData}
dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null}
includeTopTabBorder={includeTopTabBorder}
/>
)
})}
@@ -969,7 +971,7 @@ function TabBarInner({
<DropdownMenuContent
align="start"
sideOffset={6}
className={`${unifiedNewTabLauncherEnabled ? 'w-72 max-w-[calc(100vw-1rem)]' : 'min-w-[11rem]'} rounded-[11px] border-border/80 p-1 shadow-[0_16px_36px_rgba(0,0,0,0.24)]`}
className="w-72 max-w-[calc(100vw-1rem)] rounded-[11px] border-border/80 p-1 shadow-[0_16px_36px_rgba(0,0,0,0.24)]"
onCloseAutoFocus={(e) => {
// Why: terminal-producing menu actions activate a freshly-mounted
// xterm. Radix's default focus restore sends focus back to the "+"
@@ -978,7 +980,7 @@ function TabBarInner({
runPendingNewTabMenuFocusAfterClose()
}}
>
{!terminalOnly && onOpenEntry && unifiedNewTabLauncherEnabled ? (
{!terminalOnly && onOpenEntry ? (
<>
<TabBarCreateEntry
worktreeId={worktreeId}
@@ -11,6 +11,10 @@ const appStoreSnapshot: {
worktreesByRepo: Record<string, { id: string; repoId: string }[]>
unifiedTabsByWorktree: Record<string, unknown[]>
activeGroupIdByWorktree: Record<string, string>
detectedAgentIds: string[] | null
remoteDetectedAgentIds: Record<string, string[]>
isDetectingAgents: boolean
isDetectingRemoteAgents: Record<string, boolean>
} = {
activeTabId: null,
activeTabType: null,
@@ -18,7 +22,11 @@ const appStoreSnapshot: {
repos: [],
worktreesByRepo: {},
unifiedTabsByWorktree: {},
activeGroupIdByWorktree: {}
activeGroupIdByWorktree: {},
detectedAgentIds: null,
remoteDetectedAgentIds: {},
isDetectingAgents: false,
isDetectingRemoteAgents: {}
}
const pinTabMock: (tabId: string) => void = vi.fn()
const unpinTabMock: (tabId: string) => void = vi.fn()
@@ -33,6 +41,10 @@ const useAppStoreMock = vi.fn(
worktreesByRepo: Record<string, { id: string; repoId: string }[]>
unifiedTabsByWorktree: Record<string, unknown[]>
activeGroupIdByWorktree: Record<string, string>
detectedAgentIds: string[] | null
remoteDetectedAgentIds: Record<string, string[]>
isDetectingAgents: boolean
isDetectingRemoteAgents: Record<string, boolean>
pinTab: typeof pinTabMock
unpinTab: typeof unpinTabMock
settings: {
@@ -50,6 +62,10 @@ const useAppStoreMock = vi.fn(
worktreesByRepo: appStoreSnapshot.worktreesByRepo,
unifiedTabsByWorktree: appStoreSnapshot.unifiedTabsByWorktree,
activeGroupIdByWorktree: appStoreSnapshot.activeGroupIdByWorktree,
detectedAgentIds: appStoreSnapshot.detectedAgentIds,
remoteDetectedAgentIds: appStoreSnapshot.remoteDetectedAgentIds,
isDetectingAgents: appStoreSnapshot.isDetectingAgents,
isDetectingRemoteAgents: appStoreSnapshot.isDetectingRemoteAgents,
pinTab: pinTabMock,
unpinTab: unpinTabMock,
settings: {
@@ -107,6 +123,10 @@ useAppStoreExport.getState = vi.fn(() => ({
worktreesByRepo: appStoreSnapshot.worktreesByRepo,
unifiedTabsByWorktree: appStoreSnapshot.unifiedTabsByWorktree,
activeGroupIdByWorktree: appStoreSnapshot.activeGroupIdByWorktree,
detectedAgentIds: appStoreSnapshot.detectedAgentIds,
remoteDetectedAgentIds: appStoreSnapshot.remoteDetectedAgentIds,
isDetectingAgents: appStoreSnapshot.isDetectingAgents,
isDetectingRemoteAgents: appStoreSnapshot.isDetectingRemoteAgents,
pinTab: pinTabMock,
unpinTab: unpinTabMock,
settings: {
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { FilePlus, FileText, Globe, Loader2, Search } from 'lucide-react'
import { FilePlus, FileText, Globe, Loader2 } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { AgentIcon } from '@/lib/agent-catalog'
import { cn } from '@/lib/utils'
@@ -100,7 +100,7 @@ export default function TabBarCreateEntry({
const statusMessage =
statusOption?.classification.kind === 'empty' || statusOption?.classification.kind === 'blocked'
? statusOption.classification.message
: 'URL, file, or new file'
: 'Open any file, URL, agent, ...'
const submitOption = (option?: ActiveOption) => {
if (disabled || pending) {
@@ -143,7 +143,7 @@ export default function TabBarCreateEntry({
return (
<form
className="px-1 pb-1"
className="pb-1"
onSubmit={(event) => {
event.preventDefault()
submitOption()
@@ -164,11 +164,7 @@ export default function TabBarCreateEntry({
}}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="relative">
<Search
className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<div className="-mx-1 flex items-center border-b border-black/14 px-3 dark:border-white/14">
<Input
ref={inputRef}
value={query}
@@ -177,14 +173,14 @@ export default function TabBarCreateEntry({
setError(null)
}}
disabled={disabled}
aria-label="Open URL, file, or new file"
aria-label="Open any file, URL, agent, ..."
aria-invalid={error ? true : undefined}
placeholder="URL, file, or new file"
className="h-8 rounded-[7px] pl-7 pr-2 text-[12px]"
placeholder="Open any file, URL, agent, ..."
className="h-9 rounded-none border-0 bg-transparent px-0 text-[12px] shadow-none focus-visible:border-0 focus-visible:ring-0 aria-invalid:border-0 aria-invalid:ring-0 dark:bg-transparent"
/>
</div>
{error || activeOptions.length > 0 || hasQuery ? (
<div className="mt-1 space-y-0.5">
<div className="mt-1 space-y-0.5 px-1">
{error ? (
<EntryStatusRow message={error} />
) : activeOptions.length > 0 ? (
@@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
import {
ACTIVE_TAB_INDICATOR_CLASSES,
getDropIndicatorClasses,
getTabRootStateClasses
getTabRootStateClasses,
getTabStripBorderClasses
} from './drop-indicator'
describe('getDropIndicatorClasses', () => {
@@ -55,6 +56,20 @@ describe('ACTIVE_TAB_INDICATOR_CLASSES', () => {
})
})
describe('getTabStripBorderClasses', () => {
it('includes top and right borders by default', () => {
expect(getTabStripBorderClasses(true)).toBe('border-t border-r border-border')
expect(getTabStripBorderClasses(false)).toBe('border-t border-border')
})
it('can omit the top border for rounded floating panel titlebars', () => {
expect(getTabStripBorderClasses(true, { includeTopBorder: false })).toBe(
'border-r border-border'
)
expect(getTabStripBorderClasses(false, { includeTopBorder: false })).toBe('border-border')
})
})
describe('getTabRootStateClasses', () => {
it('returns the shared selected-tab surface treatment', () => {
const classes = getTabRootStateClasses(true)
@@ -33,3 +33,13 @@ export function getTabRootStateClasses(isActive: boolean): string {
? 'bg-[color-mix(in_srgb,var(--foreground)_6%,var(--card))] text-foreground'
: 'bg-card text-muted-foreground hover:text-foreground'
}
export function getTabStripBorderClasses(
hasTabsToRight: boolean,
options?: { includeTopBorder?: boolean }
): string {
const includeTopBorder = options?.includeTopBorder ?? true
return [includeTopBorder ? 'border-t' : '', hasTabsToRight ? 'border-r' : '', 'border-border']
.filter(Boolean)
.join(' ')
}
@@ -226,7 +226,9 @@ export function getTabEntryOptions(
): TabEntryOption[] {
const trimmed = query.trim()
if (!trimmed) {
return [{ id: 'empty', classification: { kind: 'empty', message: 'URL, file, or new file' } }]
return [
{ id: 'empty', classification: { kind: 'empty', message: 'Open any file, URL, agent, ...' } }
]
}
const explicitUrl = classifyExplicitUrl(trimmed)
@@ -895,9 +895,6 @@ export function connectPanePty(
})
commandLifecycle.attachXtermConsumer(pane.terminal)
const onTerminalKeyDown = (event: KeyboardEvent): void => {
deps.clearTerminalTabUnread(deps.tabId)
deps.clearTerminalPaneUnread(cacheKey)
deps.clearWorktreeUnread(deps.worktreeId)
if (isPlainEscapeKeyEvent(event)) {
setPendingTerminalInputIntent('plain-escape')
return
@@ -908,6 +905,30 @@ export function connectPanePty(
}
setPendingTerminalInputIntent('ctrl-c')
}
// Why: only treat keydowns that will produce real terminal input as the
// "user is here" signal. Modifier-only presses, autorepeat, and Cmd/Ctrl+C
// copy chords with an active selection must not dismiss attention on a
// sibling pane before the user has seen it.
if (
event.repeat ||
event.key === 'Alt' ||
event.key === 'AltGraph' ||
event.key === 'Control' ||
event.key === 'Meta' ||
event.key === 'Shift'
) {
return
}
if (
(event.metaKey || event.ctrlKey) &&
event.key.toLowerCase() === 'c' &&
pane.terminal.hasSelection()
) {
return
}
deps.clearTerminalTabUnread(deps.tabId)
deps.clearTerminalPaneUnread(cacheKey)
deps.clearWorktreeUnread(deps.worktreeId)
}
// Why: infer only from focused xterm key events. Raw PTY bytes cannot
// distinguish plain Escape from Alt/meta sequences, and programmatic writes
@@ -0,0 +1,92 @@
import React from 'react'
import { AlertTriangle, GitBranch, Loader2, RotateCcw, X } from 'lucide-react'
import { useAppStore } from '@/store'
import { retryBackgroundWorktreeCreation } from '@/lib/worktree-creation-flow'
import { getCreationProgressLabel } from '@/lib/pending-worktree-creation'
/**
* In-frame creation state, shown in the workspace content area while a worktree
* is being created. Presented as a faux tab: a tab strip carrying the new
* worktree's name (the title) over a body that holds the live status. This lets
* the in-progress create read as a real workspace tab whose content is loading,
* so the handoff to the real terminal is a same-frame swap — and the title
* (name) and the body status never duplicate each other. Its appearance is
* debounced upstream so fast creates never paint it.
*/
export default function WorktreeCreationPanel({
creationId
}: {
creationId: string
}): React.JSX.Element | null {
const entry = useAppStore((s) => s.pendingWorktreeCreations[creationId])
if (!entry) {
return null
}
const dismiss = (): void => useAppStore.getState().removePendingWorktreeCreation(creationId)
const isError = entry.status === 'error'
const title = entry.request.displayName || entry.request.name
return (
<div className="absolute inset-0 flex flex-col bg-background">
{/* Faux tab strip: mirrors the real tab row (height, border, bg) so the
create reads as a workspace tab. Carries only the worktree name + a
cancel control — the live status lives in the body below. */}
<div className="flex h-[36px] shrink-0 items-stretch border-b border-border bg-card">
<div className="flex h-full max-w-[240px] items-center gap-1.5 border-r border-border px-2.5 text-xs">
{isError ? (
<AlertTriangle className="size-3.5 shrink-0 text-destructive" />
) : (
// Why: a static worktree glyph (not a spinner) keeps the tab reading
// as a normal tab; the single loading spinner lives in the body.
<GitBranch className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate font-medium text-foreground">{title}</span>
<button
type="button"
title="Cancel"
aria-label="Cancel worktree creation"
onClick={dismiss}
className="flex size-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="size-3" />
</button>
</div>
</div>
{/* Body: a quiet top-left annotation on the surface the terminal will
fill — the same spot terminal output appears — so creation → terminal
reads as one frame filling in. */}
<div className="min-h-0 flex-1 p-3">
{isError ? (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span className="font-medium text-destructive">Couldn’t create worktree</span>
<span className="text-muted-foreground">
{entry.error ?? 'Something went wrong while creating the worktree.'}
</span>
<button
type="button"
onClick={() => retryBackgroundWorktreeCreation(creationId)}
className="inline-flex items-center gap-1 text-foreground hover:underline"
>
<RotateCcw className="size-3" />
Retry
</button>
<button
type="button"
onClick={dismiss}
className="text-muted-foreground hover:text-foreground hover:underline"
>
Dismiss
</button>
</div>
) : (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 shrink-0 animate-spin" />
<span>{getCreationProgressLabel(entry)}</span>
</div>
)}
</div>
</div>
)
}
+37 -80
View File
@@ -14,8 +14,9 @@ import {
normalizeGitHubLinkQuery
} from '@/lib/github-links'
import { activateAndRevealWorktree, type AgentStartedTelemetry } from '@/lib/worktree-activation'
import { runBackgroundWorktreeCreation } from '@/lib/worktree-creation-flow'
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../shared/tui-agent-selection'
import { tuiAgentToAgentKind } from '@/lib/telemetry'
import { isGitRepoKind } from '../../../shared/repo-kind'
@@ -2292,91 +2293,50 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
...(quickTelemetry ? { telemetry: quickTelemetry } : {})
}
: undefined
const result = await createWorktree(
const request: WorktreeCreationRequest = {
repoId,
workspaceName,
selectedRepoIsGit ? baseBranch : undefined,
effectiveSetupDecision,
selectedRepoIsGit && sparseEnabled
name: workspaceName,
...(createDisplayName ? { displayName: createDisplayName } : {}),
...(selectedRepoIsGit && baseBranch ? { baseBranch } : {}),
setupDecision: effectiveSetupDecision,
...(selectedRepoIsGit && sparseEnabled
? {
directories: normalizedSparseDirectories,
...(effectivePresetId ? { presetId: effectivePresetId } : {})
}
: undefined,
telemetrySource,
createDisplayName,
submitLinkedIssueNumber ?? undefined,
submitLinkedPR ?? undefined,
pushTarget,
agent ?? undefined,
linkedLinearIssue,
effectiveBranchNameOverride,
resolvedInitialWorkspaceStatus,
linkedGitLabMR ?? undefined,
linkedGitLabIssue ?? undefined,
backendStartup,
pendingFirstAgentMessageRename
)
const worktree = result.worktree
await applyWorktreeMeta(worktree.id, trimmedNote ? { comment: trimmedNote } : {})
// Why: agents that gate first-launch behind a "Do you trust this
// folder?" menu (cursor-agent, copilot) consume the bracketed paste
// as menu input. Pre-write the trust artifact so the menu is
// skipped — best-effort, errors swallowed by main. Guard the IPC
// presence so a stale preload bundle doesn't crash the launch with
// "Cannot read properties of undefined".
if (agent && worktree.path && window.api.agentTrust?.markTrusted) {
const preflight = TUI_AGENT_CONFIG[agent].preflightTrust
if (preflight) {
try {
await window.api.agentTrust.markTrusted({
preset: preflight,
workspacePath: worktree.path
})
} catch {
// Best-effort: continue with launch.
}
}
}
const backendSpawnedStartup = result.startupTerminal?.spawned === true
const activation = activateAndRevealWorktree(worktree.id, {
sidebarRevealBehavior: 'auto',
setup: result.setup,
defaultTabs: result.defaultTabs,
...(startupPlan && !backendSpawnedStartup
? {
startup: {
command: startupPlan.launchCommand,
...(startupPlan.env ? { env: startupPlan.env } : {}),
...(agent === 'command-code' && quickPrompt.trim().length > 0
? {
initialAgentStatus: {
agent,
prompt: quickPrompt.trim()
}
}
: {}),
...(quickTelemetry ? { telemetry: quickTelemetry } : {})
sparseCheckout: {
directories: normalizedSparseDirectories,
...(effectivePresetId ? { presetId: effectivePresetId } : {})
}
}
: {})
})
if (startupPlan && !backendSpawnedStartup) {
void ensureAgentStartupInTerminal({
worktreeId: worktree.id,
primaryTabId: activation === false ? null : activation.primaryTabId,
startup: startupPlan
})
: {}),
...(telemetrySource ? { telemetrySource } : {}),
...(submitLinkedIssueNumber != null ? { linkedIssue: submitLinkedIssueNumber } : {}),
...(submitLinkedPR != null ? { linkedPR: submitLinkedPR } : {}),
...(pushTarget ? { pushTarget } : {}),
agent,
...(linkedLinearIssue ? { linkedLinearIssue } : {}),
...(effectiveBranchNameOverride
? { branchNameOverride: effectiveBranchNameOverride }
: {}),
...(resolvedInitialWorkspaceStatus
? { workspaceStatus: resolvedInitialWorkspaceStatus }
: {}),
...(linkedGitLabMR != null ? { linkedGitLabMR } : {}),
...(linkedGitLabIssue != null ? { linkedGitLabIssue } : {}),
...(backendStartup ? { startup: backendStartup } : {}),
pendingFirstAgentMessageRename,
note: trimmedNote,
startupPlan,
quickPrompt,
quickTelemetry
}
setSidebarOpen(true)
// Why: git fetch + `git worktree add` can take 10–15s; holding the modal
// hostage to that made it feel frozen, so hand off to a background flow and
// close the modal immediately.
if (persistDraft) {
clearNewWorkspaceDraft()
}
onCreated?.()
queueNewWorkspaceTerminalFocus(worktree.id, activation)
runBackgroundWorktreeCreation(request)
} catch (error) {
const formattedError = formatWorkspaceCreateError(error)
setCreateError(formattedError)
@@ -2387,12 +2347,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
},
[
agentPrompt,
applyWorktreeMeta,
baseBranch,
branchNameOverride,
branchNameOverridePreservesNameEdits,
clearNewWorkspaceDraft,
createWorktree,
fallbackCreatureName,
effectiveLinkedPR,
linkedGitLabIssue,
@@ -2417,7 +2375,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
settings?.agentCmdOverrides,
settings?.autoRenameBranchFromWork,
disabledTuiAgents,
setSidebarOpen,
setupDecision,
sparseEnabled,
sparseError,
+12
View File
@@ -859,6 +859,18 @@ export function useIpcEvents(): void {
})
)
// Why: drive each background creation's status panel by routing the main
// process's two-phase progress to its pending entry via the correlation id.
// Guarded with `?.` so a stale preload bundle doesn't crash the listener set.
unsubs.push(
window.api.worktrees.onCreateProgress?.((data) => {
if (!data.creationId) {
return
}
useAppStore.getState().updatePendingWorktreeCreation(data.creationId, { phase: data.phase })
}) ?? (() => {})
)
if (window.api.gh?.onPRRefreshEvent) {
unsubs.push(
window.api.gh.onPRRefreshEvent((event) => {
+7 -1
View File
@@ -31,6 +31,12 @@ export const AGENT_CATALOG: AgentCatalogEntry[] = [
cmd: 'claude',
homepageUrl: 'https://docs.anthropic.com/claude/docs/claude-code'
},
{
id: 'claude-agent-teams',
label: 'Claude Agent Teams',
cmd: 'orca claude-teams',
homepageUrl: 'https://code.claude.com/docs/agent-teams'
},
{
id: 'openclaude',
label: 'OpenClaude',
@@ -261,7 +267,7 @@ export function AgentIcon({
if (!agent) {
return <AgentLetterIcon letter="?" size={size} />
}
if (agent === 'claude') {
if (agent === 'claude' || agent === 'claude-agent-teams') {
return <ClaudeIcon size={size} />
}
if (agent === 'codex') {
+1
View File
@@ -151,6 +151,7 @@ export function formatAgentTypeLabel(agentType: AgentType | null | undefined): s
// would silently accept a subset of the union.
const ICONABLE_AGENT_TYPES: Record<TuiAgent, true> = {
claude: true,
'claude-agent-teams': true,
openclaude: true,
codex: true,
autohand: true,
@@ -124,7 +124,10 @@ export function agentHasOrchestrationSkill(
}
export function sortOrchestrationAgents(agents: readonly TuiAgent[]): TuiAgent[] {
const order = new Map(TUI_AGENT_AUTO_PICK_ORDER.map((agent, index) => [agent, index]))
const order = new Map<TuiAgent, number>()
for (const [index, agent] of TUI_AGENT_AUTO_PICK_ORDER.entries()) {
order.set(agent, index)
}
return [...agents].sort(
(left, right) =>
(order.get(left) ?? Number.MAX_SAFE_INTEGER) - (order.get(right) ?? Number.MAX_SAFE_INTEGER)
@@ -0,0 +1,90 @@
import type {
CreateSparseCheckoutRequest,
GitPushTarget,
SetupDecision,
TuiAgent,
WorkspaceCreateTelemetrySource,
WorkspaceStatus,
WorktreeStartupLaunch
} from '../../../shared/types'
import type { AgentStartupPlan } from '@/lib/tui-agent-startup'
import type { AgentStartedTelemetry } from '@/lib/worktree-activation'
/** Two-phase status reported by the main process while a worktree is created.
* `fetching` covers the base-ref git fetch; `creating` covers `git worktree
* add`. The remote/runtime path emits neither, so consumers must tolerate a
* phase that never advances past `fetching`. */
export type WorktreeCreationPhase = 'fetching' | 'creating'
/**
* Everything needed to run a worktree create in the background and reproduce it
* verbatim on retry. Captured at the composer's submit cut point — after all
* interactive preflight (trust/setup decisions) has resolved — so the modal can
* close immediately and the work outlives it. Must stay plain-serializable
* (no closures/refs) so a pending entry can hold it for the panel's Retry.
*/
export type WorktreeCreationRequest = {
repoId: string
name: string
displayName?: string
baseBranch?: string
setupDecision: SetupDecision
sparseCheckout?: CreateSparseCheckoutRequest
telemetrySource?: WorkspaceCreateTelemetrySource
linkedIssue?: number
linkedPR?: number
pushTarget?: GitPushTarget
agent: TuiAgent | null
linkedLinearIssue?: string
branchNameOverride?: string
workspaceStatus?: WorkspaceStatus
linkedGitLabMR?: number
linkedGitLabIssue?: number
/** Backend-spawn startup payload (`createWorktree` arg). Present only when the
* agent launch is self-contained; otherwise the renderer drives startup via
* `startupPlan`. */
startup?: WorktreeStartupLaunch
pendingFirstAgentMessageRename: boolean
/** Post-create note persisted as the worktree comment. */
note: string
/** Renderer-side launch plan used to seed the first terminal when the backend
* did not already spawn it. Null for blank-shell creates. */
startupPlan: AgentStartupPlan | null
quickPrompt: string
quickTelemetry: AgentStartedTelemetry | null
}
/** Renderer-only, session-ephemeral record of an in-flight (or failed) worktree
* creation. Drives the sidebar strip and the in-tab creation panel. Never
* persisted — an app reload drops it and the worktree (if main finished it)
* reconciles in via the normal `worktrees:changed` refresh. Display fields
* (name, repo, agent) live on `request`, the single source of truth reused on
* retry. */
export type PendingWorktreeCreation = {
creationId: string
phase: WorktreeCreationPhase
status: 'creating' | 'error'
/** True when the create runs over a remote/runtime target that emits no phase
* progress — the panel shows a single indeterminate spinner rather than a
* stepped checklist that would freeze on the first step. */
indeterminate: boolean
/** Gates the in-frame loader so fast creates never flash it: false until the
* create has been pending past the debounce delay (or it errors). Until then
* the prior workspace content stays visible and a fast create swaps straight
* to its terminal. */
loaderVisible: boolean
error?: string
request: WorktreeCreationRequest
}
/** Human-readable progress line for an in-flight create, shared by the in-frame
* loader and the sidebar row so the two never drift. Caller handles the error
* case; this only covers the in-progress states. */
export function getCreationProgressLabel(
entry: Pick<PendingWorktreeCreation, 'phase' | 'indeterminate'>
): string {
if (entry.indeterminate) {
return 'Setting up your workspace…'
}
return entry.phase === 'creating' ? 'Creating worktree…' : 'Fetching base branch…'
}
@@ -14,8 +14,11 @@ describe('pickQuickWorkspaceAgent', () => {
it('uses the first enabled catalog agent while detection is pending', () => {
expect(pickQuickWorkspaceAgent(null, null, [])).toBe('claude')
expect(pickQuickWorkspaceAgent(null, null, ['claude'])).toBe('openclaude')
expect(pickQuickWorkspaceAgent(null, null, ['claude', 'openclaude'])).toBe('codex')
expect(pickQuickWorkspaceAgent(null, null, ['claude'])).toBe('claude-agent-teams')
expect(pickQuickWorkspaceAgent(null, null, ['claude', 'claude-agent-teams'])).toBe('openclaude')
expect(
pickQuickWorkspaceAgent(null, null, ['claude', 'claude-agent-teams', 'openclaude'])
).toBe('codex')
})
it('respects blank and disabled preferred agents', () => {

Some files were not shown because too many files have changed in this diff Show More