mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
Merge branch 'main' into improve-pr-actions-customization
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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/**')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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(() => {})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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 = {}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
+46
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user