Merge remote-tracking branch 'origin/main' into brennanb2025/fix-run-binding-loss

This commit is contained in:
Brennan Benson
2026-08-27 19:43:24 -07:00
68 changed files with 3960 additions and 565 deletions
+22 -3
View File
@@ -17,6 +17,10 @@ on:
description: JSON array of changed specs; empty runs the full suite
required: false
type: string
ssh_source_changed:
description: '"true" when the PR touches SSH execution source; gates the Docker-SSH lane'
required: false
type: string
workflow_dispatch:
inputs:
ref:
@@ -221,14 +225,21 @@ jobs:
ssh-docker-watcher-isolation:
name: ssh docker watcher isolation
needs: build
# Why ssh_source_changed first: this lane used to trigger on SSH source only as a side
# effect of one route listing a startup-readiness spec — pruning that spec would have
# silently retired the whole lane. The signal is now derived from the SSH routes directly.
# The two spec clauses stay for their honest purpose: changed-e2e hands these specs to this
# lane, so editing one must still run it here.
if: >-
inputs.test_files == '' ||
inputs.ssh_source_changed == 'true' ||
contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/paired-startup-exec-readiness.spec.ts')
runs-on: ubuntu-latest
# Why 35: the parking, retention, startup-exec, and paired parity specs run
# serially on isolated Electron/SSH fixtures after watcher isolation.
timeout-minutes: 35
# Why 60: this lane now also runs the remaining Docker-SSH specs serially. They average
# ~18s but several budget 4-10 minutes per test, so a slow run lands far above the old 35
# — and the sharded lanes already show that a lane which times out is a lane nobody trusts.
timeout-minutes: 60
steps:
- name: Checkout
@@ -260,6 +271,14 @@ jobs:
if: always()
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking
# Why here rather than the sharded lanes: the shards set no ORCA_E2E_SSH_DOCKER, so every
# spec below skipped itself while the shard still reported green. Running them on this one
# VM pays the fixture image build once instead of ten times, and keeps an SSH regression
# legible as an SSH-named failure.
- name: Run remaining Docker SSH E2E
if: always()
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker
- name: Upload watcher isolation traces
if: failure()
uses: actions/upload-artifact@v7
+7
View File
@@ -804,6 +804,7 @@ jobs:
outputs:
should_run: ${{ steps.filter.outputs.should_run }}
test_files: ${{ steps.filter.outputs.test_files }}
ssh_source_changed: ${{ steps.filter.outputs.ssh_source_changed }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -826,6 +827,11 @@ jobs:
# authorities, exclusions, and sentinels without evaluating workflow shell.
TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)"
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
# Why a separate signal: the Docker-SSH lane must trigger on SSH source, not on a
# spec name surviving in a route's list. Same routes, so the two cannot drift.
SSH_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --ssh-source)"
echo "ssh_source_changed=$SSH_SOURCE_CHANGED" >> "$GITHUB_OUTPUT"
echo "SSH source changed: $SSH_SOURCE_CHANGED"
if [ "$TEST_FILES_JSON" != '[]' ]; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "Changed E2E specs: $TEST_FILES_JSON"
@@ -844,6 +850,7 @@ jobs:
uses: ./.github/workflows/e2e.yml
with:
test_files: ${{ needs.e2e-paths.outputs.test_files }}
ssh_source_changed: ${{ needs.e2e-paths.outputs.ssh_source_changed }}
verify:
if: always()
+157 -2
View File
@@ -1,9 +1,14 @@
import { existsSync, readFileSync } from 'node:fs'
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { parse as parseJsonc } from 'jsonc-parser'
import { describe, expect, it } from 'vitest'
import { parse as parseYaml } from 'yaml'
import { PR_E2E_SOURCE_ROUTES, selectPrE2eSpecs } from './pr-e2e-source-routing.mjs'
import {
hasSshSourceChange,
PR_E2E_SOURCE_ROUTES,
selectPrE2eSpecs,
SSH_SOURCE_ROUTE_IDS
} from './pr-e2e-source-routing.mjs'
const projectDir = resolve(import.meta.dirname, '../..')
const prWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
@@ -209,6 +214,8 @@ describe('PR E2E gate contract', () => {
'src/main/providers/ssh-',
'src/main/ipc/pty',
'src/relay/',
'src/shared/ssh-',
'src/renderer/src/store/slices/direct-ssh-',
'src/renderer/src/components/terminal-pane/remote-runtime-'
]
for (const authority of sshSourceAuthorities) {
@@ -217,10 +224,24 @@ describe('PR E2E gate contract', () => {
)
}
// Why named files rather than prefixes: these seams are single modules, and a prefix
// here would route their unrelated neighbours.
for (const file of [
'src/main/runtime/public-ssh-state.ts',
'src/renderer/src/startup/ssh-startup-reconnect.ts',
'src/renderer/src/store/slices/ssh.ts'
]) {
expect(selectPrE2eSpecs([file]), file).toContain(
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts'
)
}
const mappedSpecs = [
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
]
@@ -249,6 +270,140 @@ describe('PR E2E gate contract', () => {
expect(changedInstall.run).toContain('openssh-client')
})
it('routes direct-SSH workspace and tab restore from its unnamed source seams', () => {
// Why by name: none of these carry "ssh", so the SSH authorities above never reach them
// — a closed-tab tombstone and a dropped default-tabs marker both shipped through it.
for (const file of [
'src/renderer/src/hooks/remote-workspace-session-merge.ts',
'src/main/ipc/remote-workspace-snapshot-normalization.ts',
'src/renderer/src/lib/worktree-initial-terminal-seeding.ts',
'src/renderer/src/lib/worktree-default-terminal-tabs.ts',
'src/shared/remote-workspace-session-projection.ts',
'src/renderer/src/components/terminal/initial-terminal.ts'
]) {
const specs = selectPrE2eSpecs([file])
expect(specs, file).toContain('tests/e2e/ssh-cold-activation-restore.spec.ts')
expect(specs, file).toContain('tests/e2e/ssh-reconnect-tab-destruction.spec.ts')
}
expect(
selectPrE2eSpecs(['src/renderer/src/hooks/remote-workspace-session-merge.test.ts'])
).toEqual([])
})
it('triggers the Docker-SSH lane from SSH source, not from a spec name', () => {
// The behavioural half of the invariant, and the part that actually matters: an SSH source
// edit is recognised as one, through the same routes that select the specs.
for (const file of [
'src/main/ssh/connection.ts',
'src/relay/pty-handler.ts',
'src/renderer/src/store/slices/direct-ssh-pane-retry-ledger.ts',
'src/renderer/src/hooks/remote-workspace-session-merge.ts',
'src/main/ipc/remote-workspace-snapshot-normalization.ts'
]) {
expect(hasSshSourceChange([file]), file).toBe(true)
}
for (const file of [
'src/main/git/git-status.ts',
'src/renderer/src/components/tab-bar/BrowserTab.tsx',
'src/main/ssh/connection.test.ts'
]) {
expect(hasSshSourceChange([file]), file).toBe(false)
}
// Why: the signal must stay derived from the routes. A route id that no longer exists would
// silently narrow it to nothing.
for (const id of SSH_SOURCE_ROUTE_IDS) {
expect(
PR_E2E_SOURCE_ROUTES.map((route) => route.id),
id
).toContain(id)
}
// Why text and not structure: a job `if:` is only ever available as a string. The strongest
// available assertion is that the source signal is its own disjunct, so the lane no longer
// depends on a spec name surviving in a route's spec list.
const sshLaneCondition = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if
expect(sshLaneCondition).toContain("inputs.ssh_source_changed == 'true' ||")
expect(e2eWorkflow.on.workflow_call.inputs.ssh_source_changed.type).toBe('string')
expect(prWorkflow.jobs['e2e-paths'].outputs.ssh_source_changed).toBe(
'${{ steps.filter.outputs.ssh_source_changed }}'
)
expect(prWorkflow.jobs.e2e.with.ssh_source_changed).toBe(
'${{ needs.e2e-paths.outputs.ssh_source_changed }}'
)
expect(filterStep.run).toContain('pr-e2e-source-routing.mjs --ssh-source')
expect(filterStep.run).toContain('ssh_source_changed=$SSH_SOURCE_CHANGED')
})
it('gives every Docker-gated SSH spec a lane that runs it', () => {
// Why this shape: the sharded lanes set no ORCA_E2E_SSH_DOCKER, so a Docker-gated spec
// that no runner names runs nowhere and still reports green — the silent skip this file
// exists to prevent. Asserting reachability rather than a literal keeps that true when
// the lanes move.
// Why these two are exempt: each needs something CI cannot give it, recorded in
// run-ssh-docker-e2e.mjs so the gap stays legible rather than looking like coverage.
const unreachableSpecs = new Set([
'tests/e2e/ssh-docker-relay-perf.spec.ts',
'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts',
'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts'
])
// Why comments are stripped: this file's own runner lists the two exempt specs by name in a
// prose comment. A substring scan over raw text would count any spec merely *discussed* in a
// runner as claimed by it -- the silent skip this assertion exists to catch, re-entering
// through the documentation.
const stripComments = (text) =>
text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
const laneRunners = [
'run-ssh-docker-e2e.mjs',
'run-ssh-docker-watcher-isolation-e2e.mjs',
'run-ssh-docker-terminal-parking-e2e.mjs'
].map((file) => stripComments(readFileSync(join(projectDir, 'config/scripts', file), 'utf8')))
// Why a comparison and not the bare name: preview and demo specs cite the flag in a
// "how to run me" comment without gating on it. Why a regex rather than one literal: an
// equally-valid spelling (double quotes, or a `!==` guard) would escape a fixed-string scan
// and the spec would silently leave the contract.
const dockerGateExpression = /ORCA_E2E_SSH_DOCKER\s*[!=]==\s*['"]1['"]/
const dockerGatedSpecs = readdirSync(join(projectDir, 'tests/e2e'))
.filter((file) => file.endsWith('.spec.ts'))
.map((file) => `tests/e2e/${file}`)
.filter((spec) => dockerGateExpression.test(readFileSync(join(projectDir, spec), 'utf8')))
expect(dockerGatedSpecs.length).toBeGreaterThan(0)
const unclaimed = dockerGatedSpecs.filter(
(spec) => !unreachableSpecs.has(spec) && !laneRunners.some((runner) => runner.includes(spec))
)
expect(
unclaimed,
`Docker-gated specs claimed by no lane runner: ${unclaimed.join(', ')}`
).toEqual([])
// Why: an exemption that outlives its spec would quietly excuse a real gap.
for (const spec of unreachableSpecs) {
expect(dockerGatedSpecs, spec).toContain(spec)
// Why also assert absence from every runner: `unreachableSpecs` short-circuits the
// unclaimed check above, so a spec could be documented as exempt while a runner still
// invokes it -- an exemption that reads as coverage removal but changes nothing, and a
// lane that stays red for a reason the file says it excluded.
for (const runner of laneRunners) {
expect(runner.includes(spec), `${spec} is exempt but still invoked by a lane runner`).toBe(
false
)
}
}
const laneStep = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].steps.find(
(step) => step.name === 'Run remaining Docker SSH E2E'
)
expect(laneStep.run).toContain('test:e2e:ssh-docker')
// Why: the added serial tests, several budgeting 4-10 minutes each, do not fit the old 35.
expect(
e2eWorkflow.jobs['ssh-docker-watcher-isolation']['timeout-minutes']
).toBeGreaterThanOrEqual(60)
})
it('scopes the VM rollback oracle to the PR range and recipe schema authorities', () => {
expect(rollbackStep.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
expect(rollbackStep.run).toContain('src/shared/ephemeral-vm-recipes.ts')
+39 -3
View File
@@ -18,12 +18,32 @@ export const PR_E2E_SOURCE_ROUTES = [
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
],
// Why the store/startup/shared additions: the SSH-named authorities stop at the main
// process and the pane component, but the reconnect ledgers and retained-payload
// admission that decide whether a pane rebinds live in the renderer store.
matches: (file) =>
isProductSource(file) &&
/^(?:src\/main\/ssh\/|src\/main\/providers\/ssh-|src\/main\/ipc\/(?:ssh-|pty)|src\/relay\/|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test(
/^(?:src\/main\/ssh\/|src\/main\/providers\/ssh-|src\/main\/ipc\/(?:ssh-|pty)|src\/main\/runtime\/(?:public-ssh-state|ssh-file-explorer-chunk-read)\.ts|src\/relay\/|src\/shared\/(?:ssh-|skill-ssh-relay-contract)|src\/renderer\/src\/startup\/(?:ssh-startup-reconnect|startup-ssh-connection-restore)\.ts|src\/renderer\/src\/store\/slices\/(?:ssh|direct-ssh-)|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test(
file
)
},
{
// Why a sibling route rather than more paths on ssh-terminal-source: these modules carry
// no "ssh" in their names, and only the two restore specs gate them. Folding them in
// would run the whole SSH terminal list for a tab-tombstone edit.
id: 'ssh-workspace-session-restore',
specs: [
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts'
],
matches: (file) =>
isProductSource(file) &&
/^(?:src\/main\/ipc\/remote-workspace|src\/shared\/remote-workspace-|src\/renderer\/src\/hooks\/remote-workspace-|src\/renderer\/src\/lib\/worktree-(?:initial-terminal-seeding|default-terminal-tabs)\.ts|src\/renderer\/src\/components\/terminal\/initial-terminal)/.test(
file
)
},
@@ -131,6 +151,18 @@ export function selectPrE2eSpecs(changedPaths, reportRoute = () => undefined) {
return [...specs].sort((left, right) => left.localeCompare(right))
}
/** Routes whose authorities are SSH execution source, and so require the Docker-SSH lane. */
export const SSH_SOURCE_ROUTE_IDS = ['ssh-terminal-source', 'ssh-workspace-session-restore']
// Why derive this from the routes instead of a second path list: the Docker-SSH lane used to
// trigger only because one route happened to list a startup-readiness spec, so pruning that
// spec would have silently retired the lane. Two lists that must agree is how that drifted.
export function hasSshSourceChange(changedPaths) {
return PR_E2E_SOURCE_ROUTES.filter((route) => SSH_SOURCE_ROUTE_IDS.includes(route.id)).some(
(route) => changedPaths.some(route.matches)
)
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
let input = ''
process.stdin.setEncoding('utf8')
@@ -138,6 +170,10 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
input += chunk
}
const changedPaths = input.split(/\r?\n/).filter(Boolean)
const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message))
process.stdout.write(`${JSON.stringify(specs)}\n`)
if (process.argv.includes('--ssh-source')) {
process.stdout.write(`${hasSshSourceChange(changedPaths)}\n`)
} else {
const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message))
process.stdout.write(`${JSON.stringify(specs)}\n`)
}
}
+93
View File
@@ -0,0 +1,93 @@
import { spawnSync } from 'node:child_process'
const rawExtraArgs = process.argv.slice(2)
const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const env = {
...process.env,
ORCA_E2E_SSH_DOCKER: '1',
ORCA_E2E_WEB_CLIENT: '1'
}
// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
const spawnOptions = {
stdio: 'inherit',
env,
shell: process.platform === 'win32'
}
const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
if (runtime.status !== 0) {
process.exit(runtime.status ?? 1)
}
// Why one explicit list: these specs self-skip without ORCA_E2E_SSH_DOCKER and no sharded lane
// sets it, so a spec in no runner runs nowhere. The gate contract proves every flag-reading
// spec is claimed here, by the watcher-isolation or parking runner, or by a listed exclusion.
//
// Deliberately absent, and therefore still covered by no CI trigger:
// ssh-docker-relay-perf.spec.ts — wall-clock latency thresholds; flaky budgets here would
// cost the lane its credibility. NOTE: a runner script test:e2e:ssh-docker-perf exists in
// package.json but NO workflow invokes it, so this spec currently runs in no CI lane at
// all. Recorded as a real gap, not as coverage living somewhere else.
// ssh-codex-display-artifacts-repro.spec.ts — installs a real remote codex binary that CI
// runners do not have (observed as `spawn codex ENOENT`). Runs in no CI lane at all.
// ssh-docker-bulk-open-freeze-repro.spec.ts — two reasons, both disqualifying:
// (a) it is a perf oracle, not a correctness one: SOFT_FREEZE_LAG_MS=2500 /
// HARD_FREEZE_LAG_MS=5000 measured by a renderer lag probe under a deliberate
// 5-pane output flood on a 420s budget. Same rule as ssh-docker-relay-perf above.
// (b) it is ROTTED: four call sites are out of date against terminal.ts's current
// helpers — execInTerminal gained a ptyId parameter and splitActiveTerminalPane
// gained a direction, so it cannot compile, let alone pass. Repairing it needs two
// semantic decisions (which ptyId to capture, which split direction) that change
// what the repro measures. Tracked in stablyai/orca#16764.
//
// Why both projects: ssh-port-forward-lifecycle is @headful, which the headless project
// grep-inverts away.
//
// Known gaps in SSH e2e coverage, recorded here because nothing else names them:
// - The job that runs this is still called `ssh-docker-watcher-isolation`, though watcher
// isolation is now one spec of many. Renaming it changes the GitHub check name and can
// break required-check config, so the name understates the job on purpose.
// - E2E does not gate merges: `verify.needs` in pr.yml omits `e2e` while the suite is red on
// main. Nothing in this lane blocks a PR yet. pr.yml's Require-successful-checks comment
// has the exact wiring to flip it, and the gate contract asserts the current state.
// - Five specs and one unit test are gated on env vars no workflow sets, so they run nowhere
// and are not Docker-gated, which puts them outside this file's contract:
// local-ssh-browser-routing (ORCA_E2E_LOCAL_SSH_BROWSER)
// ssh-client-hosted-browser-drop-reconnect (ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER)
// nested-runtime-ssh-lifecycle, nested-runtime-ssh-routing (ORCA_E2E_NESTED_RUNTIME_SSH)
// ssh-localhost (ORCA_E2E_SSH_LOCALHOST)
// ssh-browser-network-execution-route.docker.unit.test.ts (ORCA_RUN_DOCKER_SSH_BROWSER_E2E)
// Runner scripts for the first four sit unused in package.json; no workflow calls them.
const result = spawnSync(
pnpm,
[
'exec',
'playwright',
'test',
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
'tests/e2e/ssh-ai-vault-session-history.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
'tests/e2e/ssh-external-image-preview.spec.ts',
'tests/e2e/ssh-pi-compatible-agent-title.spec.ts',
'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
'tests/e2e/ssh-restart-tab-accumulation.spec.ts',
'tests/e2e/ssh-skill-installation.spec.ts',
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'--project',
'electron-headful',
'--workers=1',
...extraArgs
],
spawnOptions
)
process.exit(result.status ?? 1)
+1
View File
@@ -111,6 +111,7 @@
"test:e2e:terminal-perf:html-report": "node config/scripts/generate-terminal-perf-html-report.mjs",
"test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs",
"test:e2e:ssh-docker-watcher-isolation": "node config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs",
"test:e2e:ssh-docker": "node config/scripts/run-ssh-docker-e2e.mjs",
"test:e2e:ssh-client-hosted-browser": "node config/scripts/run-ssh-client-hosted-browser-drop-reconnect-e2e.mjs",
"test:e2e:local-ssh-browser": "node config/scripts/run-local-ssh-browser-routing-e2e.mjs",
"test:e2e:ssh-docker-terminal-parking": "node config/scripts/run-ssh-docker-terminal-parking-e2e.mjs",
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import {
normalizeSnapshot,
remoteWorkspaceSessionMatchesSnapshot
} from './remote-workspace-snapshot-normalization'
import type {
RemoteWorkspaceSession,
RemoteWorkspaceSnapshot
} from '../../shared/remote-workspace-types'
function session(overrides: Partial<RemoteWorkspaceSession> = {}): RemoteWorkspaceSession {
return {
activeWorktreePath: null,
activeTabId: null,
tabsByWorktreePath: {},
terminalLayoutsByTabId: {},
...overrides
}
}
function snapshotOf(session: RemoteWorkspaceSession): RemoteWorkspaceSnapshot {
return { namespace: 'ns', revision: 3, updatedAt: 1, schemaVersion: 1, session }
}
/**
* Every declared field, each holding a value the normalizer must neither drop nor flatten.
*
* `Required` is the ratchet: normalizeRemoteSession rebuilds the session as an object literal, so a
* field added to the wire type and forgotten there is invisible — it round-trips through the
* projection, reaches the relay, and is stripped on the way back. Declaring the fixture Required
* makes the next such field a compile error here before it can become a silent data loss.
*/
const everyDeclaredField: Required<RemoteWorkspaceSession> = {
activeWorktreePath: '/repo',
activeTabId: 'tab-1',
tabsByWorktreePath: { '/repo': [{ id: 'tab-1', type: 'terminal', title: 'Shell' } as never] },
terminalLayoutsByTabId: { 'tab-1': { kind: 'leaf', ptyId: 'pty-1' } as never },
activeWorktreePathsOnShutdown: ['/repo'],
activeTabIdByWorktreePath: { '/repo': 'tab-1' },
remoteSessionIdsByTabId: { 'tab-1': 'session-1' },
lastVisitedAtByWorktreePath: { '/repo': 42 },
defaultTerminalTabsAppliedByWorktreePath: { '/repo': true }
}
describe('normalizeSnapshot: field coverage', () => {
it('copies every declared field of the wire session', () => {
const normalized = normalizeSnapshot(snapshotOf(everyDeclaredField), 'ns').session
expect(Object.keys(normalized).sort()).toEqual(Object.keys(everyDeclaredField).sort())
expect(normalized).toEqual(everyDeclaredField)
})
})
describe('normalizeSnapshot: defaultTerminalTabsAppliedByWorktreePath', () => {
it('carries the marker through instead of dropping it', () => {
// The renderer exports this field and its importer reads it, but the normalizer every read
// passes through never copied it — so the flag reached the relay and could never come back.
const raw = snapshotOf(session({ defaultTerminalTabsAppliedByWorktreePath: { '/repo': true } }))
expect(normalizeSnapshot(raw, 'ns').session.defaultTerminalTabsAppliedByWorktreePath).toEqual({
'/repo': true
})
})
it('treats an empty marker record as absent, like every other optional record', () => {
// The export always emits an object, so {} and absent both mean "no worktree has applied
// them". Collapsing them keeps a no-op export from looking like a change to the patch guard.
const raw = snapshotOf(session({ defaultTerminalTabsAppliedByWorktreePath: {} }))
expect(
normalizeSnapshot(raw, 'ns').session.defaultTerminalTabsAppliedByWorktreePath
).toBeUndefined()
})
})
describe('remoteWorkspaceSessionMatchesSnapshot', () => {
it('sees a change that only sets the default-terminal-tabs marker', () => {
// patchRemoteWorkspaceSession short-circuits on this comparison, so a field it cannot see is a
// field that can never be written at all.
expect(
remoteWorkspaceSessionMatchesSnapshot(
snapshotOf(session()),
session({ defaultTerminalTabsAppliedByWorktreePath: { '/repo': true } })
)
).toBe(false)
})
})
@@ -62,6 +62,11 @@ function normalizeRemoteSession(raw: unknown): RemoteWorkspaceSession {
),
lastVisitedAtByWorktreePath: normalizeOptionalRecord<Record<string, number>>(
input.lastVisitedAtByWorktreePath
),
// Dropping this made the field write-only: every read stripped it, and the patch guard below
// compares normalized sessions, so a change that only sets it looked like no change at all.
defaultTerminalTabsAppliedByWorktreePath: normalizeOptionalRecord<Record<string, true>>(
input.defaultTerminalTabsAppliedByWorktreePath
)
}
}
@@ -0,0 +1,265 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { CreateWorktreeArgs } from '../../shared/worktree/create-types'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import type { Worktree } from '../../shared/worktree/types'
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope'
import {
assertAttachableParentWorkspace,
recordWorkspaceLineageForCreatedWorktree
} from './worktree-remote'
const CHILD_ID = 'repo-1::/repos/child'
const PARENT_ID = 'repo-1::/repos/parent'
const CREATED_AT = 1_700_000_000_000
function createdWorktree(overrides: Partial<Worktree> = {}): Worktree {
return {
id: CHILD_ID,
instanceId: 'child-instance',
repoId: 'repo-1',
hostId: 'local',
projectId: 'project-1',
path: '/repos/child',
head: 'abc123',
branch: 'refs/heads/child',
isBare: false,
isMainWorktree: false,
displayName: 'child',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
...overrides
}
}
function parentMeta(overrides: Partial<WorktreeMeta> = {}): WorktreeMeta {
return {
instanceId: 'parent-instance',
hostId: 'local',
projectId: 'project-1',
displayName: 'parent',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
...overrides
}
}
function createStore(options: {
metaById?: Record<string, WorktreeMeta>
folderWorkspaceIds?: string[]
}) {
const metaById = options.metaById ?? {}
const folderWorkspaceIds = new Set(options.folderWorkspaceIds ?? [])
return {
getWorktreeMeta: vi.fn((id: string) => metaById[id]),
getFolderWorkspace: vi.fn((id: string) =>
folderWorkspaceIds.has(id) ? { id, path: `/folders/${id}` } : undefined
),
setWorktreeLineage: vi.fn((_id: string, lineage: unknown) => lineage),
setWorkspaceLineage: vi.fn((lineage: unknown) => lineage)
}
}
function record(
store: ReturnType<typeof createStore>,
args: Partial<CreateWorktreeArgs>,
worktree = createdWorktree()
) {
return recordWorkspaceLineageForCreatedWorktree(
store as never,
args as CreateWorktreeArgs,
worktree,
CREATED_AT
)
}
describe('recordWorkspaceLineageForCreatedWorktree', () => {
beforeEach(() => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('writes both lineage rows for a worktree parent inside the same repo/host/project', () => {
const store = createStore({ metaById: { [PARENT_ID]: parentMeta() } })
const result = record(store, { parentWorkspace: worktreeWorkspaceKey(PARENT_ID) })
expect(store.setWorktreeLineage).toHaveBeenCalledWith(CHILD_ID, {
worktreeId: CHILD_ID,
worktreeInstanceId: 'child-instance',
parentWorktreeId: PARENT_ID,
parentWorktreeInstanceId: 'parent-instance',
origin: 'manual',
capture: { source: 'manual-action', confidence: 'explicit' },
createdAt: CREATED_AT
})
expect(result.lineage).toMatchObject({
parentWorktreeId: PARENT_ID,
parentWorktreeInstanceId: 'parent-instance',
origin: 'manual',
capture: { source: 'manual-action', confidence: 'explicit' }
})
expect(result.workspaceLineage).toEqual({
childWorkspaceKey: worktreeWorkspaceKey(CHILD_ID),
childInstanceId: 'child-instance',
parentWorkspaceKey: worktreeWorkspaceKey(PARENT_ID),
parentInstanceId: 'parent-instance',
origin: 'manual',
capture: { source: 'manual-action', confidence: 'explicit' },
createdAt: CREATED_AT
})
})
it('skips both lineage records when the parent belongs to a different repo', () => {
const foreignParentId = 'repo-2::/repos/parent'
const store = createStore({ metaById: { [foreignParentId]: parentMeta() } })
const result = record(store, { parentWorkspace: worktreeWorkspaceKey(foreignParentId) })
expect(store.setWorktreeLineage).not.toHaveBeenCalled()
expect(result.lineage).toBeNull()
expect(store.setWorkspaceLineage).not.toHaveBeenCalled()
expect(result.workspaceLineage).toBeNull()
})
it.each([
['host', parentMeta({ hostId: 'ssh:other-host' })],
['project', parentMeta({ projectId: 'project-2' })]
])(
'skips both lineage records when the parent %s conflicts, so no cross-host row is persisted',
(_label, meta) => {
const store = createStore({ metaById: { [PARENT_ID]: meta } })
const result = record(store, { parentWorkspace: worktreeWorkspaceKey(PARENT_ID) })
expect(store.setWorktreeLineage).not.toHaveBeenCalled()
expect(result.lineage).toBeNull()
// A persisted cross-host row makes filterLineageForHost return null for the entire host.
expect(store.setWorkspaceLineage).not.toHaveBeenCalled()
expect(result.workspaceLineage).toBeNull()
}
)
it('skips worktree lineage when the parent has no instance identity', () => {
const store = createStore({ metaById: { [PARENT_ID]: parentMeta({ instanceId: undefined }) } })
const result = record(store, { parentWorkspace: worktreeWorkspaceKey(PARENT_ID) })
expect(store.setWorktreeLineage).not.toHaveBeenCalled()
expect(result.lineage).toBeNull()
expect(result.workspaceLineage).toMatchObject({
parentWorkspaceKey: worktreeWorkspaceKey(PARENT_ID),
parentInstanceId: null
})
})
it('records only workspace lineage for a folder-workspace parent', () => {
const store = createStore({ folderWorkspaceIds: ['folder-1'] })
const result = record(store, { parentWorkspace: folderWorkspaceKey('folder-1') })
expect(store.setWorktreeLineage).not.toHaveBeenCalled()
expect(result.lineage).toBeNull()
expect(result.workspaceLineage).toMatchObject({
parentWorkspaceKey: folderWorkspaceKey('folder-1'),
parentInstanceId: null,
capture: { source: 'active-workspace', confidence: 'explicit' }
})
})
it('records nothing without a parent workspace', () => {
const store = createStore({})
const result = record(store, {})
expect(store.setWorktreeLineage).not.toHaveBeenCalled()
expect(store.setWorkspaceLineage).not.toHaveBeenCalled()
expect(result).toEqual({ lineage: null, workspaceLineage: null })
})
it('records nothing when the parent workspace is the created worktree itself', () => {
const store = createStore({ metaById: { [CHILD_ID]: parentMeta() } })
const result = record(store, { parentWorkspace: worktreeWorkspaceKey(CHILD_ID) })
expect(store.setWorktreeLineage).not.toHaveBeenCalled()
expect(store.setWorkspaceLineage).not.toHaveBeenCalled()
expect(result).toEqual({ lineage: null, workspaceLineage: null })
})
it('records nothing when the parent worktree disappeared before the write', () => {
const store = createStore({})
const result = record(store, { parentWorkspace: worktreeWorkspaceKey(PARENT_ID) })
expect(store.setWorkspaceLineage).not.toHaveBeenCalled()
expect(result).toEqual({ lineage: null, workspaceLineage: null })
})
})
describe('assertAttachableParentWorkspace', () => {
const childKey = worktreeWorkspaceKey(CHILD_ID)
beforeEach(() => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
function assertParent(
store: ReturnType<typeof createStore>,
parentWorkspace: CreateWorktreeArgs['parentWorkspace']
) {
assertAttachableParentWorkspace(store as never, parentWorkspace, childKey)
}
// Why: the pick can go stale between the composer and the create; nesting is decoration, not the job.
it.each([
['worktree', () => worktreeWorkspaceKey(PARENT_ID)],
['folder', () => folderWorkspaceKey('folder-gone')]
])('degrades instead of failing when the %s parent disappeared', (_label, key) => {
const store = createStore({})
expect(() => assertParent(store, key())).not.toThrow()
expect(console.warn).toHaveBeenCalled()
})
it('accepts a parent that still exists', () => {
const store = createStore({ metaById: { [PARENT_ID]: parentMeta() } })
expect(() => assertParent(store, worktreeWorkspaceKey(PARENT_ID))).not.toThrow()
expect(console.warn).not.toHaveBeenCalled()
})
it('rejects a self-referential parent', () => {
const store = createStore({ metaById: { [CHILD_ID]: parentMeta() } })
expect(() => assertParent(store, childKey)).toThrow(/cannot be attached to itself/)
})
it('rejects a malformed parent key', () => {
const store = createStore({})
expect(() => assertParent(store, 'not-a-workspace-key' as never)).toThrow(
/Invalid parent workspace/
)
})
})
+116 -18
View File
@@ -91,6 +91,7 @@ import { findCreatedWorktree } from './created-worktree-reconciliation'
import type { BranchPrefixSettings } from '../../shared/branch-prefix'
import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope'
import { sharesWorktreeLineageBoundary } from '../../shared/resolved-worktree-lineage'
import {
cleanupUnusedWorktreePushTargetRemoteWithExec,
sameGitHubRemoteUrl,
@@ -197,7 +198,11 @@ function getSetupRunnerCommandPlatformForLaunch(
return getSetupRunnerCommandPlatformForPath(setup?.runnerScriptPath ?? '', fallbackPlatform)
}
function validateWorkspaceLineageParentBeforeCreate(
/** Why not throw on a missing parent: nesting is optional decoration, and the pick can go stale
* between the composer and the create. A malformed or self-referential key is a bad request and
* still fails; a parent that simply disappeared degrades to an unattached workspace, matching the
* runtime path and `recordWorkspaceLineageForCreatedWorktree`. */
export function assertAttachableParentWorkspace(
store: Store,
parentWorkspace: CreateWorktreeArgs['parentWorkspace'],
childWorkspaceKey: ReturnType<typeof worktreeWorkspaceKey>
@@ -213,51 +218,120 @@ function validateWorkspaceLineageParentBeforeCreate(
throw new Error(`Invalid parent workspace: ${parentWorkspace}`)
}
if (parentScope.type === 'folder' && !store.getFolderWorkspace(parentScope.folderWorkspaceId)) {
throw new Error(`Parent folder workspace not found: ${parentWorkspace}`)
console.warn(`[worktree-create] parent folder workspace not found: ${parentWorkspace}`)
return
}
if (parentScope.type === 'worktree' && !store.getWorktreeMeta(parentScope.worktreeId)) {
throw new Error(`Parent worktree workspace not found: ${parentWorkspace}`)
console.warn(`[worktree-create] parent worktree workspace not found: ${parentWorkspace}`)
}
}
function recordWorkspaceLineageForCreatedWorktree(
type CreatedWorktreeLineageRecords = {
lineage: CreateWorktreeResult['lineage']
workspaceLineage: CreateWorktreeResult['workspaceLineage']
}
const NO_CREATED_WORKTREE_LINEAGE: CreatedWorktreeLineageRecords = {
lineage: null,
workspaceLineage: null
}
/** Mirrors the projection's edge rule so we never persist a row the sidebar would silently drop.
* WorktreeMeta has no repoId, so the parent's comes from its `<repoId>::<path>` id. */
function createdWorktreeSharesParentLineageBoundary(
worktree: Worktree,
parentWorktreeId: string,
parentMeta: WorktreeMeta
): boolean {
return sharesWorktreeLineageBoundary(worktree, {
repoId: getRepoIdFromWorktreeId(parentWorktreeId),
hostId: parentMeta.hostId,
projectId: parentMeta.projectId
})
}
export function recordWorkspaceLineageForCreatedWorktree(
store: Store,
args: CreateWorktreeArgs,
worktree: Worktree,
createdAt: number
): CreateWorktreeResult['workspaceLineage'] {
): CreatedWorktreeLineageRecords {
if (!args.parentWorkspace || !worktree.instanceId) {
return null
return NO_CREATED_WORKTREE_LINEAGE
}
const childWorkspaceKey = worktreeWorkspaceKey(worktree.id)
if (args.parentWorkspace === childWorkspaceKey) {
console.warn(`[worktree-create] refusing to attach ${worktree.id} to itself`)
return null
return NO_CREATED_WORKTREE_LINEAGE
}
const parentScope = parseWorkspaceKey(args.parentWorkspace)
if (!parentScope) {
console.warn(`[worktree-create] ignoring invalid parent workspace ${args.parentWorkspace}`)
return null
return NO_CREATED_WORKTREE_LINEAGE
}
if (parentScope.type === 'folder' && !store.getFolderWorkspace(parentScope.folderWorkspaceId)) {
console.warn(`[worktree-create] parent folder workspace disappeared: ${args.parentWorkspace}`)
return null
return NO_CREATED_WORKTREE_LINEAGE
}
const parentWorktreeMeta =
parentScope.type === 'worktree' ? store.getWorktreeMeta(parentScope.worktreeId) : null
if (parentScope.type === 'worktree' && !parentWorktreeMeta) {
console.warn(`[worktree-create] parent worktree workspace disappeared: ${args.parentWorkspace}`)
return null
return NO_CREATED_WORKTREE_LINEAGE
}
return store.setWorkspaceLineage({
// Why: only a worktree parent produces sidebar nesting; a folder parent has no WorktreeLineage row.
let lineage: CreateWorktreeResult['lineage'] = null
let parentOutsideLineageBoundary = false
if (parentScope.type === 'worktree' && parentWorktreeMeta) {
if (!parentWorktreeMeta.instanceId) {
console.warn(
`[worktree-create] parent ${parentScope.worktreeId} has no instance identity; skipping lineage`
)
} else if (
!createdWorktreeSharesParentLineageBoundary(
worktree,
parentScope.worktreeId,
parentWorktreeMeta
)
) {
parentOutsideLineageBoundary = true
console.warn(
`[worktree-create] parent ${parentScope.worktreeId} is outside ${worktree.id}'s repo/host/project boundary; skipping lineage`
)
} else {
lineage = store.setWorktreeLineage(worktree.id, {
worktreeId: worktree.id,
worktreeInstanceId: worktree.instanceId,
parentWorktreeId: parentScope.worktreeId,
parentWorktreeInstanceId: parentWorktreeMeta.instanceId,
origin: 'manual',
capture: { source: 'manual-action', confidence: 'explicit' },
createdAt
})
}
}
// Why: persisting a workspace row for an out-of-boundary worktree parent poisons the whole host —
// `filterLineageForHost` returns null for any owned row whose child and parent hosts differ, so
// every nesting on that host stops hydrating, and the row survives restarts.
if (parentOutsideLineageBoundary) {
return NO_CREATED_WORKTREE_LINEAGE
}
const workspaceLineage = store.setWorkspaceLineage({
childWorkspaceKey,
childInstanceId: worktree.instanceId,
parentWorkspaceKey: args.parentWorkspace,
parentInstanceId: parentWorktreeMeta?.instanceId ?? null,
origin: 'manual',
capture: { source: 'active-workspace', confidence: 'explicit' },
capture: {
source: parentScope.type === 'worktree' ? 'manual-action' : 'active-workspace',
confidence: 'explicit'
},
createdAt
})
return { lineage, workspaceLineage }
}
function countNonEmptyGitOutputLines(output: string): number {
@@ -1677,7 +1751,7 @@ export async function createRemoteWorktree(
)
}
validateWorkspaceLineageParentBeforeCreate(
assertAttachableParentWorkspace(
store,
args.parentWorkspace,
worktreeWorkspaceKey(`${repo.id}::${remotePath}`)
@@ -1915,7 +1989,12 @@ export async function createRemoteWorktree(
const meta = store.setWorktreeMeta(worktreeId, metaUpdates)
return { worktree: mergeWorktree(repo.id, created, meta) }
})
const workspaceLineage = recordWorkspaceLineageForCreatedWorktree(store, args, worktree, now)
const { lineage: worktreeLineage, workspaceLineage } = recordWorkspaceLineageForCreatedWorktree(
store,
args,
worktree,
now
)
// Why: shared/symlink paths, `orca.yaml` shared directories, and `.worktreeinclude` copies are local-only; remote (SSH) support needs a new relay method + auth surface, so all are skipped here.
@@ -1962,7 +2041,14 @@ export async function createRemoteWorktree(
notifyWorktreesChanged(mainWindow, repo.id)
return {
worktree: { ...worktree, workspaceLineage },
worktree: {
...worktree,
workspaceLineage,
...(worktreeLineage
? { lineage: worktreeLineage, parentWorktreeId: worktreeLineage.parentWorktreeId }
: {})
},
...(worktreeLineage ? { lineage: worktreeLineage } : {}),
...(workspaceLineage ? { workspaceLineage } : {}),
...(setup ? { setup } : {}),
...(defaultTabs ? { defaultTabs } : {}),
@@ -2308,7 +2394,7 @@ export async function createLocalWorktree(
)
}
validateWorkspaceLineageParentBeforeCreate(
assertAttachableParentWorkspace(
store,
args.parentWorkspace,
worktreeWorkspaceKey(`${repo.id}::${worktreePath}`)
@@ -2555,7 +2641,12 @@ export async function createLocalWorktree(
const meta = store.setWorktreeMeta(worktreeId, metaUpdates)
return { worktree: mergeWorktree(repo.id, created, meta) }
})
const workspaceLineage = recordWorkspaceLineageForCreatedWorktree(store, args, worktree, now)
const { lineage: worktreeLineage, workspaceLineage } = recordWorkspaceLineageForCreatedWorktree(
store,
args,
worktree,
now
)
// Why: reuse the roots creation already paid for via `git worktree list` so later IPC doesn't lazily rescan and trip macOS privacy prompts.
registerWorktreeRootsForRepo(store, repo.id, [
repo.path,
@@ -2658,7 +2749,14 @@ export async function createLocalWorktree(
notifyWorktreesChanged(mainWindow, repo.id)
return {
worktree: { ...worktree, workspaceLineage },
worktree: {
...worktree,
workspaceLineage,
...(worktreeLineage
? { lineage: worktreeLineage, parentWorktreeId: worktreeLineage.parentWorktreeId }
: {})
},
...(worktreeLineage ? { lineage: worktreeLineage } : {}),
...(workspaceLineage ? { workspaceLineage } : {}),
...(stagedStartup.activationSetup
? { setup: stagedStartup.activationSetup }
+46
View File
@@ -6469,6 +6469,52 @@ describe('OrcaRuntimeService', () => {
}
})
// Why: the desktop composer sends `parentWorkspace` too, and a bare selector defaults to CLI
// provenance — the same user action must not carry different cleanup semantics per host.
it('records an app-selected parent workspace as a manual action', async () => {
vi.mocked(addWorktree).mockClear()
const created = {
path: '/tmp/workspaces/manual-child',
head: 'def',
branch: 'refs/heads/manual-child',
isBare: false,
isMainWorktree: false
}
const childId = `${TEST_REPO_ID}::${created.path}`
const metaById: Record<string, WorktreeMeta> = {}
const runtimeStore = {
...createFolderWorkspaceRuntimeStore(),
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta }
return metaById[worktreeId]
},
setWorkspaceLineage: vi.fn((lineage: WorkspaceLineage) => lineage)
}
computeWorktreePathMock.mockReturnValue(created.path)
ensurePathWithinWorkspaceMock.mockImplementation((pathValue: string) => pathValue)
vi.mocked(listWorktrees).mockResolvedValueOnce([created])
const runtime = new OrcaRuntimeService(runtimeStore as never)
const result = await runtime.createManagedWorktree({
repoSelector: TEST_REPO_ID,
name: 'manual-child',
baseBranch: 'origin/main',
lineage: {
parentWorkspace: TEST_FOLDER_WORKSPACE_KEY,
parentWorkspaceOrigin: 'manual'
}
})
expect(result.workspaceLineage).toMatchObject({
childWorkspaceKey: `worktree:${childId}`,
parentWorkspaceKey: TEST_FOLDER_WORKSPACE_KEY,
origin: 'manual',
capture: { source: 'active-workspace', confidence: 'explicit' }
})
})
it('records folder workspace lineage inferred from environment context', async () => {
vi.mocked(addWorktree).mockClear()
const created = {
+14 -3
View File
@@ -2902,6 +2902,8 @@ type ResolvedTerminalWorkspaceLaunchTarget = {
type WorktreeLineageInput = {
parentWorkspace?: string
/** Set by in-app parent pickers so the row is not recorded as a CLI flag. */
parentWorkspaceOrigin?: 'manual'
envParentWorkspace?: string
parentWorktree?: string
cwdParentWorktree?: string
@@ -32581,11 +32583,20 @@ export class OrcaRuntimeService {
if (input.parentWorkspace) {
try {
const parent = await this.resolveWorkspaceParentSelector(input.parentWorkspace)
// Why: a picker in the app must record the same provenance as a local create, or the same
// user action would carry different cleanup semantics depending on where the repo lives.
return {
kind: 'lineage',
parent: await this.resolveWorkspaceParentSelector(input.parentWorkspace),
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' }
parent,
origin: input.parentWorkspaceOrigin === 'manual' ? 'manual' : 'cli',
capture:
input.parentWorkspaceOrigin === 'manual'
? {
source: parent.type === 'worktree' ? 'manual-action' : 'active-workspace',
confidence: 'explicit'
}
: { source: 'explicit-cli-flag', confidence: 'explicit' }
}
} catch (err) {
throw new RuntimeLineageError(
@@ -26,4 +26,19 @@ describe('buildManagedWorktreeCreateArgs', () => {
nameWasGenerated: true
})
})
it('carries the parent-pick provenance only when the client marked it manual', () => {
// Why: older clients never send it, and those creates really are CLI-flag equivalents.
expect(
build({ repo: 'id:repo-1', name: 'child', parentWorkspace: 'folder:f1' }).lineage
).not.toHaveProperty('parentWorkspaceOrigin')
expect(
build({
repo: 'id:repo-1',
name: 'child',
parentWorkspace: 'folder:f1',
parentWorkspaceOrigin: 'manual'
}).lineage
).toMatchObject({ parentWorkspaceOrigin: 'manual' })
})
})
@@ -80,6 +80,7 @@ export function buildManagedWorktreeCreateArgs(
startupDraft: params.startupDraft,
lineage: {
parentWorkspace: params.parentWorkspace,
...(params.parentWorkspaceOrigin ? { parentWorkspaceOrigin: 'manual' as const } : {}),
envParentWorkspace: params.envParentWorkspace,
parentWorktree: params.parentWorktree,
...(params.cwdParentWorktree ? { cwdParentWorktree: params.cwdParentWorktree } : {}),
@@ -75,6 +75,9 @@ export const WorktreeCreate = z
// startup and defaultTabs. Pass an explicit target to opt into an all-surface reveal.
navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(),
parentWorkspace: OptionalString,
// Why: an app-selected parent is a manual action, not the CLI's `--parent-workspace` flag.
// Absent keeps the CLI provenance older clients rely on.
parentWorkspaceOrigin: z.literal('manual').optional(),
envParentWorkspace: OptionalString,
parentWorktree: OptionalString,
cwdParentWorktree: OptionalString,
+7 -4
View File
@@ -1145,9 +1145,11 @@ html.native-shell .app-layout {
/* ── Jump palette (Cmd+J) ─────────────────────────────── */
/* Why the data-slot qualifier: CommandItem also sets data-[selected=true]:bg-accent;
this selector must beat that utility so light-mode selection is actually visible. */
[data-slot='command-item'].jump-palette-item[data-selected='true'] {
/* Why the extra qualifier: CommandItem also sets data-[selected=true]:bg-accent;
this selector must beat that utility so light-mode selection is actually visible.
The role='option' arm covers virtualized rows that render outside cmdk. */
[data-slot='command-item'].jump-palette-item[data-selected='true'],
[role='option'].jump-palette-item[data-selected='true'] {
/* Why: flat --accent (#f5f5f5) on the palette surface (#fff) is nearly invisible
in light mode — the keyboard cursor has to read as a solid selected row.
Mix foreground into background and draw an inset ring; expose the surface
@@ -1159,7 +1161,8 @@ html.native-shell .app-layout {
color: var(--foreground);
}
.dark [data-slot='command-item'].jump-palette-item[data-selected='true'] {
.dark [data-slot='command-item'].jump-palette-item[data-selected='true'],
.dark [role='option'].jump-palette-item[data-selected='true'] {
/* Why accent, not the light-mode mix: --accent (#404040) already reads clearly on
dark surfaces; a low-%% foreground mix was a visible step down from the old row. */
--jump-palette-selection-surface: var(--accent);
@@ -171,6 +171,8 @@ function renderCard(
onOpenAgentSettings={() => {}}
advancedOpen={false}
onToggleAdvanced={() => {}}
parentWorktreeId={null}
onParentWorktreeIdChange={() => {}}
createDisabled={false}
projectError={null}
creating={false}
@@ -230,6 +230,8 @@ function renderCard(
onReuseSelectedBranchChange={() => {}}
branchNameOverride=""
onBranchNameOverrideChange={() => {}}
parentWorktreeId={null}
onParentWorktreeIdChange={() => {}}
forkPushWarning={null}
detectedAgentIds={null}
onOpenAgentSettings={() => {}}
@@ -306,6 +308,11 @@ function findRunTargetItem(label: string): HTMLElement | undefined {
let current: { container: HTMLDivElement; root: Root } | null = null
function unmountCurrent(): void {
act(() => current?.root.unmount())
current?.container.remove()
}
describe('NewWorkspaceComposerCard folder task source mode', () => {
beforeEach(() => {
;(window as unknown as { api: unknown }).api = {
@@ -333,8 +340,7 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
})
afterEach(() => {
act(() => current?.root.unmount())
current?.container.remove()
unmountCurrent()
current = null
vi.clearAllMocks()
})
@@ -395,8 +401,7 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
)
expect(collapsedReuse).toBeTruthy()
act(() => current?.root.unmount())
current?.container.remove()
unmountCurrent()
current = renderCard({ canReuseSelectedBranch: true, reuseSelectedBranch: true })
const reuseLabel = [...current.container.querySelectorAll('label')].find((label) =>
@@ -430,8 +435,7 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
clickReuseCheckbox()
expect(offChanges).toEqual([false])
act(() => current?.root.unmount())
current?.container.remove()
unmountCurrent()
// Unchecked -> checked (opting into reuse — the action that pins the branch).
const onChanges: boolean[] = []
@@ -458,8 +462,7 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
'Wait for setup to complete before starting agent'
)
act(() => current?.root.unmount())
current?.container.remove()
unmountCurrent()
current = renderCard({
advancedOpen: true,
@@ -579,6 +582,19 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
expect(findInputByLabel(current.container, 'Branch name')).toBeTruthy()
})
it('places the parent workspace picker immediately after the branch name field', () => {
current = renderCard({
advancedOpen: true,
branchesEnabled: true
})
const nextField = findInputByLabel(current.container, 'Branch name')?.closest(
'div.space-y-1'
)?.nextElementSibling
expect(nextField?.textContent).toMatch(/Parent worktree(?!.*Note)/)
expect(nextField?.nextElementSibling?.textContent).toContain('Note')
})
it('does not disable folder workspace creation when only source lookup needs SSH', () => {
current = renderCard({
eligibleRepos: [
@@ -886,8 +902,7 @@ describe('NewWorkspaceComposerCard note sizing', () => {
// Sizing is layout-driven (field-sizing) rather than a JS measure pass, and happy-dom
// has no layout engine, so these assert the class contract that produces the growth.
afterEach(() => {
act(() => current?.root.unmount())
current?.container.remove()
unmountCurrent()
current = null
})
@@ -55,6 +55,11 @@ export default function NewWorkspaceComposerCard(
setupConfig,
setupControlsEnabled = true,
selectedProjectId = null,
parentWorktreeId,
onParentWorktreeIdChange,
selectedRepoExecutionHostId,
selectedRepoProjectId,
activeFolderWorkspaceId,
onAddProjectOverride,
onNestedDialogOpenChange
} = props
@@ -302,6 +307,11 @@ export default function NewWorkspaceComposerCard(
setupRunButtonLabel={setupRunButtonLabel}
setupSkipButtonLabel={setupSkipButtonLabel}
showSetupAgentStartupPolicy={showSetupAgentStartupPolicy}
parentWorktreeId={parentWorktreeId}
onParentWorktreeIdChange={onParentWorktreeIdChange}
selectedRepoExecutionHostId={selectedRepoExecutionHostId}
selectedRepoProjectId={selectedRepoProjectId}
activeFolderWorkspaceId={activeFolderWorkspaceId}
/>
</div>
<NewWorkspaceComposerFooter
@@ -0,0 +1,540 @@
// @vitest-environment happy-dom
import type React from 'react'
import type * as WorktreeRepoIndex from '@/store/worktree-repo-index'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/repo-types'
import type { WorkspaceLineage, WorktreeLineage } from '../../../../shared/worktree/lineage-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope'
type FakeStoreState = {
worktreesByRepo: Record<string, Worktree[]>
repos: Repo[]
projectHostSetups: never[]
worktreeLineageById: Record<string, WorktreeLineage>
workspaceLineageByChildKey: Record<string, WorkspaceLineage>
}
const storeState = vi.hoisted(
() =>
({
worktreesByRepo: {},
repos: [],
projectHostSetups: [],
worktreeLineageById: {},
workspaceLineageByChildKey: {}
}) as FakeStoreState
)
const popoverMock = vi.hoisted(() => ({
open: false,
onOpenChange: undefined as ((open: boolean) => void) | undefined
}))
const indexSpies = vi.hoisted(() => ({
getIndexedAllWorktrees: vi.fn(),
getIndexedWorktreeMap: vi.fn(),
getIndexedWorktreeById: vi.fn()
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (_key: string, fallback: string) => fallback })
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: FakeStoreState) => unknown) => selector(storeState)
}))
// Spies wrap the real projections, so exclusion rules stay under test while call
// counts prove when enumeration happens.
vi.mock('@/store/worktree-repo-index', async (importOriginal) => {
const actual = await importOriginal<typeof WorktreeRepoIndex>()
indexSpies.getIndexedAllWorktrees.mockImplementation(actual.getIndexedAllWorktrees)
indexSpies.getIndexedWorktreeMap.mockImplementation(actual.getIndexedWorktreeMap)
indexSpies.getIndexedWorktreeById.mockImplementation(actual.getIndexedWorktreeById)
return { ...actual, ...indexSpies }
})
vi.mock('@/components/ui/popover', async () => {
const { cloneElement } = await import('react')
return {
Popover: ({
children,
open,
onOpenChange
}: {
children: React.ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}) => {
popoverMock.open = open ?? false
popoverMock.onOpenChange = onOpenChange
return <div>{children}</div>
},
// Radix `asChild` renders the child itself and owns its click; mirror that.
PopoverTrigger: ({ children }: { children: React.ReactElement }) =>
cloneElement(children as React.ReactElement<{ onClick?: () => void }>, {
onClick: () => popoverMock.onOpenChange?.(!popoverMock.open)
}),
// Radix mounts content only while open — the property the lazy list relies on.
PopoverContent: ({ children }: { children: React.ReactNode }) =>
popoverMock.open ? <div>{children}</div> : null
}
})
vi.mock('@/components/ui/command', () => ({
Command: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
CommandInput: ({
ref,
value,
placeholder,
onValueChange,
onKeyDown,
onKeyUp,
onCompositionStart,
onCompositionEnd
}: {
ref?: React.Ref<HTMLInputElement>
value?: string
placeholder?: string
onValueChange?: (value: string) => void
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>
onKeyUp?: React.KeyboardEventHandler<HTMLInputElement>
onCompositionStart?: React.CompositionEventHandler<HTMLInputElement>
onCompositionEnd?: React.CompositionEventHandler<HTMLInputElement>
}) => (
<input
ref={ref}
data-slot="command-input"
value={value}
placeholder={placeholder}
onChange={(event) => onValueChange?.(event.target.value)}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
/>
),
CommandList: ({
ref,
children,
className
}: {
ref?: React.Ref<HTMLDivElement>
children: React.ReactNode
className?: string
}) => (
<div ref={ref} className={className}>
{children}
</div>
)
}))
// The virtual window needs real layout the test DOM has none of; render every row.
vi.mock('@tanstack/react-virtual', () => ({
useVirtualizer: ({ count }: { count: number }) => ({
getTotalSize: () => count * 44,
getVirtualItems: () =>
Array.from({ length: count }, (_, index) => ({
index,
key: index,
start: index * 44,
size: 44
})),
scrollToIndex: vi.fn(),
scrollToOffset: vi.fn()
})
}))
const { ComposerParentWorktreePicker } = await import('./ComposerParentWorktreePicker')
const REPO_ID = 'repo1'
function makeRepo(id: string): Repo {
return {
id,
path: `/src/${id}`,
displayName: id,
badgeColor: '#111111',
addedAt: 0
} as Repo
}
function makeWorktree(overrides: Partial<Worktree> & { id: string }): Worktree {
return {
repoId: REPO_ID,
path: `/work/${overrides.id}`,
head: 'abc123',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false,
displayName: overrides.id,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
...overrides
}
}
function seed(worktrees: Worktree[], repoIds: string[] = [REPO_ID]): void {
const byRepo: Record<string, Worktree[]> = {}
for (const worktree of worktrees) {
byRepo[worktree.repoId] = [...(byRepo[worktree.repoId] ?? []), worktree]
}
storeState.worktreesByRepo = byRepo
storeState.repos = repoIds.map(makeRepo)
}
function trigger(): HTMLButtonElement {
const node = document.querySelector<HTMLButtonElement>('button[role="combobox"]')
if (!node) {
throw new Error('parent picker trigger not found')
}
return node
}
function rows(): HTMLElement[] {
return Array.from(document.querySelectorAll<HTMLElement>('[role="option"]'))
}
function candidateLabels(): string[] {
return rows()
.slice(1)
.map((row) => row.textContent ?? '')
}
function searchInput(): HTMLInputElement {
const node = document.querySelector<HTMLInputElement>('[data-slot="command-input"]')
if (!node) {
throw new Error('parent picker search input not found')
}
return node
}
function rowFor(label: string): HTMLElement {
const row = rows().find((node) => node.textContent?.includes(label))
if (!row) {
throw new Error(`row not found for ${label}`)
}
return row
}
beforeEach(() => {
popoverMock.open = false
popoverMock.onOpenChange = undefined
storeState.worktreesByRepo = {}
storeState.repos = []
storeState.worktreeLineageById = {}
storeState.workspaceLineageByChildKey = {}
indexSpies.getIndexedAllWorktrees.mockClear()
indexSpies.getIndexedWorktreeMap.mockClear()
indexSpies.getIndexedWorktreeById.mockClear()
})
afterEach(cleanup)
describe('ComposerParentWorktreePicker', () => {
it('enumerates candidate worktrees only once the popover opens', () => {
seed([makeWorktree({ id: 'alpha' }), makeWorktree({ id: 'beta' })])
render(<ComposerParentWorktreePicker repoId={REPO_ID} value="alpha" onChange={vi.fn()} />)
// The closed trigger resolves one name from the shared index; it never enumerates.
expect(indexSpies.getIndexedWorktreeById).toHaveBeenCalled()
expect(indexSpies.getIndexedAllWorktrees).not.toHaveBeenCalled()
expect(indexSpies.getIndexedWorktreeMap).not.toHaveBeenCalled()
fireEvent.click(trigger())
expect(indexSpies.getIndexedAllWorktrees).toHaveBeenCalled()
expect(indexSpies.getIndexedWorktreeMap).toHaveBeenCalled()
})
it('shows "No parent" on the closed trigger when nothing is picked', () => {
seed([makeWorktree({ id: 'alpha' })])
render(<ComposerParentWorktreePicker repoId={REPO_ID} value={null} onChange={vi.fn()} />)
expect(trigger().textContent).toContain('No parent')
})
it('shows the picked worktree display name on the closed trigger', () => {
seed([makeWorktree({ id: 'alpha', displayName: 'Alpha workspace' })])
render(<ComposerParentWorktreePicker repoId={REPO_ID} value="alpha" onChange={vi.fn()} />)
expect(trigger().textContent).toContain('Alpha workspace')
expect(trigger().textContent).not.toContain('No parent')
})
it('reports the picked worktree id and closes when a candidate row is clicked', () => {
const onChange = vi.fn()
seed([makeWorktree({ id: 'alpha', displayName: 'Alpha' })])
render(<ComposerParentWorktreePicker repoId={REPO_ID} value={null} onChange={onChange} />)
fireEvent.click(trigger())
fireEvent.click(rowFor('Alpha'))
expect(onChange).toHaveBeenCalledWith('alpha')
expect(rows()).toHaveLength(0)
})
it('clears the pick when the pinned "No parent" row is clicked', () => {
const onChange = vi.fn()
seed([makeWorktree({ id: 'alpha', displayName: 'Alpha' })])
render(<ComposerParentWorktreePicker repoId={REPO_ID} value="alpha" onChange={onChange} />)
fireEvent.click(trigger())
fireEvent.click(rows()[0])
expect(onChange).toHaveBeenCalledWith(null)
expect(rows()).toHaveLength(0)
})
it('excludes archived worktrees and worktrees from another repo', () => {
seed(
[
makeWorktree({ id: 'alpha', displayName: 'Alpha' }),
makeWorktree({ id: 'archived', displayName: 'Archived', isArchived: true }),
makeWorktree({ id: 'other', displayName: 'Other repo', repoId: 'repo2' })
],
[REPO_ID, 'repo2']
)
render(<ComposerParentWorktreePicker repoId={REPO_ID} value={null} onChange={vi.fn()} />)
fireEvent.click(trigger())
expect(candidateLabels().join(' ')).toContain('Alpha')
expect(candidateLabels().join(' ')).not.toContain('Archived')
expect(candidateLabels().join(' ')).not.toContain('Other repo')
})
it('excludes candidates on another execution host or project', () => {
seed([
makeWorktree({ id: 'same', displayName: 'Same host', hostId: 'local', projectId: 'proj1' }),
makeWorktree({
id: 'otherHost',
displayName: 'Other host',
hostId: 'ssh:box',
projectId: 'proj1'
}),
makeWorktree({
id: 'otherProject',
displayName: 'Other project',
hostId: 'local',
projectId: 'proj2'
})
])
render(
<ComposerParentWorktreePicker
repoId={REPO_ID}
executionHostId="local"
projectId="proj1"
value={null}
onChange={vi.fn()}
/>
)
fireEvent.click(trigger())
expect(candidateLabels().join(' ')).toContain('Same host')
expect(candidateLabels().join(' ')).not.toContain('Other host')
expect(candidateLabels().join(' ')).not.toContain('Other project')
})
// A worktree with no recorded hostId inherits its repo's host, which is the child's host too.
it('keeps candidates whose host or project is unrecorded', () => {
seed([makeWorktree({ id: 'unscoped', displayName: 'Unscoped' })])
render(
<ComposerParentWorktreePicker
repoId={REPO_ID}
executionHostId="local"
projectId="proj1"
value={null}
onChange={vi.fn()}
/>
)
fireEvent.click(trigger())
expect(candidateLabels().join(' ')).toContain('Unscoped')
})
it('restricts candidates to the active folder workspace subtree', () => {
const attached = makeWorktree({
id: 'attached',
displayName: 'Attached',
instanceId: 'attached-instance'
})
const nested = makeWorktree({
id: 'nested',
displayName: 'Nested',
instanceId: 'nested-instance'
})
const outside = makeWorktree({ id: 'outside', displayName: 'Outside' })
seed([attached, nested, outside])
storeState.workspaceLineageByChildKey = {
[worktreeWorkspaceKey(attached.id)]: {
childWorkspaceKey: worktreeWorkspaceKey(attached.id),
childInstanceId: attached.instanceId ?? null,
parentWorkspaceKey: folderWorkspaceKey('folder-1'),
parentInstanceId: null,
origin: 'manual',
capture: { source: 'active-workspace', confidence: 'explicit' },
createdAt: 1
}
}
storeState.worktreeLineageById = {
[nested.id]: {
worktreeId: nested.id,
worktreeInstanceId: 'nested-instance',
parentWorktreeId: attached.id,
parentWorktreeInstanceId: 'attached-instance',
origin: 'manual',
capture: { source: 'manual-action', confidence: 'explicit' },
createdAt: 2
}
}
render(
<ComposerParentWorktreePicker
repoId={REPO_ID}
value={null}
onChange={vi.fn()}
activeFolderWorkspaceId="folder-1"
/>
)
fireEvent.click(trigger())
const labels = candidateLabels().join(' ')
expect(labels).toContain('Attached')
expect(labels).toContain('Nested')
expect(labels).not.toContain('Outside')
})
it('keeps the trigger inert while disabled', () => {
seed([makeWorktree({ id: 'alpha', displayName: 'Alpha' })])
render(
<ComposerParentWorktreePicker repoId={REPO_ID} value={null} onChange={vi.fn()} disabled />
)
fireEvent.click(trigger())
expect(trigger().disabled).toBe(true)
expect(rows()).toHaveLength(0)
})
it('picks the top match when Enter follows a search query', () => {
const onChange = vi.fn()
seed([
makeWorktree({ id: 'alpha', displayName: 'Alpha' }),
makeWorktree({ id: 'beta', displayName: 'Beta' })
])
render(<ComposerParentWorktreePicker repoId={REPO_ID} value={null} onChange={onChange} />)
fireEvent.click(trigger())
fireEvent.change(searchInput(), { target: { value: 'Beta' } })
fireEvent.keyDown(searchInput(), { key: 'Enter', keyCode: 13 })
// Regression: the highlight used to reset onto the pinned "No parent" row, so the
// standard type-then-Enter flow cleared the parent instead of picking the match.
expect(onChange).toHaveBeenCalledWith('beta')
})
it('clears the parent when Enter is pressed with no search query', () => {
const onChange = vi.fn()
seed([makeWorktree({ id: 'alpha', displayName: 'Alpha' })])
render(<ComposerParentWorktreePicker repoId={REPO_ID} value="alpha" onChange={onChange} />)
fireEvent.click(trigger())
fireEvent.keyDown(searchInput(), { key: 'Enter', keyCode: 13 })
expect(onChange).toHaveBeenCalledWith(null)
})
it('keeps the parent when Enter only confirms a CJK composition', () => {
const onChange = vi.fn()
seed([makeWorktree({ id: 'alpha', displayName: 'Alpha' })])
render(<ComposerParentWorktreePicker repoId={REPO_ID} value={null} onChange={onChange} />)
fireEvent.click(trigger())
fireEvent.compositionStart(searchInput())
// The confirming Enter arrives twice: the marked keydown, then an unmarked redispatch.
fireEvent.keyDown(searchInput(), { key: 'Enter', keyCode: 229 })
fireEvent.keyDown(searchInput(), { key: 'Enter', keyCode: 13 })
expect(onChange).not.toHaveBeenCalled()
expect(rows().length).toBeGreaterThan(0)
})
it('falls back to "No parent" on the trigger when the picked parent is archived', () => {
seed([makeWorktree({ id: 'alpha', displayName: 'Alpha workspace', isArchived: true })])
render(<ComposerParentWorktreePicker repoId={REPO_ID} value="alpha" onChange={vi.fn()} />)
expect(trigger().textContent).not.toContain('Alpha workspace')
expect(trigger().textContent).toContain('No parent')
})
it('excludes a folder root whose lineage row is instance-stale', () => {
const attached = makeWorktree({
id: 'attached',
displayName: 'Attached',
instanceId: 'current-instance'
})
seed([attached])
storeState.workspaceLineageByChildKey = {
[worktreeWorkspaceKey(attached.id)]: {
childWorkspaceKey: worktreeWorkspaceKey(attached.id),
childInstanceId: 'deleted-and-recreated',
parentWorkspaceKey: folderWorkspaceKey('folder-1'),
parentInstanceId: null,
origin: 'manual',
capture: { source: 'active-workspace', confidence: 'explicit' },
createdAt: 1
}
}
render(
<ComposerParentWorktreePicker
repoId={REPO_ID}
value={null}
onChange={vi.fn()}
activeFolderWorkspaceId="folder-1"
/>
)
fireEvent.click(trigger())
expect(candidateLabels().join(' ')).not.toContain('Attached')
})
it('closes an open popover when the drawer collapses the trigger', () => {
seed([makeWorktree({ id: 'alpha', displayName: 'Alpha' })])
const view = render(
<ComposerParentWorktreePicker repoId={REPO_ID} value={null} onChange={vi.fn()} />
)
fireEvent.click(trigger())
expect(rows().length).toBeGreaterThan(0)
view.rerender(
<ComposerParentWorktreePicker repoId={REPO_ID} value={null} onChange={vi.fn()} disabled />
)
expect(rows()).toHaveLength(0)
})
})
@@ -0,0 +1,429 @@
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Check, ChevronsUpDown, GitBranch } from 'lucide-react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { Button } from '@/components/ui/button'
import { Command, CommandInput, CommandList } from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import {
isImeCompositionKeyDown,
useImeEnterGestureOwnership
} from '@/lib/ime-composition-keyboard-event'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import {
getIndexedAllWorktrees,
getIndexedWorktreeById,
getIndexedWorktreeMap
} from '@/store/worktree-repo-index'
import { compareWorktreeDisplayName } from '@/lib/worktree-display-name-order'
import { branchDisplayName } from '@/components/sidebar/WorktreeCardHelpers'
import { getCyclicProjectedWorktreeLineageIds } from '@/components/sidebar/worktree-lineage-projection'
import {
clampWorktreeParentPickerIndex,
filterWorktreeParentCandidates
} from '@/components/sidebar/worktree-parent-picker-filtering'
import {
PICKER_LIST_MAX_HEIGHT,
PICKER_ROW_HEIGHT,
PICKER_ROW_OVERSCAN
} from '@/components/sidebar/worktree-parent-picker-placement'
import {
getLineageChildrenByParentId,
getLineageChildWorktree
} from '@/components/right-sidebar/folder-workspace-attached-worktrees'
import { COMBOBOX_POPOVER_SURFACE } from './type-ahead-combobox-styles'
import {
sharesWorktreeLineageBoundary,
type WorktreeLineageBoundary
} from '../../../../shared/resolved-worktree-lineage'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import type { WorkspaceLineage, WorktreeLineage } from '../../../../shared/worktree/lineage-types'
import type { Worktree } from '../../../../shared/worktree/types'
import type { ExecutionHostId } from '../../../../shared/execution-host'
/** Single-line row: text-sm leading (20px) over py-2. Set as the row's explicit height. */
const NO_PARENT_ROW_HEIGHT = 36
const NO_PARENT_ROW_KEY = 'no-parent'
type ComposerParentWorktreePickerProps = {
repoId: string
/** Parent must belong to the same execution host that will create the child. */
executionHostId?: ExecutionHostId | null
projectId?: string | null
value: string | null
onChange: (id: string | null) => void
disabled?: boolean
/** When the active workspace is a folder, restrict candidates to that folder's subtree. */
activeFolderWorkspaceId?: string | null
}
type ParentWorktreeCandidateListProps = {
repoId: string
executionHostId?: ExecutionHostId | null
projectId?: string | null
value: string | null
activeFolderWorkspaceId: string | null
onSelect: (id: string | null) => void
}
/**
* Parent picker for the composer's Advanced drawer. Nesting only — the base
* branch is unaffected.
*
* Everything that enumerates worktrees lives in the inner list, which Radix
* mounts on open, so a closed picker costs one string selector and nothing else.
*/
function ComposerParentWorktreePickerImpl({
repoId,
executionHostId,
projectId,
value,
onChange,
disabled = false,
activeFolderWorkspaceId = null
}: ComposerParentWorktreePickerProps): React.JSX.Element {
// Why: subscribe so translate() copy refreshes on language change without a remount.
useTranslation()
const [open, setOpen] = useState(false)
const labelId = `${useId()}label`
// Why: an archived parent is already gone from the candidate list and the sidebar would
// drop the edge, so showing its name would promise nesting the create can't deliver.
const parentName = useAppStore((s) => {
if (!value) {
return null
}
const parent = getIndexedWorktreeById(s.worktreesByRepo, value)
return parent && !parent.isArchived ? parent.displayName : null
})
// Why: the trigger goes inert when the Advanced drawer collapses, but the portaled
// popover does not — it would linger detached from a control the user can't reach.
// Adjusted during render (not in an effect) so the stale-open frame never paints.
if (open && disabled) {
setOpen(false)
}
const handleSelect = useCallback(
(nextId: string | null) => {
onChange(nextId)
setOpen(false)
},
[onChange]
)
return (
<div className="space-y-1.5">
<span id={labelId} className="block text-xs font-medium text-muted-foreground">
{translate('auto.components.ComposerParentWorktreePicker.label', 'Parent worktree')}
</span>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
aria-labelledby={labelId}
disabled={disabled}
className="h-9 w-full justify-between border-input px-3 text-sm font-normal text-foreground focus:border-ring focus:ring-[3px] focus:ring-ring/50"
>
<span className="truncate">
{parentName ??
translate('auto.components.ComposerParentWorktreePicker.noParent', 'No parent')}
</span>
<ChevronsUpDown className="size-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className={cn(
'flex w-[var(--radix-popover-trigger-width)] min-w-[17rem] flex-col p-0',
COMBOBOX_POPOVER_SURFACE
)}
// Why: Radix would focus the content wrapper; the search box is the real target.
onOpenAutoFocus={(event) => {
event.preventDefault()
const content = event.currentTarget
if (content instanceof HTMLElement) {
content.querySelector<HTMLInputElement>('[data-slot="command-input"]')?.focus()
}
}}
>
<ParentWorktreeCandidateList
repoId={repoId}
executionHostId={executionHostId}
projectId={projectId}
value={value}
activeFolderWorkspaceId={activeFolderWorkspaceId}
onSelect={handleSelect}
/>
</PopoverContent>
</Popover>
<p className="text-[11px] text-muted-foreground">
{translate(
'auto.components.ComposerParentWorktreePicker.description',
'Nests this workspace under another in the sidebar. Does not change the base branch.'
)}
</p>
</div>
)
}
/** Worktrees reachable from the folder workspace, so a pick can't drop the new one out of it. */
function getFolderWorkspaceSubtreeIds(
folderWorkspaceId: string,
workspaceLineageByChildKey: Readonly<Record<string, WorkspaceLineage>>,
worktreeLineageById: Record<string, WorktreeLineage>,
worktreeById: Map<string, Worktree>
): Set<string> {
const folderKey = folderWorkspaceKey(folderWorkspaceId)
const rootIds = new Set<string>()
for (const lineage of Object.values(workspaceLineageByChildKey)) {
if (lineage.parentWorkspaceKey !== folderKey) {
continue
}
// Why: the folder view drops archived and instance-stale rows, so accepting them here
// would offer a parent whose whole branch the folder never actually shows.
const rootWorktree = getLineageChildWorktree(lineage, worktreeById)
if (rootWorktree) {
rootIds.add(rootWorktree.id)
}
}
const subtreeIds = new Set(rootIds)
for (const children of getLineageChildrenByParentId(
worktreeLineageById,
worktreeById,
rootIds
).values()) {
for (const child of children) {
subtreeIds.add(child.id)
}
}
return subtreeIds
}
function ParentWorktreeCandidateList({
repoId,
executionHostId,
projectId,
value,
activeFolderWorkspaceId,
onSelect
}: ParentWorktreeCandidateListProps): React.JSX.Element {
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const worktreeLineageById = useAppStore((s) => s.worktreeLineageById)
const workspaceLineageByChildKey = useAppStore((s) => s.workspaceLineageByChildKey)
const listRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const imeEnter = useImeEnterGestureOwnership()
const optionIdPrefix = `${useId()}option`
const [search, setSearch] = useState('')
const [highlightedIndex, setHighlightedIndex] = useState(0)
const candidates = useMemo(() => {
// Why `?? undefined`: an unresolved host or project is "not known yet", not "no host", and
// the boundary check reads undefined on either side as a wildcard. A candidate in this repo
// with no recorded hostId inherits that repo's host, so it is on the child's host too.
const childBoundary: WorktreeLineageBoundary = {
repoId,
hostId: executionHostId ?? undefined,
projectId: projectId ?? undefined
}
const worktreeMap = getIndexedWorktreeMap(worktreesByRepo)
const cyclicLineageIds = getCyclicProjectedWorktreeLineageIds(worktreeLineageById, worktreeMap)
const folderSubtreeIds = activeFolderWorkspaceId
? getFolderWorkspaceSubtreeIds(
activeFolderWorkspaceId,
workspaceLineageByChildKey,
worktreeLineageById,
worktreeMap
)
: null
return getIndexedAllWorktrees(worktreesByRepo)
.filter(
(candidate) =>
candidate.repoId === repoId &&
!candidate.isArchived &&
sharesWorktreeLineageBoundary(childBoundary, candidate) &&
!cyclicLineageIds.has(candidate.id) &&
(folderSubtreeIds === null || folderSubtreeIds.has(candidate.id))
)
.sort(compareWorktreeDisplayName)
}, [
activeFolderWorkspaceId,
executionHostId,
projectId,
repoId,
workspaceLineageByChildKey,
worktreeLineageById,
worktreesByRepo
])
const filtered = useMemo(
() => filterWorktreeParentCandidates(candidates, search),
[candidates, search]
)
// Index 0 is the pinned "No parent" row; candidates start at 1.
const activeIndex = clampWorktreeParentPickerIndex(highlightedIndex, filtered.length + 1)
// Why: the "No parent" row is virtualized as index 0 rather than pinned above the
// scroller. A row outside the virtual container shifts every item's true position
// without the virtualizer knowing, so scrollToIndex would land short by that height.
const virtualizer = useVirtualizer({
count: filtered.length + 1,
getScrollElement: () => listRef.current,
estimateSize: (index) => (index === 0 ? NO_PARENT_ROW_HEIGHT : PICKER_ROW_HEIGHT),
overscan: PICKER_ROW_OVERSCAN,
getItemKey: (index) => (index === 0 ? NO_PARENT_ROW_KEY : (filtered[index - 1]?.id ?? index)),
// Why: the list mounts inside a popover that measures on the next frame, so
// seed the viewport to avoid a blank first paint.
initialRect: { width: 0, height: PICKER_LIST_MAX_HEIGHT }
})
const moveHighlight = useCallback(
(nextIndex: number) => {
setHighlightedIndex(nextIndex)
virtualizer.scrollToIndex(nextIndex, { align: 'auto' })
},
[virtualizer]
)
const handleSearchChange = useCallback(
(nextSearch: string) => {
// Why: re-ranking on each keystroke makes any prior highlight meaningless. A query
// highlights the top match so Enter picks it; index 0 clears the parent, which is
// never what typing a name means. The clamp drops it back to 0 when nothing matches.
setSearch(nextSearch)
setHighlightedIndex(nextSearch.trim().length > 0 ? 1 : 0)
virtualizer.scrollToOffset(0)
},
[virtualizer]
)
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
// Why: cmdk's root handler only knows the mounted virtual window, so own navigation here.
// A CJK confirm sends two Enter keydowns and only the first is marked, so the marked-only
// check must be paired with the gesture carry or the unmarked one commits a selection.
if (imeEnter.ownsKeyDown(event) || isImeCompositionKeyDown(event)) {
return
}
const navigate = (nextIndex: number): void => {
event.preventDefault()
event.stopPropagation()
moveHighlight(clampWorktreeParentPickerIndex(nextIndex, filtered.length + 1))
}
if (event.key === 'ArrowDown') {
navigate(activeIndex + 1)
} else if (event.key === 'ArrowUp') {
navigate(activeIndex - 1)
} else if (event.key === 'Home') {
navigate(0)
} else if (event.key === 'End') {
navigate(filtered.length)
} else if (event.key === 'Enter') {
const candidate = activeIndex === 0 ? null : filtered[activeIndex - 1]
if (activeIndex === 0 || candidate) {
event.preventDefault()
event.stopPropagation()
onSelect(candidate?.id ?? null)
}
}
},
[activeIndex, filtered, imeEnter, moveHighlight, onSelect]
)
// Why: cmdk's Input owns aria-activedescendant and points it at its own empty
// item registry while we drive selection, so re-point it after every commit.
useEffect(() => {
inputRef.current?.setAttribute('aria-activedescendant', `${optionIdPrefix}-${activeIndex}`)
})
const virtualRows = virtualizer.getVirtualItems()
return (
<Command shouldFilter={false} className="min-h-0 bg-transparent">
<CommandInput
ref={inputRef}
value={search}
onValueChange={handleSearchChange}
onKeyDown={handleKeyDown}
onKeyUp={imeEnter.onKeyUp}
onCompositionStart={() => imeEnter.setComposing(true)}
onCompositionEnd={() => imeEnter.setComposing(false)}
wrapperClassName="shrink-0"
placeholder={translate(
'auto.components.ComposerParentWorktreePicker.searchPlaceholder',
'Search workspaces...'
)}
/>
<CommandList ref={listRef} className="max-h-72 min-h-0 flex-1">
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
{virtualRows.map((virtualRow) => {
const rowIndex = virtualRow.index
const candidate = rowIndex === 0 ? null : filtered[rowIndex - 1]
if (rowIndex !== 0 && !candidate) {
return null
}
const isHighlighted = rowIndex === activeIndex
return (
<div
key={virtualRow.key}
id={`${optionIdPrefix}-${rowIndex}`}
role="option"
aria-selected={(candidate?.id ?? null) === value}
data-selected={isHighlighted || undefined}
// Why `jump-palette-item`: selection chrome lives in main.css — flat accent is invisible on light popovers.
className="jump-palette-item absolute left-0 top-0 flex w-full cursor-default select-none items-center gap-2 overflow-hidden rounded-sm px-2 py-2 text-sm outline-none"
style={{
height: virtualRow.size,
transform: `translateY(${virtualRow.start}px)`
}}
onPointerMove={() => setHighlightedIndex(rowIndex)}
onClick={() => onSelect(candidate?.id ?? null)}
>
<Check
className={cn(
'size-3.5 shrink-0',
(candidate?.id ?? null) !== value && 'opacity-0'
)}
/>
{candidate ? (
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium">{candidate.displayName}</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[11px] leading-none text-muted-foreground">
<GitBranch className="size-3 shrink-0" />
<span className="truncate">{branchDisplayName(candidate.branch)}</span>
</div>
</div>
) : (
<span className="truncate">
{translate(
'auto.components.ComposerParentWorktreePicker.noParent',
'No parent'
)}
</span>
)}
</div>
)
})}
</div>
{filtered.length === 0 && search.trim().length > 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
{translate('auto.components.ComposerParentWorktreePicker.noMatches', 'No matches.')}
</div>
) : null}
</CommandList>
</Command>
)
}
/** Why: every prop is a primitive or a stable callback, so the composer's per-keystroke
* renders stop here instead of re-rendering the popover subtree. */
export const ComposerParentWorktreePicker = React.memo(ComposerParentWorktreePickerImpl)
export default ComposerParentWorktreePicker
@@ -11,6 +11,7 @@ import {
shouldHandleTextControlPaste
} from '@/lib/text-control-paste'
import { translate } from '@/i18n/i18n'
import { ComposerParentWorktreePicker } from './ComposerParentWorktreePicker'
import type { NewWorkspaceComposerCardProps } from './new-workspace-composer-card-props'
function SetupCommandPreview({
@@ -37,6 +38,11 @@ type NewWorkspaceComposerAdvancedSectionProps = Pick<
| 'branchesEnabled'
| 'branchNameOverride'
| 'onBranchNameOverrideChange'
| 'parentWorktreeId'
| 'onParentWorktreeIdChange'
| 'selectedRepoExecutionHostId'
| 'selectedRepoProjectId'
| 'activeFolderWorkspaceId'
| 'note'
| 'onNoteChange'
| 'setupControlsEnabled'
@@ -74,6 +80,11 @@ export function NewWorkspaceComposerAdvancedSection({
branchNameInputId,
branchNameOverride,
onBranchNameOverrideChange,
parentWorktreeId = null,
onParentWorktreeIdChange,
selectedRepoExecutionHostId,
selectedRepoProjectId,
activeFolderWorkspaceId = null,
note,
onNoteChange,
setupControlsEnabled = true,
@@ -191,6 +202,18 @@ export function NewWorkspaceComposerAdvancedSection({
</div>
) : null}
{selectedRepoIsGit && branchesEnabled && onParentWorktreeIdChange ? (
<ComposerParentWorktreePicker
repoId={repoId}
executionHostId={selectedRepoExecutionHostId}
projectId={selectedRepoProjectId}
value={parentWorktreeId}
onChange={onParentWorktreeIdChange}
activeFolderWorkspaceId={activeFolderWorkspaceId}
disabled={!advancedOpen}
/>
) : null}
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
{translate('auto.components.NewWorkspaceComposerCard.f8728aa4f9', 'Note')}
@@ -18,6 +18,7 @@ import type { SparsePreset } from '../../../../shared/worktree/create-types'
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
import type { TaskSourceContext } from '../../../../shared/task-source-context'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { ExecutionHostId } from '../../../../shared/execution-host'
export type RepoOption = React.ComponentProps<typeof RepoCombobox>['repos'][number]
export type EphemeralVmRecipeOption = NonNullable<OrcaHooks['environmentRecipes']>[number]
@@ -61,6 +62,11 @@ export type NewWorkspaceComposerCardProps = {
onNameValueChange: (value: string) => void
branchNameOverride: string | undefined
onBranchNameOverrideChange: (value: string | undefined) => void
parentWorktreeId?: string | null
onParentWorktreeIdChange?: (value: string | null) => void
selectedRepoExecutionHostId?: ExecutionHostId | null
selectedRepoProjectId?: string | null
activeFolderWorkspaceId?: string | null
onSmartGitHubItemSelect: (item: GitHubWorkItem) => void
onSmartGitLabItemSelect: (item: GitLabWorkItem) => void
onSmartBranchSelect: (refName: string, localBranchName: string) => void
@@ -131,7 +131,8 @@ function getWorktreeById(
)
}
function getLineageChildWorktree(
/** Exported so the composer's parent picker offers exactly the roots this view attaches. */
export function getLineageChildWorktree(
lineage: WorkspaceLineage,
worktreeById: Map<string, Worktree>
): Worktree | null {
@@ -14,6 +14,9 @@ export type ComposerCardSourceProps = Pick<
| 'ephemeralVmRecipeError'
| 'name'
| 'branchNameOverride'
| 'parentWorktreeId'
| 'selectedRepoExecutionHostId'
| 'selectedRepoProjectId'
| 'smartNameSelection'
| 'reuseSelectedBranch'
| 'createMultiple'
@@ -63,6 +66,7 @@ export type ComposerCardActionProps = {
smartNameRepoSwitchTarget?: 'project' | 'task-source'
onNameValueChange: ComposerModel['handleNameValueChange']
onBranchNameOverrideChange: ComposerModel['handleBranchNameOverrideChange']
onParentWorktreeIdChange: ComposerModel['setParentWorktreeId']
onSmartGitHubItemSelect: ComposerModel['handleSmartGitHubItemSelect']
onSmartGitLabItemSelect: ComposerModel['handleSmartGitLabItemSelect']
onSmartBranchSelect: ComposerModel['handleSmartBranchSelect']
@@ -13,6 +13,7 @@ export function buildComposerCardProps(state: ComposerModel) {
attachmentPaths,
baseBranch,
branchNameOverride,
parentWorktreeId,
createGateMode,
createError,
createMultiple,
@@ -81,10 +82,12 @@ export function buildComposerCardProps(state: ComposerModel) {
selectedProjectHostSetupId,
selectedProjectId,
selectedRepo,
selectedRepoExecutionHostId,
selectedRepoConnectInProgress,
selectedRepoConnectionId,
selectedRepoGitHubSourceContext,
selectedRepoIsGit,
selectedRepoProjectId,
selectedRepoRequiresConnection,
shouldWaitForIssueAutomationCheck,
sourceIntentBlocksCreate,
@@ -96,6 +99,7 @@ export function buildComposerCardProps(state: ComposerModel) {
setCreateMultiple,
setLinkQuery,
setNote,
setParentWorktreeId,
setSelectedEphemeralVmRecipeId,
setSetupDecision,
setSmartNameMode,
@@ -157,6 +161,10 @@ export function buildComposerCardProps(state: ComposerModel) {
onNameValueChange: handleNameValueChange,
branchNameOverride: isProjectGroupTarget ? undefined : branchNameOverride,
onBranchNameOverrideChange: isProjectGroupTarget ? () => {} : handleBranchNameOverrideChange,
parentWorktreeId: isProjectGroupTarget ? null : parentWorktreeId,
onParentWorktreeIdChange: isProjectGroupTarget ? () => {} : setParentWorktreeId,
selectedRepoExecutionHostId: isProjectGroupTarget ? null : selectedRepoExecutionHostId,
selectedRepoProjectId: isProjectGroupTarget ? null : selectedRepoProjectId,
onSmartGitHubItemSelect: handleSmartGitHubItemSelect,
onSmartGitLabItemSelect: handleSmartGitLabItemSelect,
onSmartBranchSelect: isProjectGroupTarget ? () => {} : handleSmartBranchSelect,
@@ -55,6 +55,7 @@ export type ComposerStoreActions = {
linkedTaskSourceContext?: TaskSourceContext | null
startupDraft?: string
nameWasGenerated?: boolean
parentWorktreeId?: string
provisionedRoot?: {
runtimeId: string
executionHostId: ExecutionHostId
@@ -98,6 +98,7 @@ export function useComposerSubmitOrchestration(
normalizedSparseDirectories: target.derivedComposerState.normalizedSparseDirectories,
note: target.sourceContextState.note,
onCreated: target.composerTargetStore.onCreated,
parentWorktreeId: target.workspaceIdentityState.parentWorktreeId,
persistDraft: target.composerTargetStore.persistDraft,
persistSetupAgentStartupPolicy: target.providerRuntimeSync.persistSetupAgentStartupPolicy,
prepareFullSubmit: fullSubmitPreparation.prepareFullSubmit,
@@ -210,6 +211,7 @@ export function useComposerSubmitOrchestration(
linkedGitLabMR: target.workspaceIdentityState.linkedGitLabMR,
normalizedSparseDirectories: target.derivedComposerState.normalizedSparseDirectories,
onCreated: target.composerTargetStore.onCreated,
parentWorktreeId: target.workspaceIdentityState.parentWorktreeId,
persistDraft: target.composerTargetStore.persistDraft,
persistSetupAgentStartupPolicy: target.providerRuntimeSync.persistSetupAgentStartupPolicy,
prepareQuickSubmit: quickSubmitPreparation.prepareQuickSubmit,
@@ -72,6 +72,7 @@ describe('useFullCreationExecution cancellation', () => {
normalizedSparseDirectories: [],
note: '',
onCreated: vi.fn<NonNullable<FullCreationExecutionInput['onCreated']>>(),
parentWorktreeId: null,
persistDraft: false,
persistSetupAgentStartupPolicy,
prepareFullSubmit: vi
@@ -12,6 +12,7 @@ export type FullCreationExecutionInput = Pick<
| 'normalizedSparseDirectories'
| 'note'
| 'onCreated'
| 'parentWorktreeId'
| 'persistDraft'
| 'persistSetupAgentStartupPolicy'
| 'prepareFullSubmit'
@@ -47,6 +48,7 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) {
normalizedSparseDirectories,
note,
onCreated,
parentWorktreeId,
persistDraft,
persistSetupAgentStartupPolicy,
prepareFullSubmit,
@@ -155,7 +157,8 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) {
nameWasGenerated,
...(!backendStartup && startupPlan?.draftPrompt
? { startupDraft: startupPlan.draftPrompt }
: {})
: {}),
...(parentWorktreeId ? { parentWorktreeId } : {})
}
)
@@ -251,6 +254,7 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) {
normalizedSparseDirectories,
note,
onCreated,
parentWorktreeId,
persistDraft,
persistSetupAgentStartupPolicy,
prepareFullSubmit,
@@ -18,6 +18,8 @@ export type ComposerIdentityModel = {
setCompareBaseRef: React.Dispatch<React.SetStateAction<string | undefined>>
branchNameOverride: string | undefined
setBranchNameOverride: React.Dispatch<React.SetStateAction<string | undefined>>
parentWorktreeId: string | null
setParentWorktreeId: React.Dispatch<React.SetStateAction<string | null>>
branchNameOverridePreservesNameEdits: boolean
setBranchNameOverridePreservesNameEdits: React.Dispatch<React.SetStateAction<boolean>>
smartNameMode: SmartNameMode
@@ -12,6 +12,7 @@ type QuickCreationExecutionInput = Pick<
| 'linkedGitLabMR'
| 'normalizedSparseDirectories'
| 'onCreated'
| 'parentWorktreeId'
| 'persistDraft'
| 'persistSetupAgentStartupPolicy'
| 'prepareQuickSubmit'
@@ -58,6 +59,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) {
linkedGitLabMR,
normalizedSparseDirectories,
onCreated,
parentWorktreeId,
persistDraft,
persistSetupAgentStartupPolicy,
prepareQuickSubmit,
@@ -217,6 +219,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) {
linkedLinearIssueWorkspaceId,
linkedLinearIssueOrganizationUrlKey,
branchNameOverride: effectiveBranchNameOverride,
parentWorktreeId,
workspaceStatus: resolvedInitialWorkspaceStatus,
linkedGitLabMR,
linkedGitLabIssue,
@@ -259,6 +262,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) {
linkedGitLabMR,
normalizedSparseDirectories,
onCreated,
parentWorktreeId,
persistDraft,
persistSetupAgentStartupPolicy,
prepareQuickSubmit,
@@ -29,6 +29,7 @@ function createInput(
linkedLinearIssueWorkspaceId: undefined,
linkedLinearIssueOrganizationUrlKey: undefined,
branchNameOverride: undefined,
parentWorktreeId: null,
workspaceStatus: undefined,
linkedGitLabMR: null,
linkedGitLabIssue: null,
@@ -87,6 +88,7 @@ describe('quick composer creation request', () => {
linkedLinearIssueWorkspaceId: 'workspace-1',
linkedLinearIssueOrganizationUrlKey: 'orca',
branchNameOverride: 'feature',
parentWorktreeId: 'repo-1::/worktrees/parent',
workspaceStatus: 'in-progress',
linkedGitLabMR: 9,
linkedGitLabIssue: 8,
@@ -107,6 +109,7 @@ describe('quick composer creation request', () => {
linkedGitLabMR: 9,
linkedGitLabIssue: 8,
branchNameOverride: 'feature',
parentWorktreeId: 'repo-1::/worktrees/parent',
launchDraftPrompt: 'draft',
suppressTerminalFocusOnCompletion: true
})
@@ -33,6 +33,7 @@ export type QuickCreationRequestInput = {
linkedLinearIssueWorkspaceId: string | undefined
linkedLinearIssueOrganizationUrlKey: string | undefined
branchNameOverride: string | undefined
parentWorktreeId: string | null
workspaceStatus: WorktreeCreationRequest['workspaceStatus']
linkedGitLabMR: number | null
linkedGitLabIssue: number | null
@@ -88,6 +89,7 @@ export function buildQuickCreationRequest(
? { linkedLinearIssueOrganizationUrlKey: input.linkedLinearIssueOrganizationUrlKey }
: {}),
...(input.branchNameOverride ? { branchNameOverride: input.branchNameOverride } : {}),
...(input.parentWorktreeId ? { parentWorktreeId: input.parentWorktreeId } : {}),
...(input.workspaceStatus ? { workspaceStatus: input.workspaceStatus } : {}),
...(input.includeGitLabLinks && input.linkedGitLabMR != null
? { linkedGitLabMR: input.linkedGitLabMR }
@@ -100,6 +100,8 @@ export function useWorkspaceIdentityState(input: WorkspaceIdentityStateInput) {
initialLinearBranchName
)
const [parentWorktreeId, setParentWorktreeId] = useState<string | null>(null)
const [branchNameOverridePreservesNameEdits, setBranchNameOverridePreservesNameEdits] = useState(
Boolean(initialLinearBranchName)
)
@@ -195,6 +197,8 @@ export function useWorkspaceIdentityState(input: WorkspaceIdentityStateInput) {
setCompareBaseRef,
branchNameOverride,
setBranchNameOverride,
parentWorktreeId,
setParentWorktreeId,
branchNameOverridePreservesNameEdits,
setBranchNameOverridePreservesNameEdits,
smartNameMode,
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { renderHook } from '@testing-library/react'
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { useComposerState } from './useComposerState'
@@ -31,7 +31,7 @@ afterEach(() => {
})
describe('useComposerState integrated lifecycle', () => {
it('composes two live composers and releases each native drop listener on unmount', () => {
it('composes two live composers and exposes the parent-worktree control state', () => {
useAppStore.setState({
repos: [],
projects: [],
@@ -57,6 +57,10 @@ describe('useComposerState integrated lifecycle', () => {
expect(first.result.current.cardProps.name).toBe('first')
expect(second.result.current.cardProps.name).toBe('second')
expect(first.result.current.cardProps.parentWorktreeId).toBeNull()
expect(first.result.current.cardProps.onParentWorktreeIdChange).toBeTypeOf('function')
act(() => first.result.current.cardProps.onParentWorktreeIdChange('repo-1::/parent'))
expect(first.result.current.cardProps.parentWorktreeId).toBe('repo-1::/parent')
expect(window.api.ui.onFileDrop).toHaveBeenCalledTimes(2)
second.unmount()
+11 -1
View File
@@ -448,7 +448,10 @@
}
}
}
}
},
"createdWithoutParentNesting": "Created without nesting under \"{{value0}}\"",
"createdWithoutParentNestingUnnamed": "Created without nesting under the selected parent",
"createdWithoutParentNestingDetail": "The parent workspace was no longer available. You can set it from the workspace menu."
},
"repos": {
"2975400634": "Update Orca server to open non-Git folders on this runtime.",
@@ -16305,6 +16308,13 @@
"remoteLabel": "Browsers on {{value0}}",
"clientNoteLocalStorage": "Imports read this device’s browsers. Cookies are stored locally.",
"remoteNoteRemoteStorage": "Imports read browsers on {{value0}}. Cookies are stored on that machine, and a permission prompt may appear on its screen."
},
"ComposerParentWorktreePicker": {
"label": "Parent worktree",
"noParent": "No parent",
"description": "Nests this workspace under another in the sidebar. Does not change the base branch.",
"searchPlaceholder": "Search workspaces...",
"noMatches": "No matches."
}
},
"i18n": {
+11 -1
View File
@@ -263,7 +263,10 @@
"3b57982bf6": "Git no pudo eliminar de forma segura la rama \"{{value0}}\" después de eliminar el espacio de trabajo \"{{value1}}\", así que Orca la conservó para evitar perder commits locales.",
"81f13f48d2": "Git no pudo eliminar de forma segura la rama \"{{value0}}\" después de eliminar el worktree \"{{value1}}\", así que Orca la conservó para evitar perder commits locales.",
"runtimeScopeForbiddenTitle": "Esta conexión tiene acceso limitado (móvil)",
"runtimeScopeForbiddenDescription": "Los espacios de trabajo no están disponibles con un emparejamiento de acceso móvil. Vuelve a conectarte con el enlace del navegador desde Ajustes → Servidores remotos de Orca → Comparte este servidor Orca."
"runtimeScopeForbiddenDescription": "Los espacios de trabajo no están disponibles con un emparejamiento de acceso móvil. Vuelve a conectarte con el enlace del navegador desde Ajustes → Servidores remotos de Orca → Comparte este servidor Orca.",
"createdWithoutParentNesting": "Creado sin anidarlo bajo \"{{value0}}\"",
"createdWithoutParentNestingUnnamed": "Creado sin anidarlo bajo el worktree padre seleccionado",
"createdWithoutParentNestingDetail": "El espacio de trabajo padre ya no estaba disponible. Puedes establecerlo desde el menú del espacio de trabajo."
},
"repos": {
"2975400634": "Actualiza el servidor Orca para abrir carpetas que no sean Git en este host.",
@@ -14433,6 +14436,13 @@
"deleteDescription": "“{{name}}” ya no estará disponible en su enlace público.",
"delete": "Eliminar"
}
},
"ComposerParentWorktreePicker": {
"label": "Worktree padre",
"noParent": "Sin worktree padre",
"description": "Anida este espacio de trabajo bajo otro en la barra lateral. No cambia la rama base.",
"searchPlaceholder": "Buscar espacios de trabajo...",
"noMatches": "No hay coincidencias."
}
},
"i18n": {
+11 -1
View File
@@ -263,7 +263,10 @@
"3b57982bf6": "ワークスペース\"{{value1}}\"を削除した後、Git はブランチ\"{{value0}}\"を安全に削除できなかったため、Orca はローカルコミットを失わないように保持しました。",
"81f13f48d2": "ワークツリー\"{{value1}}\"を削除した後、Git はブランチ\"{{value0}}\"を安全に削除できなかったため、Orca はローカルコミットを失わないように保持しました。",
"runtimeScopeForbiddenTitle": "この接続は制限付き(モバイル)アクセスです",
"runtimeScopeForbiddenDescription": "モバイル向けペアリングではワークスペースを使用できません。[設定] → [リモート Orca サーバー] → [この Orca サーバーを共有する] から、ブラウザ用リンクで再接続してください。"
"runtimeScopeForbiddenDescription": "モバイル向けペアリングではワークスペースを使用できません。[設定] → [リモート Orca サーバー] → [この Orca サーバーを共有する] から、ブラウザ用リンクで再接続してください。",
"createdWithoutParentNesting": "「{{value0}}」の配下にネストせずに作成しました",
"createdWithoutParentNestingUnnamed": "選択した親ワークツリーの配下にネストせずに作成しました",
"createdWithoutParentNestingDetail": "親ワークスペースは利用できなくなりました。ワークスペースメニューから設定できます。"
},
"repos": {
"2975400634": "Orca サーバーを更新して、このランタイムで Git 以外のフォルダーを開くようにします。",
@@ -14424,6 +14427,13 @@
"deleteDescription": "「{{name}}」は公開リンクで利用できなくなります。",
"delete": "削除"
}
},
"ComposerParentWorktreePicker": {
"label": "親ワークツリー",
"noParent": "親なし",
"description": "このワークスペースをサイドバー内の別のワークスペースの配下にネストします。ベースブランチは変更されません。",
"searchPlaceholder": "ワークスペースを検索...",
"noMatches": "一致する項目がありません。"
}
},
"i18n": {
+82 -7
View File
@@ -266,7 +266,10 @@
"3b57982bf6": "\"{{value1}}\" 워크스페이스를 삭제한 후 Git이 \"{{value0}}\" 브랜치를 안전하게 삭제할 수 없어 Orca가 로컬 커밋 손실을 방지하기 위해 유지했습니다.",
"81f13f48d2": "\"{{value1}}\" 워크트리를 삭제한 후 Git이 \"{{value0}}\" 브랜치를 안전하게 삭제할 수 없어 Orca가 로컬 커밋 손실을 방지하기 위해 유지했습니다.",
"runtimeScopeForbiddenTitle": "이 연결은 제한된(모바일) 액세스입니다",
"runtimeScopeForbiddenDescription": "모바일 범위 페어링에서는 워크스페이스를 사용할 수 없습니다. 설정 → 원격 Orca 서버 → 이 Orca 서버 공유에서 브라우저 액세스 링크로 다시 연결하세요."
"runtimeScopeForbiddenDescription": "모바일 범위 페어링에서는 워크스페이스를 사용할 수 없습니다. 설정 → 원격 Orca 서버 → 이 Orca 서버 공유에서 브라우저 액세스 링크로 다시 연결하세요.",
"createdWithoutParentNesting": "\"{{value0}}\" 아래에 중첩하지 않고 생성했습니다",
"createdWithoutParentNestingUnnamed": "선택한 상위 워크트리 아래에 중첩하지 않고 생성했습니다",
"createdWithoutParentNestingDetail": "상위 워크스페이스를 더 이상 사용할 수 없습니다. 워크스페이스 메뉴에서 설정할 수 있습니다."
},
"repos": {
"2975400634": "이 런타임에서 Git이 아닌 폴더를 열려면 Orca 서버를 업데이트하세요.",
@@ -3879,17 +3882,82 @@
},
"skills": {
"SkillsPage": {
"cb142070b4": "새로 고침", "a68dee6a32": "스킬 검색", "f43ad6edf3": "스킬", "ea72d6185b": "스킬을 검색하지 못했습니다", "dc4c3328ee": "파일 표시", "9963dff6d3": "설명을 찾을 수 없습니다.", "995fde8337": "스킬 파일을 표시하지 못했습니다", "4acd6d68ec": "스킬을 찾을 수 없습니다", "6a62a0168c": "일치하는 항목 없음", "08a321a984": "현재 검색 및 필터와 일치하는 스킬이 없습니다.", "cd7893fbc1": "스킬 검색 중", "35b9a724a0": "사용 가능", "c13b82793c": "설치 관리", "aee7b99cc6": "링크에서 설치", "filterProvider": "에이전트별 필터", "filterSource": "소스별 필터", "allSources": "전체", "clearFilters": "필터 지우기", "closeSkills": "스킬 닫기", "closeTooltip": "닫기 · Esc", "moreActions": "추가 작업", "sharedLinks": "공유 링크", "emptyCopy": "검색한 스킬 폴더가 비어 있습니다. 공유 번들을 설치하거나 스킬을 추가한 후 새로 고치세요.", "retry": "다시 시도", "remoteShareNotice": "이 스킬은 {{host}}에 있습니다. 공유하려면 해당 머신에서 스킬을 여세요.", "viewSwitch": "표시", "searchLinks": "링크 검색", "deleteSkills": "스킬 삭제…"
"cb142070b4": "새로 고침",
"a68dee6a32": "스킬 검색",
"f43ad6edf3": "스킬",
"ea72d6185b": "스킬을 검색하지 못했습니다",
"dc4c3328ee": "파일 표시",
"9963dff6d3": "설명을 찾을 수 없습니다.",
"995fde8337": "스킬 파일을 표시하지 못했습니다",
"4acd6d68ec": "스킬을 찾을 수 없습니다",
"6a62a0168c": "일치하는 항목 없음",
"08a321a984": "현재 검색 및 필터와 일치하는 스킬이 없습니다.",
"cd7893fbc1": "스킬 검색 중",
"35b9a724a0": "사용 가능",
"c13b82793c": "설치 관리",
"aee7b99cc6": "링크에서 설치",
"filterProvider": "에이전트별 필터",
"filterSource": "소스별 필터",
"allSources": "전체",
"clearFilters": "필터 지우기",
"closeSkills": "스킬 닫기",
"closeTooltip": "닫기 · Esc",
"moreActions": "추가 작업",
"sharedLinks": "공유 링크",
"emptyCopy": "검색한 스킬 폴더가 비어 있습니다. 공유 번들을 설치하거나 스킬을 추가한 후 새로 고치세요.",
"retry": "다시 시도",
"remoteShareNotice": "이 스킬은 {{host}}에 있습니다. 공유하려면 해당 머신에서 스킬을 여세요.",
"viewSwitch": "표시",
"searchLinks": "링크 검색",
"deleteSkills": "스킬 삭제…"
},
"SkillShareSelectionControls": { "01c5a15e02": "스킬 공유" },
"SkillRow": { "updatedUnknown": "날짜 없음", "pathCopied": "경로 복사됨", "copyPath": "경로 복사", "detailPath": "경로", "skillActions": "{{value0}} 작업", "viewDetails": "세부 정보 보기", "deleteSkill": "삭제…" },
"SkillRow": {
"updatedUnknown": "날짜 없음",
"pathCopied": "경로 복사됨",
"copyPath": "경로 복사",
"detailPath": "경로",
"skillActions": "{{value0}} 작업",
"viewDetails": "세부 정보 보기",
"deleteSkill": "삭제…"
},
"SkillsList": { "listLabel": "스킬" },
"sourceStatus": { "missing": "폴더를 찾을 수 없음", "remoteRepo": "원격 리포지토리 — 검색 안 됨", "unavailable": "검색 안 됨" },
"sourceStatus": {
"missing": "폴더를 찾을 수 없음",
"remoteRepo": "원격 리포지토리 — 검색 안 됨",
"unavailable": "검색 안 됨"
},
"sources": { "heading": "스킬 폴더" },
"sourceKind": { "home": "홈", "workspace": "워크스페이스", "bundled": "번들", "plugin": "플러그인" },
"count": { "skillOne": "스킬 {{count}}개", "skillOther": "스킬 {{count}}개", "sourceOne": "소스 {{count}}개", "sourceOther": "소스 {{count}}개", "fileOne": "파일 {{count}}개", "fileOther": "파일 {{count}}개", "resultOne": "결과 {{count}}개", "resultOther": "결과 {{count}}개", "selected": "{{count}}개 선택됨", "shareOne": "스킬 {{count}}개 공유", "shareOther": "스킬 {{count}}개 공유", "linkOne": "링크 {{count}}개", "linkOther": "링크 {{count}}개" },
"sourceKind": {
"home": "홈",
"workspace": "워크스페이스",
"bundled": "번들",
"plugin": "플러그인"
},
"count": {
"skillOne": "스킬 {{count}}개",
"skillOther": "스킬 {{count}}개",
"sourceOne": "소스 {{count}}개",
"sourceOther": "소스 {{count}}개",
"fileOne": "파일 {{count}}개",
"fileOther": "파일 {{count}}개",
"resultOne": "결과 {{count}}개",
"resultOther": "결과 {{count}}개",
"selected": "{{count}}개 선택됨",
"shareOne": "스킬 {{count}}개 공유",
"shareOther": "스킬 {{count}}개 공유",
"linkOne": "링크 {{count}}개",
"linkOther": "링크 {{count}}개"
},
"filter": { "allAgents": "모든 에이전트", "sharedAgent": "공유됨 (.agents)" },
"SkillsSelectionHeader": { "exit": "선택 나가기", "exitTooltip": "선택 나가기 · Esc", "title": "공유할 스킬 선택", "selectAll": "가능한 {{count}}개 모두 선택", "clear": "지우기", "deleteTitle": "삭제할 스킬 선택" },
"SkillsSelectionHeader": {
"exit": "선택 나가기",
"exitTooltip": "선택 나가기 · Esc",
"title": "공유할 스킬 선택",
"selectAll": "가능한 {{count}}개 모두 선택",
"clear": "지우기",
"deleteTitle": "삭제할 스킬 선택"
},
"SkillDetailDialog": { "agents": "에이전트", "updated": "업데이트됨", "copy": "복사" },
"SkillFreshnessNudge": {
"titleOne": "설치된 Orca 스킬이 오래되었습니다",
@@ -14391,6 +14459,13 @@
"deleteDescription": "“{{name}}”은(는) 공개 링크에서 더 이상 사용할 수 없습니다.",
"delete": "삭제"
}
},
"ComposerParentWorktreePicker": {
"label": "상위 워크트리",
"noParent": "상위 워크트리 없음",
"description": "이 워크스페이스를 사이드바의 다른 워크스페이스 아래에 중첩합니다. 기준 브랜치는 변경되지 않습니다.",
"searchPlaceholder": "워크스페이스 검색...",
"noMatches": "일치하는 항목이 없습니다."
}
},
"i18n": {
+90 -7
View File
@@ -266,7 +266,10 @@
"3b57982bf6": "删除工作区\"{{value1}}\"后,Git 无法安全删除分支\"{{value0}}\",因此 Orca 将其保留以避免丢失本地提交。",
"81f13f48d2": "删除工作树\"{{value1}}\"后,Git 无法安全删除分支\"{{value0}}\",因此 Orca 将其保留以避免丢失本地提交。",
"runtimeScopeForbiddenTitle": "此连接具有受限(移动端)访问权限",
"runtimeScopeForbiddenDescription": "移动端范围的配对无法使用工作区。请通过设置 → 远程 Orca 服务器 → 分享此 Orca 服务器中的浏览器访问链接重新连接。"
"runtimeScopeForbiddenDescription": "移动端范围的配对无法使用工作区。请通过设置 → 远程 Orca 服务器 → 分享此 Orca 服务器中的浏览器访问链接重新连接。",
"createdWithoutParentNesting": "已创建,但未嵌套在“{{value0}}”下",
"createdWithoutParentNestingUnnamed": "已创建,但未嵌套在所选父工作树下",
"createdWithoutParentNestingDetail": "父工作区已不可用。您可以从工作区菜单中重新设置。"
},
"repos": {
"2975400634": "请更新 Orca 服务器,以便在此运行时打开非 Git 文件夹。",
@@ -3889,17 +3892,90 @@
},
"skills": {
"SkillsPage": {
"cb142070b4": "刷新", "a68dee6a32": "搜索技能", "f43ad6edf3": "技能", "ea72d6185b": "无法扫描技能", "dc4c3328ee": "显示文件", "9963dff6d3": "未找到说明。", "995fde8337": "无法显示技能文件", "4acd6d68ec": "未找到技能", "6a62a0168c": "无匹配项", "08a321a984": "没有技能匹配当前搜索和筛选条件。", "cd7893fbc1": "正在扫描技能", "35b9a724a0": "可用", "c13b82793c": "管理安装", "aee7b99cc6": "从链接安装", "filterProvider": "按 Agent 筛选", "filterSource": "按来源筛选", "allSources": "全部", "clearFilters": "清除筛选", "closeSkills": "关闭技能", "closeTooltip": "关闭 · Esc", "moreActions": "更多操作", "sharedLinks": "共享链接", "emptyCopy": "扫描的技能文件夹为空。请安装共享包,或添加技能后刷新。", "retry": "重试", "remoteShareNotice": "这些技能位于 {{host}}。请在该机器上打开“技能”以进行共享。", "viewSwitch": "显示", "searchLinks": "搜索链接", "deleteSkills": "删除技能…"
"cb142070b4": "刷新",
"a68dee6a32": "搜索技能",
"f43ad6edf3": "技能",
"ea72d6185b": "无法扫描技能",
"dc4c3328ee": "显示文件",
"9963dff6d3": "未找到说明。",
"995fde8337": "无法显示技能文件",
"4acd6d68ec": "未找到技能",
"6a62a0168c": "无匹配项",
"08a321a984": "没有技能匹配当前搜索和筛选条件。",
"cd7893fbc1": "正在扫描技能",
"35b9a724a0": "可用",
"c13b82793c": "管理安装",
"aee7b99cc6": "从链接安装",
"filterProvider": "按 Agent 筛选",
"filterSource": "按来源筛选",
"allSources": "全部",
"clearFilters": "清除筛选",
"closeSkills": "关闭技能",
"closeTooltip": "关闭 · Esc",
"moreActions": "更多操作",
"sharedLinks": "共享链接",
"emptyCopy": "扫描的技能文件夹为空。请安装共享包,或添加技能后刷新。",
"retry": "重试",
"remoteShareNotice": "这些技能位于 {{host}}。请在该机器上打开“技能”以进行共享。",
"viewSwitch": "显示",
"searchLinks": "搜索链接",
"deleteSkills": "删除技能…"
},
"SkillShareSelectionControls": { "01c5a15e02": "共享技能" },
"SkillRow": { "updatedUnknown": "无日期", "pathCopied": "路径已复制", "copyPath": "复制路径", "detailPath": "路径", "skillActions": "{{value0}} 的操作", "viewDetails": "查看详情", "deleteSkill": "删除…" },
"SkillRow": {
"updatedUnknown": "无日期",
"pathCopied": "路径已复制",
"copyPath": "复制路径",
"detailPath": "路径",
"skillActions": "{{value0}} 的操作",
"viewDetails": "查看详情",
"deleteSkill": "删除…"
},
"SkillsList": { "listLabel": "技能" },
"sourceStatus": { "missing": "未找到文件夹", "remoteRepo": "远程仓库 — 未扫描", "unavailable": "未扫描" },
"sourceStatus": {
"missing": "未找到文件夹",
"remoteRepo": "远程仓库 — 未扫描",
"unavailable": "未扫描"
},
"sources": { "heading": "技能文件夹" },
"sourceKind": { "home": "主目录", "workspace": "工作区", "bundled": "内置", "plugin": "插件" },
"count": { "skillOne": "{{count}} 个技能", "skillOther": "{{count}} 个技能", "sourceOne": "{{count}} 个来源", "sourceOther": "{{count}} 个来源", "fileOne": "{{count}} 个文件", "fileOther": "{{count}} 个文件", "resultOne": "{{count}} 个结果", "resultOther": "{{count}} 个结果", "selected": "已选择 {{count}} 个", "shareOne": "共享 {{count}} 个技能", "shareOther": "共享 {{count}} 个技能", "linkOne": "{{count}} 个链接", "linkOther": "{{count}} 个链接", "deleteOne": "删除 {{count}} 个技能", "deleteOther": "删除 {{count}} 个技能", "deletedOne": "已删除 {{count}} 个技能", "deletedOther": "已删除 {{count}} 个技能", "deleteFolderOne": "{{count}} 个文件夹", "deleteFolderOther": "{{count}} 个文件夹", "deleteLinkOne": "{{count}} 个链接", "deleteLinkOther": "{{count}} 个链接" },
"sourceKind": {
"home": "主目录",
"workspace": "工作区",
"bundled": "内置",
"plugin": "插件"
},
"count": {
"skillOne": "{{count}} 个技能",
"skillOther": "{{count}} 个技能",
"sourceOne": "{{count}} 个来源",
"sourceOther": "{{count}} 个来源",
"fileOne": "{{count}} 个文件",
"fileOther": "{{count}} 个文件",
"resultOne": "{{count}} 个结果",
"resultOther": "{{count}} 个结果",
"selected": "已选择 {{count}} 个",
"shareOne": "共享 {{count}} 个技能",
"shareOther": "共享 {{count}} 个技能",
"linkOne": "{{count}} 个链接",
"linkOther": "{{count}} 个链接",
"deleteOne": "删除 {{count}} 个技能",
"deleteOther": "删除 {{count}} 个技能",
"deletedOne": "已删除 {{count}} 个技能",
"deletedOther": "已删除 {{count}} 个技能",
"deleteFolderOne": "{{count}} 个文件夹",
"deleteFolderOther": "{{count}} 个文件夹",
"deleteLinkOne": "{{count}} 个链接",
"deleteLinkOther": "{{count}} 个链接"
},
"filter": { "allAgents": "所有 Agent", "sharedAgent": "共享 (.agents)" },
"SkillsSelectionHeader": { "exit": "退出选择", "exitTooltip": "退出选择 · Esc", "title": "选择要共享的技能", "selectAll": "选择全部 {{count}} 个符合条件的项目", "clear": "清除", "deleteTitle": "选择要删除的技能" },
"SkillsSelectionHeader": {
"exit": "退出选择",
"exitTooltip": "退出选择 · Esc",
"title": "选择要共享的技能",
"selectAll": "选择全部 {{count}} 个符合条件的项目",
"clear": "清除",
"deleteTitle": "选择要删除的技能"
},
"SkillDetailDialog": { "agents": "Agent", "updated": "已更新", "copy": "复制" },
"SkillFreshnessNudge": {
"titleOne": "已安装的 Orca 技能已过期",
@@ -14407,6 +14483,13 @@
"deleteDescription": "“{{name}}” 将无法再通过其公共链接访问。",
"delete": "删除"
}
},
"ComposerParentWorktreePicker": {
"label": "父工作树",
"noParent": "无父工作树",
"description": "在侧边栏中将此工作区嵌套到另一个工作区下。不会更改基础分支。",
"searchPlaceholder": "搜索工作区...",
"noMatches": "无匹配项。"
}
},
"i18n": {
@@ -79,6 +79,8 @@ export type WorktreeCreationRequest = {
linkedLinearIssueWorkspaceId?: string | null
linkedLinearIssueOrganizationUrlKey?: string | null
branchNameOverride?: string
/** Parent picked in the composer's Advanced drawer. Sidebar nesting only, no git effect. */
parentWorktreeId?: string
workspaceStatus?: WorkspaceStatus
linkedGitLabMR?: number
linkedGitLabIssue?: number
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const FLOW_SOURCE = readFileSync(join(__dirname, 'worktree-creation-flow.ts'), 'utf8')
const FLOW_SOURCE = readFileSync(join(__dirname, 'worktree-creation-flow-execute.ts'), 'utf8')
function sourceBetween(source: string, startPattern: string, endPattern: string): string {
const start = source.indexOf(startPattern)
@@ -0,0 +1,240 @@
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import { activateAndRevealWorktree, type ActivateAndRevealResult } from '@/lib/worktree-activation'
import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding'
import { ensureAgentStartupInTerminal } from '@/lib/new-workspace'
import { queueWorkspaceActivationTerminalFocus } from '@/lib/workspace-activation-terminal-focus'
import {
attachEphemeralVmRuntimeToWorkspace,
cleanupEphemeralVmRuntimeForFailedCreate,
prepareRequestForCreate
} from '@/lib/ephemeral-vm-worktree-creation'
import { getProvisionedRootCreateOptions } from '@/lib/provisioned-root-create-options'
import {
formatWorkspaceCreateError,
getWorkspaceCreateErrorToastMessage
} from '@/lib/workspace-create-error-format'
import type { CreateWorktreeResult } from '../../../shared/worktree/create-types'
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { seedAgentTabStateAfterWorktreeCreate } from '@/lib/worktree-creation-agent-seeds'
import { resolveBackendDraftStartup } from '@/lib/worktree-draft-startup-view-mode'
import { buildWorktreeCreationStartupOpt } from '@/lib/worktree-creation-flow-startup'
// Why: activePendingCreationId can outlive the terminal route when the user
// switches app views; only the terminal route renders the creation panel.
function isPendingCreationSurfaceVisible(creationId: string): boolean {
const state = useAppStore.getState()
return state.activeView === 'terminal' && state.activePendingCreationId === creationId
}
async function preflightAgentTrust(
request: WorktreeCreationRequest,
path: string,
connectionId?: string | null
): Promise<void> {
// Why: trust-gated agents (cursor-agent, copilot) consume the bracketed paste
// as menu input on first launch. Pre-write the trust artifact before any
// terminal spawns. Best-effort — the worktree already exists, so a failure
// here must not strand it.
if (!request.agent || !window.api.agentTrust?.markTrusted) {
return
}
const preflight = TUI_AGENT_CONFIG[request.agent].preflightTrust
if (!preflight) {
return
}
try {
await window.api.agentTrust.markTrusted({
preset: preflight,
workspacePath: path,
...(connectionId ? { connectionId } : {})
})
} catch {
// Best-effort: continue with launch.
}
}
export async function executeWorktreeCreation(
creationId: string,
request: WorktreeCreationRequest
): Promise<void> {
const preparedRequest = await prepareRequestForCreate(creationId, request)
if (!preparedRequest) {
return
}
let result: CreateWorktreeResult
try {
const provisionedRoot = getProvisionedRootCreateOptions(preparedRequest)
const backendStartup = provisionedRoot ? undefined : resolveBackendDraftStartup(preparedRequest)
result = await useAppStore
.getState()
.createWorktree(
preparedRequest.repoId,
preparedRequest.name,
preparedRequest.baseBranch,
preparedRequest.setupDecision,
preparedRequest.sparseCheckout,
preparedRequest.telemetrySource,
preparedRequest.displayName,
preparedRequest.linkedIssue,
preparedRequest.linkedPR,
preparedRequest.pushTarget,
preparedRequest.agent ?? undefined,
preparedRequest.linkedLinearIssue,
preparedRequest.branchNameOverride,
preparedRequest.workspaceStatus,
preparedRequest.linkedGitLabMR,
preparedRequest.linkedGitLabIssue,
backendStartup,
preparedRequest.pendingFirstAgentMessageRename,
creationId,
preparedRequest.linkedLinearIssueWorkspaceId,
preparedRequest.linkedLinearIssueOrganizationUrlKey,
preparedRequest.linkedBitbucketPR,
preparedRequest.linkedAzureDevOpsPR,
preparedRequest.linkedGiteaPR,
preparedRequest.compareBaseRef,
{
...(preparedRequest.nameWasGenerated ? { nameWasGenerated: true } : {}),
...(preparedRequest.linkedWorkItem !== undefined
? { linkedWorkItem: preparedRequest.linkedWorkItem }
: {}),
...(preparedRequest.linkedTaskSourceContext !== undefined
? { linkedTaskSourceContext: preparedRequest.linkedTaskSourceContext }
: {}),
// Why: the remote host must own task-draft startup so its initial terminal is the agent, not an idle fallback shell.
...(!backendStartup && preparedRequest.agent && preparedRequest.launchDraftPrompt
? { startupDraft: preparedRequest.launchDraftPrompt }
: {}),
...(provisionedRoot ? { provisionedRoot } : {}),
...(preparedRequest.parentWorktreeId
? { parentWorktreeId: preparedRequest.parentWorktreeId }
: {})
}
)
} catch (error) {
// Why: a missing entry means the user cancelled mid-flight — abandon
// silently rather than surfacing an error for work they already dismissed.
if (!useAppStore.getState().pendingWorktreeCreations[creationId]) {
return
}
if (preparedRequest.ephemeralVmRuntimeId) {
await cleanupEphemeralVmRuntimeForFailedCreate(preparedRequest)
}
const message = getWorkspaceCreateErrorToastMessage(formatWorkspaceCreateError(error))
// Why: an error must stay on the same creation surface that owns the faux
// tab strip, rather than falling back to stale previous-workspace tabs.
useAppStore.getState().updatePendingWorktreeCreation(creationId, {
status: 'error',
error: message,
...(preparedRequest.ephemeralVmRecipe ? { request } : {})
})
// Why: only toast when the panel isn't already showing this error (the user
// navigated away), so a visible failure isn't announced twice.
if (!isPendingCreationSurfaceVisible(creationId)) {
toast.error(message)
}
return
}
const worktree = result.worktree
// Why: cancellation can race a successful backend adoption; clean up again after it settles so an adopted workspace cannot outlive its destroyed VM.
if (!useAppStore.getState().pendingWorktreeCreations[creationId]) {
if (preparedRequest.ephemeralVmRuntimeId) {
await cleanupEphemeralVmRuntimeForFailedCreate(preparedRequest)
}
return
}
await attachEphemeralVmRuntimeToWorkspace(preparedRequest, worktree.id)
const backendSpawned = result.startupTerminal?.spawned === true
if (preparedRequest.startupPlan && !backendSpawned && !preparedRequest.startupPlan.launchToken) {
// Why: delayed delivery must target the exact pane spawned from this queued
// startup, so both halves of the handoff share one renderer-session token.
preparedRequest.startupPlan.launchToken = createBrowserUuid()
}
const startupOpt = buildWorktreeCreationStartupOpt(preparedRequest, backendSpawned)
if (worktree.path) {
const repoConnectionId =
useAppStore.getState().repos.find((repo) => repo.id === worktree.repoId)?.connectionId ?? null
await preflightAgentTrust(preparedRequest, worktree.path, repoConnectionId)
}
// `createWorktree` already inserted the real worktree row. Leaving for an app
// view keeps the create in the background, while selecting another workspace
// means the user still expects this task-launch handoff when it becomes ready;
// the entry guard prevents a late trust preflight from reviving a cancelled create.
const completionState = useAppStore.getState()
const shouldActivateOnCompletion =
completionState.pendingWorktreeCreations[creationId] !== undefined &&
(isPendingCreationSurfaceVisible(creationId) ||
(completionState.activeView === 'terminal' &&
completionState.activePendingCreationId === null))
let activation: ActivateAndRevealResult | false = false
let primaryTabId: string | null
if (shouldActivateOnCompletion) {
activation = activateAndRevealWorktree(worktree.id, {
sidebarRevealBehavior: 'auto',
...(result.setup ? { setup: result.setup } : {}),
...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}),
...(startupOpt ? { startup: startupOpt } : {}),
...(preparedRequest.issueCommand ? { issueCommand: preparedRequest.issueCommand } : {}),
...(backendSpawned ? { backendStartupTerminalSpawned: true } : {})
})
primaryTabId = activation === false ? null : activation.primaryTabId
} else {
// The user moved on. Seed the worktree's terminal + setup in the background
// (setActiveTab only writes global focus for the active worktree, so this is
// safe) without yanking them back to it.
primaryTabId = ensureWorktreeHasInitialTerminal(
useAppStore.getState(),
worktree.id,
startupOpt,
result.setup,
preparedRequest.issueCommand,
result.defaultTabs,
{
activateCreatedTabs: false,
...(backendSpawned ? { backendStartupTerminalSpawned: true } : {})
}
)
}
// Why: clearing synchronously right after activation lets React commit the
// panel→terminal swap in one frame — no two-row flicker, no empty-terminal flash.
useAppStore.getState().removePendingWorktreeCreation(creationId, { cleanupVm: false })
seedAgentTabStateAfterWorktreeCreate({
request: preparedRequest,
worktreeId: worktree.id,
primaryTabId,
startupTerminalTabId: result.startupTerminal?.tabId,
backendSpawned
})
if (preparedRequest.startupPlan && !backendSpawned) {
void ensureAgentStartupInTerminal({
worktreeId: worktree.id,
primaryTabId,
startup: preparedRequest.startupPlan
})
}
if (shouldActivateOnCompletion && !preparedRequest.suppressTerminalFocusOnCompletion) {
queueWorkspaceActivationTerminalFocus(worktree.id, activation)
}
// Why: awaiting the note IPC before the swap would add a visible round-trip to
// the panel→terminal transition; it's cosmetic, so it runs last.
if (preparedRequest.note) {
try {
await useAppStore.getState().updateWorktreeMeta(worktree.id, {
comment: preparedRequest.note
})
} catch {
console.error('Failed to update worktree meta after creation')
}
}
}
+1 -234
View File
@@ -1,31 +1,12 @@
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import { activateAndRevealWorktree, type ActivateAndRevealResult } from '@/lib/worktree-activation'
import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding'
import { ensureAgentStartupInTerminal } from '@/lib/new-workspace'
import { queueWorkspaceActivationTerminalFocus } from '@/lib/workspace-activation-terminal-focus'
import {
attachEphemeralVmRuntimeToWorkspace,
cleanupEphemeralVmRuntimeForFailedCreate,
prepareRequestForCreate
} from '@/lib/ephemeral-vm-worktree-creation'
import { getProvisionedRootCreateOptions } from '@/lib/provisioned-root-create-options'
import {
formatWorkspaceCreateError,
getWorkspaceCreateErrorToastMessage
} from '@/lib/workspace-create-error-format'
import type { CreateWorktreeResult } from '../../../shared/worktree/create-types'
import {
findPendingLinkedWorkItemCreationId,
type WorktreeCreationPhase,
type WorktreeCreationRequest
} from '@/lib/pending-worktree-creation'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { seedAgentTabStateAfterWorktreeCreate } from '@/lib/worktree-creation-agent-seeds'
import { resolveBackendDraftStartup } from '@/lib/worktree-draft-startup-view-mode'
import { executeWorktreeCreation } from '@/lib/worktree-creation-flow-execute'
import {
buildWorktreeCreationStartupOpt,
getInitialWorktreeCreationPhase,
getWorktreeCreationIndeterminate
} from '@/lib/worktree-creation-flow-startup'
@@ -34,13 +15,6 @@ type ContinueBackgroundWorktreeCreationOptions = {
revealCreationSurface?: boolean
}
// Why: activePendingCreationId can outlive the terminal route when the user
// switches app views; only the terminal route renders the creation panel.
function isPendingCreationSurfaceVisible(creationId: string): boolean {
const state = useAppStore.getState()
return state.activeView === 'terminal' && state.activePendingCreationId === creationId
}
function revealPendingCreation(
creationId: string,
request: WorktreeCreationRequest,
@@ -65,213 +39,6 @@ function revealPendingCreation(
store.setSidebarOpen(true)
}
async function preflightAgentTrust(
request: WorktreeCreationRequest,
path: string,
connectionId?: string | null
): Promise<void> {
// Why: trust-gated agents (cursor-agent, copilot) consume the bracketed paste
// as menu input on first launch. Pre-write the trust artifact before any
// terminal spawns. Best-effort — the worktree already exists, so a failure
// here must not strand it.
if (!request.agent || !window.api.agentTrust?.markTrusted) {
return
}
const preflight = TUI_AGENT_CONFIG[request.agent].preflightTrust
if (!preflight) {
return
}
try {
await window.api.agentTrust.markTrusted({
preset: preflight,
workspacePath: path,
...(connectionId ? { connectionId } : {})
})
} catch {
// Best-effort: continue with launch.
}
}
async function executeWorktreeCreation(
creationId: string,
request: WorktreeCreationRequest
): Promise<void> {
const preparedRequest = await prepareRequestForCreate(creationId, request)
if (!preparedRequest) {
return
}
let result: CreateWorktreeResult
try {
const provisionedRoot = getProvisionedRootCreateOptions(preparedRequest)
const backendStartup = provisionedRoot ? undefined : resolveBackendDraftStartup(preparedRequest)
result = await useAppStore
.getState()
.createWorktree(
preparedRequest.repoId,
preparedRequest.name,
preparedRequest.baseBranch,
preparedRequest.setupDecision,
preparedRequest.sparseCheckout,
preparedRequest.telemetrySource,
preparedRequest.displayName,
preparedRequest.linkedIssue,
preparedRequest.linkedPR,
preparedRequest.pushTarget,
preparedRequest.agent ?? undefined,
preparedRequest.linkedLinearIssue,
preparedRequest.branchNameOverride,
preparedRequest.workspaceStatus,
preparedRequest.linkedGitLabMR,
preparedRequest.linkedGitLabIssue,
backendStartup,
preparedRequest.pendingFirstAgentMessageRename,
creationId,
preparedRequest.linkedLinearIssueWorkspaceId,
preparedRequest.linkedLinearIssueOrganizationUrlKey,
preparedRequest.linkedBitbucketPR,
preparedRequest.linkedAzureDevOpsPR,
preparedRequest.linkedGiteaPR,
preparedRequest.compareBaseRef,
{
...(preparedRequest.nameWasGenerated ? { nameWasGenerated: true } : {}),
...(preparedRequest.linkedWorkItem !== undefined
? { linkedWorkItem: preparedRequest.linkedWorkItem }
: {}),
...(preparedRequest.linkedTaskSourceContext !== undefined
? { linkedTaskSourceContext: preparedRequest.linkedTaskSourceContext }
: {}),
// Why: the remote host must own task-draft startup so its initial terminal is the agent, not an idle fallback shell.
...(!backendStartup && preparedRequest.agent && preparedRequest.launchDraftPrompt
? { startupDraft: preparedRequest.launchDraftPrompt }
: {}),
...(provisionedRoot ? { provisionedRoot } : {})
}
)
} catch (error) {
// Why: a missing entry means the user cancelled mid-flight — abandon
// silently rather than surfacing an error for work they already dismissed.
if (!useAppStore.getState().pendingWorktreeCreations[creationId]) {
return
}
if (preparedRequest.ephemeralVmRuntimeId) {
await cleanupEphemeralVmRuntimeForFailedCreate(preparedRequest)
}
const message = getWorkspaceCreateErrorToastMessage(formatWorkspaceCreateError(error))
// Why: an error must stay on the same creation surface that owns the faux
// tab strip, rather than falling back to stale previous-workspace tabs.
useAppStore.getState().updatePendingWorktreeCreation(creationId, {
status: 'error',
error: message,
...(preparedRequest.ephemeralVmRecipe ? { request } : {})
})
// Why: only toast when the panel isn't already showing this error (the user
// navigated away), so a visible failure isn't announced twice.
if (!isPendingCreationSurfaceVisible(creationId)) {
toast.error(message)
}
return
}
const worktree = result.worktree
// Why: cancellation can race a successful backend adoption; clean up again after it settles so an adopted workspace cannot outlive its destroyed VM.
if (!useAppStore.getState().pendingWorktreeCreations[creationId]) {
if (preparedRequest.ephemeralVmRuntimeId) {
await cleanupEphemeralVmRuntimeForFailedCreate(preparedRequest)
}
return
}
await attachEphemeralVmRuntimeToWorkspace(preparedRequest, worktree.id)
const backendSpawned = result.startupTerminal?.spawned === true
if (preparedRequest.startupPlan && !backendSpawned && !preparedRequest.startupPlan.launchToken) {
// Why: delayed delivery must target the exact pane spawned from this queued
// startup, so both halves of the handoff share one renderer-session token.
preparedRequest.startupPlan.launchToken = createBrowserUuid()
}
const startupOpt = buildWorktreeCreationStartupOpt(preparedRequest, backendSpawned)
if (worktree.path) {
const repoConnectionId =
useAppStore.getState().repos.find((repo) => repo.id === worktree.repoId)?.connectionId ?? null
await preflightAgentTrust(preparedRequest, worktree.path, repoConnectionId)
}
// `createWorktree` already inserted the real worktree row. Leaving for an app
// view keeps the create in the background, while selecting another workspace
// means the user still expects this task-launch handoff when it becomes ready;
// the entry guard prevents a late trust preflight from reviving a cancelled create.
const completionState = useAppStore.getState()
const shouldActivateOnCompletion =
completionState.pendingWorktreeCreations[creationId] !== undefined &&
(isPendingCreationSurfaceVisible(creationId) ||
(completionState.activeView === 'terminal' &&
completionState.activePendingCreationId === null))
let activation: ActivateAndRevealResult | false = false
let primaryTabId: string | null
if (shouldActivateOnCompletion) {
activation = activateAndRevealWorktree(worktree.id, {
sidebarRevealBehavior: 'auto',
...(result.setup ? { setup: result.setup } : {}),
...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}),
...(startupOpt ? { startup: startupOpt } : {}),
...(preparedRequest.issueCommand ? { issueCommand: preparedRequest.issueCommand } : {}),
...(backendSpawned ? { backendStartupTerminalSpawned: true } : {})
})
primaryTabId = activation === false ? null : activation.primaryTabId
} else {
// The user moved on. Seed the worktree's terminal + setup in the background
// (setActiveTab only writes global focus for the active worktree, so this is
// safe) without yanking them back to it.
primaryTabId = ensureWorktreeHasInitialTerminal(
useAppStore.getState(),
worktree.id,
startupOpt,
result.setup,
preparedRequest.issueCommand,
result.defaultTabs,
{
activateCreatedTabs: false,
...(backendSpawned ? { backendStartupTerminalSpawned: true } : {})
}
)
}
// Why: clearing synchronously right after activation lets React commit the
// panel→terminal swap in one frame — no two-row flicker, no empty-terminal flash.
useAppStore.getState().removePendingWorktreeCreation(creationId, { cleanupVm: false })
seedAgentTabStateAfterWorktreeCreate({
request: preparedRequest,
worktreeId: worktree.id,
primaryTabId,
startupTerminalTabId: result.startupTerminal?.tabId,
backendSpawned
})
if (preparedRequest.startupPlan && !backendSpawned) {
void ensureAgentStartupInTerminal({
worktreeId: worktree.id,
primaryTabId,
startup: preparedRequest.startupPlan
})
}
if (shouldActivateOnCompletion && !preparedRequest.suppressTerminalFocusOnCompletion) {
queueWorkspaceActivationTerminalFocus(worktree.id, activation)
}
// Why: awaiting the note IPC before the swap would add a visible round-trip to
// the panel→terminal transition; it's cosmetic, so it runs last.
if (preparedRequest.note) {
try {
await useAppStore.getState().updateWorktreeMeta(worktree.id, {
comment: preparedRequest.note
})
} catch {
console.error('Failed to update worktree meta after creation')
}
}
}
/**
* Kick off a worktree create in the background. The caller (the composer) has
* already resolved every interactive decision into `request`, so this returns
@@ -8,6 +8,7 @@ import {
export type RuntimeEnvironmentCallRequest = {
method: string
params?: Record<string, unknown>
}
export function createCompatibleRuntimeStatusResponse(
@@ -1,3 +1,4 @@
import type { CreateWorktreeCallOptions } from './worktrees/create/worktree-create-payload'
import type { WorkspaceKey } from '../../../../shared/folder-workspace-types'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { WorkspaceSource as WorkspaceCreateTelemetrySource } from '../../../../shared/workspace-source'
@@ -7,7 +8,6 @@ import type {
} from '../../../../shared/worktree/base-ref-drift-types'
import type {
CreateSparseCheckoutRequest,
CreateWorktreeArgs,
CreateWorktreeResult,
ForceDeleteWorktreeBranchResult,
SetupDecision
@@ -19,11 +19,9 @@ import type {
DetectedWorktree,
DetectedWorktreeListResult,
GitPushTarget,
WorkspaceLinkedItem,
WorkspaceStatus,
Worktree
} from '../../../../shared/worktree/types'
import type { TaskSourceContext } from '../../../../shared/task-source-context'
import type { WorktreeRemovalTarget } from '../../../../shared/worktree/removal'
import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector'
import type { ExecutionHostId } from '../../../../shared/execution-host'
@@ -208,20 +206,7 @@ export type WorktreeSlice = {
linkedAzureDevOpsPR?: number | null,
linkedGiteaPR?: number | null,
compareBaseRef?: string,
options?: {
automationProvenanceRequest?: CreateWorktreeArgs['automationProvenanceRequest']
linkedWorkItem?: WorkspaceLinkedItem | null
linkedTaskSourceContext?: TaskSourceContext | null
/** Lets the owning runtime launch and prefill a task agent without first creating an idle shell. */
startupDraft?: string
/** True only when `name` came from the creature-name generator; gates host-side retirement. */
nameWasGenerated?: boolean
provisionedRoot?: {
runtimeId: string
executionHostId: ExecutionHostId
expectedPath: string
}
}
options?: CreateWorktreeCallOptions
) => Promise<CreateWorktreeResult>
/** Register an in-flight background creation and make it the active surface. */
beginPendingWorktreeCreation: (entry: PendingWorktreeCreation) => void
@@ -0,0 +1,388 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AppState } from '../types'
import type { Worktree } from '../../../../shared/worktree/types'
import { toast } from 'sonner'
import type { RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture'
import { worktreeWorkspaceKey, folderWorkspaceKey } from '../../../../shared/workspace-scope'
import { makeLineage, makeWorktree } from './worktrees-slice-test-fixtures'
import {
createTestStore,
mockApi,
resetRemoteRuntimeMocks,
resetWorktreeSliceModuleMemory,
runtimeEnvironmentCall
} from './worktrees-slice-test-harness'
vi.mock('sonner', () => ({
toast: {
warning: vi.fn(),
info: vi.fn(),
success: vi.fn(),
error: vi.fn(),
dismiss: vi.fn()
}
}))
vi.mock('@/components/worktree-base-fallback-notice', () => ({
requestWorktreeBaseFallbackNotice: vi.fn()
}))
beforeEach(resetWorktreeSliceModuleMemory)
describe('createWorktree composer parent pick', () => {
beforeEach(() => {
vi.clearAllMocks()
resetRemoteRuntimeMocks()
})
function createParentPickStore(parent?: Worktree) {
const store = createTestStore()
store.setState({ worktreesByRepo: { repo1: parent ? [parent] : [] } } as Partial<AppState>)
return store
}
function createWithParentPick(
store: ReturnType<typeof createTestStore>,
parentWorktreeId: string
) {
const createWorktree = store.getState().createWorktree
const args: Parameters<typeof createWorktree> = ['repo1', 'feature', 'origin/main']
args[25] = { parentWorktreeId }
return createWorktree(...args)
}
it('nests the new workspace under the picked parent worktree', async () => {
const parent = makeWorktree({
id: 'repo1::/path/parent',
repoId: 'repo1',
path: '/path/parent',
instanceId: 'parent-instance'
})
const store = createParentPickStore(parent)
store.setState({ activeWorkspaceKey: folderWorkspaceKey('folder-1') } as Partial<AppState>)
mockApi.worktrees.create.mockResolvedValue({
worktree: makeWorktree({ id: 'repo1::/path/child', repoId: 'repo1', path: '/path/child' })
})
await createWithParentPick(store, parent.id)
expect(mockApi.worktrees.create).toHaveBeenCalledWith(
expect.objectContaining({ parentWorkspace: worktreeWorkspaceKey(parent.id) })
)
})
it('drops a stale parent pick and falls back to the active folder workspace', async () => {
const store = createParentPickStore()
store.setState({ activeWorkspaceKey: folderWorkspaceKey('folder-1') } as Partial<AppState>)
mockApi.worktrees.create.mockResolvedValue({
worktree: makeWorktree({ id: 'repo1::/path/child', repoId: 'repo1', path: '/path/child' })
})
await createWithParentPick(store, 'repo1::/path/removed')
expect(mockApi.worktrees.create).toHaveBeenCalledWith(
expect.objectContaining({ parentWorkspace: folderWorkspaceKey('folder-1') })
)
})
it('warns when an archived parent pick is dropped before create', async () => {
const parent = makeWorktree({
id: 'repo1::/path/parent',
repoId: 'repo1',
path: '/path/parent',
displayName: 'parent-wt',
isArchived: true
})
const store = createParentPickStore(parent)
mockApi.worktrees.create.mockResolvedValue({
worktree: makeWorktree({ id: 'repo1::/path/child', repoId: 'repo1', path: '/path/child' })
})
await createWithParentPick(store, parent.id)
expect(toast.warning).toHaveBeenCalledWith(
'Created without nesting under "parent-wt"',
expect.objectContaining({ description: expect.any(String) })
)
})
it('warns when the backend rejects an accepted parent pick', async () => {
const parent = makeWorktree({
id: 'repo1::/path/parent',
repoId: 'repo1',
path: '/path/parent',
displayName: 'parent-wt',
instanceId: 'parent-instance'
})
const store = createParentPickStore(parent)
mockApi.worktrees.create.mockResolvedValue({
worktree: makeWorktree({ id: 'repo1::/path/child', repoId: 'repo1', path: '/path/child' }),
lineage: null
})
await createWithParentPick(store, parent.id)
expect(toast.warning).toHaveBeenCalledWith(
'Created without nesting under "parent-wt"',
expect.objectContaining({ description: expect.any(String) })
)
})
it('keeps an accepted parent pick quiet and seeds its lineage', async () => {
const parent = makeWorktree({
id: 'repo1::/path/parent',
repoId: 'repo1',
path: '/path/parent',
instanceId: 'parent-instance'
})
const created = makeWorktree({
id: 'repo1::/path/child',
repoId: 'repo1',
path: '/path/child',
instanceId: 'child-instance'
})
const lineage = makeLineage({
worktreeId: created.id,
worktreeInstanceId: 'child-instance',
parentWorktreeId: parent.id,
parentWorktreeInstanceId: 'parent-instance'
})
const store = createParentPickStore(parent)
mockApi.worktrees.create.mockResolvedValue({ worktree: created, lineage })
await createWithParentPick(store, parent.id)
expect(toast.warning).not.toHaveBeenCalled()
expect(store.getState().worktreeLineageById[created.id]).toEqual(lineage)
})
// Why: paired web clients route window.api.worktrees.create to their host, which still rejects.
it('retries without the parent when the create API reports the parent is gone', async () => {
const parent = makeWorktree({
id: 'repo1::/path/parent',
repoId: 'repo1',
path: '/path/parent',
displayName: 'parent-wt',
instanceId: 'parent-instance'
})
const store = createParentPickStore(parent)
mockApi.worktrees.create
.mockRejectedValueOnce(
Object.assign(new Error('Parent selector was not found.'), {
code: 'LINEAGE_PARENT_NOT_FOUND'
})
)
.mockResolvedValue({
worktree: makeWorktree({ id: 'repo1::/path/child', repoId: 'repo1', path: '/path/child' })
})
await createWithParentPick(store, parent.id)
expect(mockApi.worktrees.create).toHaveBeenCalledTimes(2)
expect(mockApi.worktrees.create.mock.calls[1][0]).not.toHaveProperty('parentWorkspace')
expect(toast.warning).toHaveBeenCalledTimes(1)
})
it('warns once when a dropped pick is followed by branch-conflict retries', async () => {
const store = createParentPickStore()
const created = makeWorktree({
id: 'repo1::/path/child',
repoId: 'repo1',
path: '/path/child'
})
mockApi.worktrees.create
.mockRejectedValueOnce(new Error('Branch "feature" already exists locally.'))
.mockResolvedValue({ worktree: created })
await createWithParentPick(store, 'repo1::/path/removed')
expect(mockApi.worktrees.create).toHaveBeenCalledTimes(2)
expect(toast.warning).toHaveBeenCalledTimes(1)
})
})
describe('createWorktree parent pick on a remote runtime', () => {
const REMOTE_REPO = 'repo-remote'
const PARENT_ID = `${REMOTE_REPO}::/remote/parent`
beforeEach(() => {
vi.clearAllMocks()
resetRemoteRuntimeMocks()
})
function createRemoteStore() {
const store = createTestStore()
store.setState({
repos: [
{
id: REMOTE_REPO,
path: '/home/dev/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
executionHostId: 'runtime:env-1'
}
],
worktreesByRepo: {
[REMOTE_REPO]: [
makeWorktree({
id: PARENT_ID,
repoId: REMOTE_REPO,
path: '/remote/parent',
displayName: 'parent-wt',
instanceId: 'parent-instance'
})
]
}
} as Partial<AppState>)
return store
}
function createOnRemote(store: ReturnType<typeof createTestStore>) {
const createWorktree = store.getState().createWorktree
const args: Parameters<typeof createWorktree> = [REMOTE_REPO, 'feature', 'origin/main']
args[25] = { parentWorktreeId: PARENT_ID }
return createWorktree(...args)
}
function createCalls() {
return runtimeEnvironmentCall.mock.calls
.map(([request]: [RuntimeEnvironmentCallRequest]) => request)
.filter((request) => request.method === 'worktree.create')
}
function mockRuntimeCreate(firstCreate: (id: string) => unknown) {
let createCount = 0
runtimeEnvironmentCall.mockImplementation(({ method }: RuntimeEnvironmentCallRequest) => {
if (method !== 'worktree.create') {
return Promise.resolve({ id: method, ok: true, result: null })
}
createCount += 1
return createCount === 1
? Promise.resolve(firstCreate('rpc-create'))
: Promise.resolve({
id: 'rpc-create',
ok: true,
result: {
worktree: makeWorktree({
id: `${REMOTE_REPO}::/remote/child`,
repoId: REMOTE_REPO,
path: '/remote/child'
})
}
})
})
}
it('marks an app-selected parent as manual so the host does not record a CLI flag', async () => {
mockRuntimeCreate((id) => ({
id,
ok: true,
result: {
worktree: makeWorktree({
id: `${REMOTE_REPO}::/remote/child`,
repoId: REMOTE_REPO,
path: '/remote/child'
}),
lineage: makeLineage({
worktreeId: `${REMOTE_REPO}::/remote/child`,
parentWorktreeId: PARENT_ID
})
}
}))
await createOnRemote(createRemoteStore())
expect(createCalls()[0]?.params).toMatchObject({
parentWorkspace: worktreeWorkspaceKey(PARENT_ID),
parentWorkspaceOrigin: 'manual'
})
expect(toast.warning).not.toHaveBeenCalled()
})
it('retries without the parent and warns when the host no longer has it', async () => {
mockRuntimeCreate((id) => ({
id,
ok: false,
error: { code: 'LINEAGE_PARENT_NOT_FOUND', message: 'Parent selector was not found.' }
}))
await createOnRemote(createRemoteStore())
const calls = createCalls()
expect(calls).toHaveLength(2)
expect(calls[1]?.params).not.toHaveProperty('parentWorkspace')
expect(toast.warning).toHaveBeenCalledWith(
'Created without nesting under "parent-wt"',
expect.objectContaining({ description: expect.any(String) })
)
})
it('retries when a transport re-wraps the parent-missing code into a plain error', async () => {
let createCount = 0
runtimeEnvironmentCall.mockImplementation(({ method }: RuntimeEnvironmentCallRequest) => {
if (method !== 'worktree.create') {
return Promise.resolve({ id: method, ok: true, result: null })
}
createCount += 1
// Why: the relay envelope re-throws as a plain Error, so only the trailing code survives.
return createCount === 1
? Promise.reject(new Error('Error invoking remote method: LINEAGE_PARENT_NOT_FOUND'))
: Promise.resolve({
id: 'rpc-create',
ok: true,
result: {
worktree: makeWorktree({
id: `${REMOTE_REPO}::/remote/child`,
repoId: REMOTE_REPO,
path: '/remote/child'
})
}
})
})
await createOnRemote(createRemoteStore())
expect(createCalls()).toHaveLength(2)
expect(toast.warning).toHaveBeenCalledTimes(1)
})
// Why: the folder parent comes from the active scope, not a pick, so nothing else would surface it.
it('warns when the retry drops an active folder-workspace parent', async () => {
const store = createRemoteStore()
store.setState({
activeWorkspaceKey: folderWorkspaceKey('folder-1'),
folderWorkspaces: [{ id: 'folder-1', name: 'Design docs' }]
} as unknown as Partial<AppState>)
mockRuntimeCreate((id) => ({
id,
ok: false,
error: { code: 'LINEAGE_PARENT_NOT_FOUND', message: 'Parent selector was not found.' }
}))
const createWorktree = store.getState().createWorktree
await createWorktree(REMOTE_REPO, 'feature', 'origin/main')
const calls = createCalls()
expect(calls).toHaveLength(2)
expect(calls[0]?.params).toMatchObject({ parentWorkspace: folderWorkspaceKey('folder-1') })
expect(calls[1]?.params).not.toHaveProperty('parentWorkspace')
expect(toast.warning).toHaveBeenCalledWith(
'Created without nesting under "Design docs"',
expect.objectContaining({ description: expect.any(String) })
)
})
it('never double-creates when the host fails for an unrelated reason', async () => {
mockRuntimeCreate((id) => ({
id,
ok: false,
error: { code: 'internal_error', message: 'disk on fire' }
}))
await expect(createOnRemote(createRemoteStore())).rejects.toThrow('disk on fire')
expect(createCalls()).toHaveLength(1)
expect(toast.warning).not.toHaveBeenCalled()
})
})
@@ -266,6 +266,223 @@ describe('fetchWorktrees', () => {
})
})
it('keeps a lineage row written while the host lineage request was in flight', async () => {
const store = createTestStore()
const worktree = makeWorktree({
id: 'repo1::/remote/wt1',
repoId: 'repo1',
path: '/remote/wt1',
branch: 'refs/heads/remote',
hostId: 'runtime:env-1'
})
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as never,
worktreesByRepo: { repo1: [worktree] },
worktreeLineageById: {},
workspaceLineageByChildKey: {}
} as Partial<AppState>)
let releaseLineage: (() => void) | undefined
const lineageReplied = new Promise<void>((resolve) => {
releaseLineage = resolve
})
runtimeEnvironmentCall.mockImplementation(async ({ method }: RuntimeEnvironmentCallRequest) => {
if (method === 'worktree.lineageList') {
await lineageReplied
return {
id: 'rpc-1',
ok: true,
result: { lineage: {}, workspaceLineage: {} },
_meta: { runtimeId: 'runtime-remote' }
}
}
return {
id: 'rpc-2',
ok: true,
result: makeDetectedResult('repo1', [worktree]),
_meta: { runtimeId: 'runtime-remote' }
}
})
const pending = store.getState().fetchWorktreeLineage({ executionHostId: 'runtime:env-1' })
const created = makeLineage({ worktreeId: worktree.id })
const createdWorkspace = makeWorkspaceLineage({
childWorkspaceKey: worktreeWorkspaceKey(worktree.id)
})
store.setState({
worktreeLineageById: { [created.worktreeId]: created },
workspaceLineageByChildKey: { [createdWorkspace.childWorkspaceKey]: createdWorkspace }
} as Partial<AppState>)
releaseLineage?.()
await pending
expect(store.getState().worktreeLineageById).toEqual({ [created.worktreeId]: created })
expect(store.getState().workspaceLineageByChildKey).toEqual({
[createdWorkspace.childWorkspaceKey]: createdWorkspace
})
})
it('keeps a parent reassignment written while the host lineage request was in flight', async () => {
const store = createTestStore()
const worktree = makeWorktree({
id: 'repo1::/remote/wt1',
repoId: 'repo1',
path: '/remote/wt1',
branch: 'refs/heads/remote',
hostId: 'runtime:env-1'
})
const childKey = worktreeWorkspaceKey(worktree.id)
const before = makeLineage({
worktreeId: worktree.id,
parentWorktreeId: 'repo1::/remote/old-parent'
})
const beforeWorkspace = makeWorkspaceLineage({
childWorkspaceKey: childKey,
parentWorkspaceKey: worktreeWorkspaceKey('repo1::/remote/old-parent')
})
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as never,
worktreesByRepo: { repo1: [worktree] },
worktreeLineageById: { [before.worktreeId]: before },
workspaceLineageByChildKey: { [childKey]: beforeWorkspace }
} as Partial<AppState>)
let releaseLineage: (() => void) | undefined
const lineageReplied = new Promise<void>((resolve) => {
releaseLineage = resolve
})
runtimeEnvironmentCall.mockImplementation(async ({ method }: RuntimeEnvironmentCallRequest) => {
if (method === 'worktree.lineageList') {
await lineageReplied
return {
id: 'rpc-1',
ok: true,
result: {
lineage: { [before.worktreeId]: before },
workspaceLineage: { [childKey]: beforeWorkspace }
},
_meta: { runtimeId: 'runtime-remote' }
}
}
return {
id: 'rpc-2',
ok: true,
result: makeDetectedResult('repo1', [worktree]),
_meta: { runtimeId: 'runtime-remote' }
}
})
const pending = store.getState().fetchWorktreeLineage({ executionHostId: 'runtime:env-1' })
const reassigned = makeLineage({
worktreeId: worktree.id,
parentWorktreeId: 'repo1::/remote/new-parent'
})
const reassignedWorkspace = makeWorkspaceLineage({
childWorkspaceKey: childKey,
parentWorkspaceKey: worktreeWorkspaceKey('repo1::/remote/new-parent')
})
store.setState({
worktreeLineageById: { [reassigned.worktreeId]: reassigned },
workspaceLineageByChildKey: { [childKey]: reassignedWorkspace }
} as Partial<AppState>)
releaseLineage?.()
await pending
expect(store.getState().worktreeLineageById).toEqual({ [reassigned.worktreeId]: reassigned })
expect(store.getState().workspaceLineageByChildKey).toEqual({ [childKey]: reassignedWorkspace })
})
it('keeps a lineage row deleted while the host lineage request was in flight', async () => {
const store = createTestStore()
const worktree = makeWorktree({
id: 'repo1::/remote/wt1',
repoId: 'repo1',
path: '/remote/wt1',
branch: 'refs/heads/remote',
hostId: 'runtime:env-1'
})
const childKey = worktreeWorkspaceKey(worktree.id)
const before = makeLineage({ worktreeId: worktree.id })
const beforeWorkspace = makeWorkspaceLineage({ childWorkspaceKey: childKey })
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as never,
worktreesByRepo: { repo1: [worktree] },
worktreeLineageById: { [before.worktreeId]: before },
workspaceLineageByChildKey: { [childKey]: beforeWorkspace }
} as Partial<AppState>)
let releaseLineage: (() => void) | undefined
const lineageReplied = new Promise<void>((resolve) => {
releaseLineage = resolve
})
runtimeEnvironmentCall.mockImplementation(async ({ method }: RuntimeEnvironmentCallRequest) => {
if (method === 'worktree.lineageList') {
await lineageReplied
return {
id: 'rpc-1',
ok: true,
result: {
lineage: { [before.worktreeId]: before },
workspaceLineage: { [childKey]: beforeWorkspace }
},
_meta: { runtimeId: 'runtime-remote' }
}
}
return {
id: 'rpc-2',
ok: true,
result: makeDetectedResult('repo1', [worktree]),
_meta: { runtimeId: 'runtime-remote' }
}
})
const pending = store.getState().fetchWorktreeLineage({ executionHostId: 'runtime:env-1' })
store.setState({
worktreeLineageById: {},
workspaceLineageByChildKey: {}
} as Partial<AppState>)
releaseLineage?.()
await pending
expect(store.getState().worktreeLineageById).toEqual({})
expect(store.getState().workspaceLineageByChildKey).toEqual({})
})
it('drops a host lineage row the refresh reply no longer reports', async () => {
const store = createTestStore()
const worktree = makeWorktree({
id: 'repo1::/remote/wt1',
repoId: 'repo1',
path: '/remote/wt1',
branch: 'refs/heads/remote',
hostId: 'runtime:env-1'
})
const stale = makeLineage({ worktreeId: worktree.id })
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as never,
worktreesByRepo: { repo1: [worktree] },
worktreeLineageById: { [stale.worktreeId]: stale }
} as Partial<AppState>)
runtimeEnvironmentCall.mockImplementation(({ method }: RuntimeEnvironmentCallRequest) =>
Promise.resolve({
id: 'rpc-1',
ok: true,
result:
method === 'worktree.lineageList'
? { lineage: {}, workspaceLineage: {} }
: makeDetectedResult('repo1', [worktree]),
_meta: { runtimeId: 'runtime-remote' }
})
)
await store.getState().fetchWorktreeLineage({ executionHostId: 'runtime:env-1' })
expect(store.getState().worktreeLineageById).toEqual({})
})
it('keeps a successful remote worktree refresh when lineage refresh fails', async () => {
const store = createTestStore()
const refreshed = makeWorktree({
@@ -1,12 +1,12 @@
import type { WorktreeSlice } from '../../worktree-helpers'
import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types'
import type { CreateWorktreeResult } from '../../../../../../shared/worktree/create-types'
import {
CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS,
getClientWorktreeCreateCandidate,
getGeneratedWorktreeCreateRetryCandidate,
isRetryableWorktreeCreateConflict
} from '../../../../../../shared/new-workspace/worktree-create-retry-policy'
import { parseWorkspaceKey, folderWorkspaceKey } from '../../../../../../shared/workspace-scope'
import {
assertRuntimeEnvironmentCapability,
callRuntimeRpc,
@@ -16,12 +16,82 @@ import { WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY } from '../../../.
import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/local-base-ref-suggestion-toast'
import { requestWorktreeBaseFallbackNotice } from '@/components/worktree-base-fallback-notice'
import { showLocalBaseRefRefreshToast } from './local-base-ref-refresh-toast'
import {
getProjectHostSetupForRepoHost,
repoHostId,
withRepoHostOwnership
} from '../listing/worktree-host-ownership'
import { settingsForRepoOwner } from '../listing/worktree-owner-settings'
import { applyCreatedWorktree } from './created-worktree-state-merge'
import { isRuntimeLineageParentMissingError } from '../listing/runtime-worktree-rpc-errors'
import {
buildLocalWorktreeCreateArgs,
buildRuntimeWorktreeCreateParams,
type WorktreeCreateAttempt,
type WorktreeCreateRequest
} from './worktree-create-payload'
import {
notifyWorktreeParentDropped,
resolveWorktreeCreateParent,
type WorktreeCreateParentPick
} from './worktree-create-parent-pick'
type RuntimeTarget = ReturnType<typeof getActiveRuntimeTarget>
type CreateAttemptOutcome = {
result: CreateWorktreeResult
/** The host had no such parent, so this attempt was retried unattached. */
droppedParent: boolean
}
async function runCreateAttempt(
request: WorktreeCreateRequest,
attempt: WorktreeCreateAttempt,
target: RuntimeTarget
): Promise<CreateAttemptOutcome> {
const provisionedRoot = request.options?.provisionedRoot
const create = async (
parentWorkspace: WorktreeCreateAttempt['parentWorkspace']
): Promise<CreateWorktreeResult> =>
provisionedRoot
? await window.api.worktrees.adoptProvisionedRoot({
...buildLocalWorktreeCreateArgs(request, { ...attempt, parentWorkspace }),
...provisionedRoot
})
: target.kind === 'local'
? // Why local can still reject on the parent: paired web clients route this API to their host.
await window.api.worktrees.create(
buildLocalWorktreeCreateArgs(request, { ...attempt, parentWorkspace })
)
: await callRuntimeRpc<CreateWorktreeResult>(
target,
'worktree.create',
buildRuntimeWorktreeCreateParams(request, { ...attempt, parentWorkspace }),
{ timeoutMs: 10 * 60_000 }
)
try {
return { result: await create(attempt.parentWorkspace), droppedParent: false }
} catch (error) {
if (!attempt.parentWorkspace || !isRuntimeLineageParentMissingError(error)) {
throw error
}
return { result: await create(undefined), droppedParent: true }
}
}
/** True when the create landed without the nesting the caller asked for. */
function lostRequestedParent(
outcome: CreateAttemptOutcome,
parent: WorktreeCreateParentPick,
target: RuntimeTarget
): boolean {
if (outcome.droppedParent) {
return true
}
if (parent.pickedParentWorktreeId) {
return !outcome.result.lineage
}
// Why local-only: older hosts predate the top-level workspace lineage field, so its absence
// over RPC would warn about a nesting that actually landed.
return (
target.kind === 'local' && Boolean(parent.parentWorkspace) && !outcome.result.workspaceLineage
)
}
export function createCreateWorktree(
set: WorktreeSliceSet,
@@ -55,188 +125,87 @@ export function createCreateWorktree(
compareBaseRef,
options
) => {
const automationProvenanceRequest = options?.automationProvenanceRequest
const linkedWorkItem = options?.linkedWorkItem
const linkedTaskSourceContext = options?.linkedTaskSourceContext
const startupDraft = options?.startupDraft
const provisionedRoot = options?.provisionedRoot
const request: WorktreeCreateRequest = {
repoId,
name,
baseBranch,
setupDecision,
sparseCheckout,
telemetrySource,
displayName,
linkedIssue,
linkedPR,
pushTarget,
createdWithAgent,
linkedLinearIssue,
branchNameOverride,
workspaceStatus,
linkedGitLabMR,
linkedGitLabIssue,
startup,
pendingFirstAgentMessageRename,
creationId,
linkedLinearIssueWorkspaceId,
linkedLinearIssueOrganizationUrlKey,
linkedBitbucketPR,
linkedAzureDevOpsPR,
linkedGiteaPR,
compareBaseRef,
options
}
try {
// Why outside the retry loop: a branch-name conflict retry must not re-warn about the same dropped pick.
const parent = resolveWorktreeCreateParent(get(), repoId, options?.parentWorktreeId)
let warnedParentDropped = false
const warnParentDroppedOnce = (): void => {
if (warnedParentDropped) {
return
}
warnedParentDropped = true
notifyWorktreeParentDropped(get(), parent)
}
if (parent.staleBeforeCreate) {
warnParentDroppedOnce()
}
// Why: manual sort is user-authored order; stamp new workspaces at the top rather than relying on sortOrder fallback.
const manualOrder = get().sortBy === 'manual' ? Date.now() : undefined
const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId))
if (
target.kind === 'environment' &&
(options?.linkedWorkItem?.provider === 'jira' ||
options?.linkedTaskSourceContext?.provider === 'jira')
) {
await assertRuntimeEnvironmentCapability(
target.environmentId,
WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY,
'Update the remote runtime to link Jira'
)
}
if (options?.provisionedRoot && target.kind !== 'local') {
throw new Error('Provisioned-root recipes currently require a direct SSH connection.')
}
for (let attempt = 0; attempt < CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS; attempt += 1) {
const candidateName = options?.nameWasGenerated
? getGeneratedWorktreeCreateRetryCandidate(name, attempt)
: getClientWorktreeCreateCandidate(name, attempt)
// Why: older runtimes reject exact PR branch overrides on collision, so retry both branch and worktree names.
const candidateBranchNameOverride = branchNameOverride
? getClientWorktreeCreateCandidate(branchNameOverride, attempt)
: undefined
try {
// Why: manual sort is user-authored order; stamp new workspaces at the top rather than relying on sortOrder fallback.
const manualOrder = get().sortBy === 'manual' ? Date.now() : undefined
const activeScope = parseWorkspaceKey(get().activeWorkspaceKey ?? '')
const parentWorkspace =
activeScope?.type === 'folder'
? folderWorkspaceKey(activeScope.folderWorkspaceId)
: undefined
const createArgs = {
repoId,
name: candidateName,
...(options?.nameWasGenerated ? { nameWasGenerated: true } : {}),
baseBranch,
...(compareBaseRef ? { compareBaseRef } : {}),
...(candidateBranchNameOverride
? { branchNameOverride: candidateBranchNameOverride }
: {}),
setupDecision,
sparseCheckout,
...(displayName ? { displayName } : {}),
...(telemetrySource ? { telemetrySource } : {}),
...(linkedIssue !== undefined ? { linkedIssue } : {}),
...(linkedPR !== undefined ? { linkedPR } : {}),
...(pushTarget ? { pushTarget } : {}),
...(createdWithAgent ? { createdWithAgent } : {}),
...(pendingFirstAgentMessageRename === true && createdWithAgent
? { pendingFirstAgentMessageRename: true }
: {}),
...(linkedLinearIssue !== undefined ? { linkedLinearIssue } : {}),
...(linkedLinearIssueWorkspaceId !== undefined ? { linkedLinearIssueWorkspaceId } : {}),
...(linkedLinearIssueOrganizationUrlKey !== undefined
? { linkedLinearIssueOrganizationUrlKey }
: {}),
...(manualOrder !== undefined ? { manualOrder } : {}),
...(parentWorkspace ? { parentWorkspace } : {}),
...(workspaceStatus !== undefined ? { workspaceStatus } : {}),
...(linkedGitLabMR !== undefined ? { linkedGitLabMR } : {}),
...(linkedGitLabIssue !== undefined ? { linkedGitLabIssue } : {}),
...(linkedBitbucketPR !== undefined ? { linkedBitbucketPR } : {}),
...(linkedAzureDevOpsPR !== undefined ? { linkedAzureDevOpsPR } : {}),
...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}),
...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}),
...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}),
...(startup ? { startup } : {}),
...(creationId ? { creationId } : {}),
...(automationProvenanceRequest ? { automationProvenanceRequest } : {})
const outcome = await runCreateAttempt(
request,
{
name: options?.nameWasGenerated
? getGeneratedWorktreeCreateRetryCandidate(name, attempt)
: getClientWorktreeCreateCandidate(name, attempt),
// Why: older runtimes reject exact PR branch overrides on collision, so retry both branch and worktree names.
branchNameOverride: branchNameOverride
? getClientWorktreeCreateCandidate(branchNameOverride, attempt)
: undefined,
parentWorkspace: parent.parentWorkspace,
manualOrder
},
target
)
if (lostRequestedParent(outcome, parent, target)) {
warnParentDroppedOnce()
}
const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId))
if (
target.kind === 'environment' &&
(linkedWorkItem?.provider === 'jira' || linkedTaskSourceContext?.provider === 'jira')
) {
await assertRuntimeEnvironmentCapability(
target.environmentId,
WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY,
'Update the remote runtime to link Jira'
)
}
if (provisionedRoot && target.kind !== 'local') {
throw new Error('Provisioned-root recipes currently require a direct SSH connection.')
}
const result = provisionedRoot
? await window.api.worktrees.adoptProvisionedRoot({
...createArgs,
...provisionedRoot
})
: target.kind === 'local'
? await window.api.worktrees.create(createArgs)
: await callRuntimeRpc<Awaited<ReturnType<typeof window.api.worktrees.create>>>(
target,
'worktree.create',
{
repo: repoId,
name: candidateName,
...(options?.nameWasGenerated ? { nameWasGenerated: true } : {}),
baseBranch,
...(compareBaseRef ? { compareBaseRef } : {}),
...(candidateBranchNameOverride
? { branchNameOverride: candidateBranchNameOverride }
: {}),
setupDecision,
sparseCheckout,
...(displayName ? { displayName } : {}),
...(telemetrySource ? { telemetrySource } : {}),
...(linkedIssue !== undefined ? { linkedIssue } : {}),
...(linkedPR !== undefined ? { linkedPR } : {}),
...(pushTarget ? { pushTarget } : {}),
...(createdWithAgent ? { createdWithAgent } : {}),
...(pendingFirstAgentMessageRename === true && createdWithAgent
? { pendingFirstAgentMessageRename: true }
: {}),
...(linkedLinearIssue !== undefined ? { linkedLinearIssue } : {}),
...(linkedLinearIssueWorkspaceId !== undefined
? { linkedLinearIssueWorkspaceId }
: {}),
...(linkedLinearIssueOrganizationUrlKey !== undefined
? { linkedLinearIssueOrganizationUrlKey }
: {}),
...(manualOrder !== undefined ? { manualOrder } : {}),
...(parentWorkspace ? { parentWorkspace } : {}),
...(workspaceStatus !== undefined ? { workspaceStatus } : {}),
...(linkedGitLabMR !== undefined ? { linkedGitLabMR } : {}),
...(linkedGitLabIssue !== undefined ? { linkedGitLabIssue } : {}),
...(linkedBitbucketPR !== undefined ? { linkedBitbucketPR } : {}),
...(linkedAzureDevOpsPR !== undefined ? { linkedAzureDevOpsPR } : {}),
...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}),
...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}),
...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}),
...(startupDraft ? { startupDraft } : {}),
...(automationProvenanceRequest ? { automationProvenanceRequest } : {}),
...(startup
? {
startupCommand: startup.command,
...(startup.env ? { startupEnv: startup.env } : {}),
...(startup.launchConfig
? { startupLaunchConfig: startup.launchConfig }
: {}),
...(startup.startupCommandDelivery
? { startupCommandDelivery: startup.startupCommandDelivery }
: {}),
activate: true
}
: {})
},
{ timeoutMs: 10 * 60_000 }
)
// Why: worktrees.onChanged can add this worktree before this callback runs; appending blindly would duplicate it (React key clash).
set((s) => {
const hostId = repoHostId(s, repoId)
const createdWorktree = withRepoHostOwnership(
result.worktree,
hostId,
getProjectHostSetupForRepoHost(s, repoId, hostId)
)
const current = s.worktreesByRepo[repoId] ?? []
const alreadyPresent = current.some((w) => w.id === createdWorktree.id)
const nextWorktrees = alreadyPresent
? current.map((worktree) =>
worktree.id === createdWorktree.id
? { ...worktree, ...createdWorktree }
: worktree
)
: [...current, createdWorktree]
return {
worktreesByRepo: {
...s.worktreesByRepo,
[repoId]: nextWorktrees
},
...(result.workspaceLineage
? {
workspaceLineageByChildKey: {
...s.workspaceLineageByChildKey,
[result.workspaceLineage.childWorkspaceKey]: result.workspaceLineage
}
}
: {}),
...(result.initialBaseStatus
? {
baseStatusByWorktreeId: {
...s.baseStatusByWorktreeId,
[result.worktree.id]:
s.baseStatusByWorktreeId[result.worktree.id] ?? result.initialBaseStatus
}
}
: {}),
sortEpoch: s.sortEpoch + 1
}
})
applyCreatedWorktree(set, repoId, outcome.result)
const { result } = outcome
showLocalBaseRefRefreshToast(result.localBaseRefRefresh, result.worktree)
if (result.baseFallback) {
requestWorktreeBaseFallbackNotice(result.baseFallback)
@@ -0,0 +1,63 @@
import type { WorktreeSliceSet } from '../listing/worktree-slice-types'
import type { CreateWorktreeResult } from '../../../../../../shared/worktree/create-types'
import {
getProjectHostSetupForRepoHost,
repoHostId,
withRepoHostOwnership
} from '../listing/worktree-host-ownership'
/** Folds a fresh create result into the worktree, lineage and base-status maps. */
export function applyCreatedWorktree(
set: WorktreeSliceSet,
repoId: string,
result: CreateWorktreeResult
) {
// Why: worktrees.onChanged can add this worktree before this callback runs; appending blindly would duplicate it (React key clash).
set((s) => {
const hostId = repoHostId(s, repoId)
const createdWorktree = withRepoHostOwnership(
result.worktree,
hostId,
getProjectHostSetupForRepoHost(s, repoId, hostId)
)
const current = s.worktreesByRepo[repoId] ?? []
const alreadyPresent = current.some((w) => w.id === createdWorktree.id)
const nextWorktrees = alreadyPresent
? current.map((worktree) =>
worktree.id === createdWorktree.id ? { ...worktree, ...createdWorktree } : worktree
)
: [...current, createdWorktree]
return {
worktreesByRepo: {
...s.worktreesByRepo,
[repoId]: nextWorktrees
},
...(result.workspaceLineage
? {
workspaceLineageByChildKey: {
...s.workspaceLineageByChildKey,
[result.workspaceLineage.childWorkspaceKey]: result.workspaceLineage
}
}
: {}),
...(result.lineage
? {
worktreeLineageById: {
...s.worktreeLineageById,
[result.lineage.worktreeId]: result.lineage
}
}
: {}),
...(result.initialBaseStatus
? {
baseStatusByWorktreeId: {
...s.baseStatusByWorktreeId,
[result.worktree.id]:
s.baseStatusByWorktreeId[result.worktree.id] ?? result.initialBaseStatus
}
}
: {}),
sortEpoch: s.sortEpoch + 1
}
})
}
@@ -0,0 +1,94 @@
import type { AppState } from '../../../types'
import type { WorkspaceKey } from '../../../../../../shared/folder-workspace-types'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { resolveWorktreeDisplayName } from '@/lib/worktree-default-display-name'
import {
folderWorkspaceKey,
parseWorkspaceKey,
worktreeWorkspaceKey
} from '../../../../../../shared/workspace-scope'
import { getIndexedWorktreeById } from '@/store/worktree-repo-index'
export type WorktreeCreateParentPick = {
/** Workspace the create attaches to. Undefined once a stale pick is dropped. */
parentWorkspace?: WorkspaceKey
/** Set only for an accepted user pick, whose lineage row must come back from the host. */
pickedParentWorktreeId?: string
/** Label for the drop warning; null when the parent record is already gone, '' when it has no name. */
pickedDisplayName: string | null
/** The pick no longer qualifies, so the create runs unattached. */
staleBeforeCreate: boolean
}
/** Resolves the composer pick against the current store, falling back to the active folder scope. */
export function resolveWorktreeCreateParent(
state: AppState,
repoId: string,
requestedParentWorktreeId: string | undefined
): WorktreeCreateParentPick {
const picked = requestedParentWorktreeId
? getIndexedWorktreeById(state.worktreesByRepo, requestedParentWorktreeId)
: undefined
const usable = picked && !picked.isArchived && picked.repoId === repoId ? picked.id : undefined
const pickedDisplayName = picked ? resolveWorktreeDisplayName(picked).trim() : null
if (usable) {
return {
parentWorkspace: worktreeWorkspaceKey(usable),
pickedParentWorktreeId: usable,
pickedDisplayName,
staleBeforeCreate: false
}
}
const activeScope = parseWorkspaceKey(state.activeWorkspaceKey ?? '')
return {
parentWorkspace:
activeScope?.type === 'folder'
? folderWorkspaceKey(activeScope.folderWorkspaceId)
: undefined,
pickedDisplayName,
staleBeforeCreate: Boolean(requestedParentWorktreeId)
}
}
/** Resolved late so the common create path never walks the folder-workspace list, and defensively
* so a missing label can never fail a create that already succeeded. */
function parentLabel(state: AppState, parent: WorktreeCreateParentPick): string | null {
// Why not truthiness: a picked-but-unnamed worktree must warn unnamed, not name the active folder.
if (parent.pickedDisplayName !== null) {
return parent.pickedDisplayName || null
}
const scope = parent.parentWorkspace ? parseWorkspaceKey(parent.parentWorkspace) : null
if (scope?.type !== 'folder') {
return null
}
const workspace = state.folderWorkspaces?.find(
(candidate) => candidate.id === scope.folderWorkspaceId
)
return workspace?.name.trim() || null
}
export function notifyWorktreeParentDropped(
state: AppState,
parent: WorktreeCreateParentPick
): void {
const parentDisplayName = parentLabel(state, parent)
toast.warning(
parentDisplayName
? translate(
'auto.store.slices.worktrees.createdWithoutParentNesting',
'Created without nesting under "{{value0}}"',
{ value0: parentDisplayName }
)
: translate(
'auto.store.slices.worktrees.createdWithoutParentNestingUnnamed',
'Created without nesting under the selected parent'
),
{
description: translate(
'auto.store.slices.worktrees.createdWithoutParentNestingDetail',
'The parent workspace was no longer available. You can set it from the workspace menu.'
)
}
)
}
@@ -0,0 +1,132 @@
import type { CreateWorktreeArgs } from '../../../../../../shared/worktree/create-types'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import type { WorkspaceKey } from '../../../../../../shared/folder-workspace-types'
import type { TaskSourceContext } from '../../../../../../shared/task-source-context'
import type { WorkspaceLinkedItem } from '../../../../../../shared/worktree/types'
/** Trailing bag for `createWorktree` args that outgrew its positional list. */
export type CreateWorktreeCallOptions = {
automationProvenanceRequest?: CreateWorktreeArgs['automationProvenanceRequest']
linkedWorkItem?: WorkspaceLinkedItem | null
linkedTaskSourceContext?: TaskSourceContext | null
/** Lets the owning runtime launch and prefill a task agent without first creating an idle shell. */
startupDraft?: string
/** True only when `name` came from the creature-name generator; gates host-side retirement. */
nameWasGenerated?: boolean
/** Parent picked in the composer. Sets sidebar nesting only; ignored if it no longer exists. */
parentWorktreeId?: string
provisionedRoot?: {
runtimeId: string
executionHostId: ExecutionHostId
expectedPath: string
}
}
/** Everything `createWorktree` received, packed once so both transports read from the same record.
* The per-attempt fields live on `WorktreeCreateAttempt` instead. */
export type WorktreeCreateRequest = Omit<CreateWorktreeArgs, 'parentWorkspace' | 'manualOrder'> & {
options?: CreateWorktreeCallOptions
}
/** Per-attempt values: names carry the conflict-retry suffix, the parent can be dropped on retry. */
export type WorktreeCreateAttempt = {
name: string
branchNameOverride?: string
parentWorkspace?: WorkspaceKey
manualOrder?: number
}
/** Fields both transports spell the same way; one builder keeps IPC and RPC payloads from drifting. */
function sharedCreateFields(
request: WorktreeCreateRequest,
attempt: WorktreeCreateAttempt
): Omit<CreateWorktreeArgs, 'repoId' | 'startup' | 'creationId'> {
const { options } = request
return {
name: attempt.name,
...(options?.nameWasGenerated ? { nameWasGenerated: true } : {}),
baseBranch: request.baseBranch,
...(request.compareBaseRef ? { compareBaseRef: request.compareBaseRef } : {}),
...(attempt.branchNameOverride ? { branchNameOverride: attempt.branchNameOverride } : {}),
setupDecision: request.setupDecision,
sparseCheckout: request.sparseCheckout,
...(request.displayName ? { displayName: request.displayName } : {}),
...(request.telemetrySource ? { telemetrySource: request.telemetrySource } : {}),
...(request.linkedIssue !== undefined ? { linkedIssue: request.linkedIssue } : {}),
...(request.linkedPR !== undefined ? { linkedPR: request.linkedPR } : {}),
...(request.pushTarget ? { pushTarget: request.pushTarget } : {}),
...(request.createdWithAgent ? { createdWithAgent: request.createdWithAgent } : {}),
...(request.pendingFirstAgentMessageRename === true && request.createdWithAgent
? { pendingFirstAgentMessageRename: true }
: {}),
...(request.linkedLinearIssue !== undefined
? { linkedLinearIssue: request.linkedLinearIssue }
: {}),
...(request.linkedLinearIssueWorkspaceId !== undefined
? { linkedLinearIssueWorkspaceId: request.linkedLinearIssueWorkspaceId }
: {}),
...(request.linkedLinearIssueOrganizationUrlKey !== undefined
? {
linkedLinearIssueOrganizationUrlKey: request.linkedLinearIssueOrganizationUrlKey
}
: {}),
...(attempt.manualOrder !== undefined ? { manualOrder: attempt.manualOrder } : {}),
...(attempt.parentWorkspace ? { parentWorkspace: attempt.parentWorkspace } : {}),
...(request.workspaceStatus !== undefined ? { workspaceStatus: request.workspaceStatus } : {}),
...(request.linkedGitLabMR !== undefined ? { linkedGitLabMR: request.linkedGitLabMR } : {}),
...(request.linkedGitLabIssue !== undefined
? { linkedGitLabIssue: request.linkedGitLabIssue }
: {}),
...(request.linkedBitbucketPR !== undefined
? { linkedBitbucketPR: request.linkedBitbucketPR }
: {}),
...(request.linkedAzureDevOpsPR !== undefined
? { linkedAzureDevOpsPR: request.linkedAzureDevOpsPR }
: {}),
...(request.linkedGiteaPR !== undefined ? { linkedGiteaPR: request.linkedGiteaPR } : {}),
...(options?.linkedWorkItem !== undefined ? { linkedWorkItem: options.linkedWorkItem } : {}),
...(options?.linkedTaskSourceContext !== undefined
? { linkedTaskSourceContext: options.linkedTaskSourceContext }
: {}),
...(options?.automationProvenanceRequest
? { automationProvenanceRequest: options.automationProvenanceRequest }
: {})
}
}
export function buildLocalWorktreeCreateArgs(
request: WorktreeCreateRequest,
attempt: WorktreeCreateAttempt
): CreateWorktreeArgs {
return {
repoId: request.repoId,
...sharedCreateFields(request, attempt),
...(request.startup ? { startup: request.startup } : {}),
...(request.creationId ? { creationId: request.creationId } : {})
}
}
export function buildRuntimeWorktreeCreateParams(
request: WorktreeCreateRequest,
attempt: WorktreeCreateAttempt
): Record<string, unknown> {
const { startup, options } = request
return {
repo: request.repoId,
...sharedCreateFields(request, attempt),
// Why: the host defaults a bare `parentWorkspace` to CLI provenance; app picks are manual.
...(attempt.parentWorkspace ? { parentWorkspaceOrigin: 'manual' } : {}),
...(options?.startupDraft ? { startupDraft: options.startupDraft } : {}),
...(startup
? {
startupCommand: startup.command,
...(startup.env ? { startupEnv: startup.env } : {}),
...(startup.launchConfig ? { startupLaunchConfig: startup.launchConfig } : {}),
...(startup.startupCommandDelivery
? { startupCommandDelivery: startup.startupCommandDelivery }
: {}),
activate: true
}
: {})
}
}
@@ -110,7 +110,7 @@ export function createFetchWorktrees(
// Direct SSH lineage requires its own qualified authority result.
// Bulk runtime callers apply one final host-wide snapshot after all repo merges.
if (!directSshAuthority && !options?.suppressRemoteLineageRefresh) {
await refreshRemoteWorktreeLineageBestEffort(settings, set)
await refreshRemoteWorktreeLineageBestEffort(settings, set, get)
}
return directCallerAuthority ? refresh.providerResult! : refresh.result.authoritative
} catch (err) {
@@ -11,3 +11,9 @@ export function isRuntimeSelectorNotFoundError(error: unknown): boolean {
export function isRuntimeRepoNotFoundError(error: unknown): boolean {
return hasRuntimeRpcErrorCode(error, 'repo_not_found')
}
/** Thrown before the worktree exists, so the caller can safely retry without the parent.
* Matches re-wrapped errors too: relay and environment transports lose the RuntimeRpcCallError. */
export function isRuntimeLineageParentMissingError(error: unknown): boolean {
return hasRuntimeRpcErrorCode(error, 'LINEAGE_PARENT_NOT_FOUND')
}
@@ -12,10 +12,11 @@ import { settingsForWorktreeOwner } from '../listing/worktree-owner-settings'
// Why: this runs inside a catch, so letting the refresh reject would replace the failure it recovers from.
async function refreshWorktreeLineageBestEffort(
ownerSettings: AppState['settings'],
set: WorktreeSliceSet
set: WorktreeSliceSet,
get: WorktreeSliceGet
): Promise<void> {
try {
await refreshWorktreeLineageForSettings(ownerSettings, set)
await refreshWorktreeLineageForSettings(ownerSettings, set, get)
} catch (err) {
console.error('Failed to refresh worktree lineage after a failed write:', err)
}
@@ -41,7 +42,7 @@ export function createFetchWorktreeLineage(
const settings = ownerSettings
? { ...ownerSettings, activeRuntimeEnvironmentId }
: ({ activeRuntimeEnvironmentId } as AppState['settings'])
await refreshWorktreeLineageForSettings(settings, set, {
await refreshWorktreeLineageForSettings(settings, set, get, {
reuseRecentCompatibilityFailure: true
})
} catch (err) {
@@ -66,7 +67,7 @@ export function createUpdateWorktreeLineage(
)
} catch (err) {
console.error('Failed to update worktree lineage:', err)
await refreshWorktreeLineageBestEffort(ownerSettings, set)
await refreshWorktreeLineageBestEffort(ownerSettings, set, get)
throw err
}
}
@@ -87,7 +88,7 @@ export function createAssignWorktreeParent(
} catch (err) {
console.error('Failed to assign worktree parent:', err)
// Unlike the update path this rethrows, so the recovery refresh must not mask the original cause.
await refreshWorktreeLineageBestEffort(ownerSettings, set)
await refreshWorktreeLineageBestEffort(ownerSettings, set, get)
throw err
}
}
@@ -37,16 +37,24 @@ export function mergeLineageForHost(
'repos' | 'settings' | 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'worktreeLineageById'
>,
hostId: ExecutionHostId,
lineage: Readonly<Record<string, WorktreeLineage>>
lineage: Readonly<Record<string, WorktreeLineage>>,
lineageAtRequestStart?: Readonly<Record<string, WorktreeLineage>>
): Readonly<Record<string, WorktreeLineage>> {
// Why per-row and not just per-key: any local write that landed after this request started
// outranks the reply — a create, a parent reassignment, or a delete the reply would restore.
const writtenSinceRequestStart = (worktreeId: string): boolean =>
lineageAtRequestStart !== undefined &&
lineageAtRequestStart[worktreeId] !== state.worktreeLineageById[worktreeId]
const next: Record<string, WorktreeLineage> = {}
for (const [worktreeId, existing] of Object.entries(state.worktreeLineageById)) {
if (getWorktreeHostId(state, worktreeId) !== hostId) {
if (getWorktreeHostId(state, worktreeId) !== hostId || writtenSinceRequestStart(worktreeId)) {
next[worktreeId] = existing
}
}
for (const [worktreeId, incoming] of Object.entries(lineage)) {
next[worktreeId] = incoming
if (!writtenSinceRequestStart(worktreeId)) {
next[worktreeId] = incoming
}
}
return reuseEqualRecordMap(state.worktreeLineageById, next)
}
@@ -61,20 +69,30 @@ export function mergeWorkspaceLineageForHost(
| 'workspaceLineageByChildKey'
>,
hostId: ExecutionHostId,
lineage: Readonly<Record<string, WorkspaceLineage>>
lineage: Readonly<Record<string, WorkspaceLineage>>,
lineageAtRequestStart?: Readonly<Record<string, WorkspaceLineage>>
): Readonly<Record<string, WorkspaceLineage>> {
const writtenSinceRequestStart = (childKey: string): boolean =>
lineageAtRequestStart !== undefined &&
lineageAtRequestStart[childKey] !== state.workspaceLineageByChildKey[childKey]
const next: Record<string, WorkspaceLineage> = {}
for (const [childKey, existing] of Object.entries(state.workspaceLineageByChildKey)) {
const childScope = parseWorkspaceKey(existing.childWorkspaceKey)
const childHostId =
childScope?.type === 'worktree' ? getWorktreeHostId(state, childScope.worktreeId) : null
// A focused host refresh can no longer prove unknown-host child rows are current.
if (childScope?.type !== 'worktree' || (childHostId !== null && childHostId !== hostId)) {
if (
childScope?.type !== 'worktree' ||
(childHostId !== null && childHostId !== hostId) ||
writtenSinceRequestStart(childKey)
) {
next[childKey] = existing
}
}
for (const [childKey, incoming] of Object.entries(lineage)) {
next[childKey] = incoming
if (!writtenSinceRequestStart(childKey)) {
next[childKey] = incoming
}
}
return reuseEqualRecordMap(state.workspaceLineageByChildKey, next)
}
@@ -22,6 +22,21 @@ import type {
WorktreeWithLineage
} from '../listing/worktree-slice-types'
/** Per-row baseline the reply is merged against, so any local write since then outranks it. */
type LineageAtRequestStart = {
worktreeLineageById: Readonly<Record<string, WorktreeLineage>>
workspaceLineageByChildKey: Readonly<Record<string, WorkspaceLineage>>
}
function captureLineageAtRequestStart(
state: Pick<AppState, 'worktreeLineageById' | 'workspaceLineageByChildKey'>
): LineageAtRequestStart {
return {
worktreeLineageById: state.worktreeLineageById,
workspaceLineageByChildKey: state.workspaceLineageByChildKey
}
}
export async function listWorktreeLineageForRuntime(
settings: AppState['settings'],
options: BackgroundRuntimeRefreshOptions = {}
@@ -206,14 +221,21 @@ export function applyHostLineageRefresh(
lineage: {
worktreeLineageById: Readonly<Record<string, WorktreeLineage>>
workspaceLineageByChildKey: Readonly<Record<string, WorkspaceLineage>>
}
},
lineageAtRequestStart?: LineageAtRequestStart
): void {
set((s) => {
const worktreeLineageById = mergeLineageForHost(s, hostId, lineage.worktreeLineageById)
const worktreeLineageById = mergeLineageForHost(
s,
hostId,
lineage.worktreeLineageById,
lineageAtRequestStart?.worktreeLineageById
)
const workspaceLineageByChildKey = mergeWorkspaceLineageForHost(
s,
hostId,
lineage.workspaceLineageByChildKey
lineage.workspaceLineageByChildKey,
lineageAtRequestStart?.workspaceLineageByChildKey
)
if (
worktreeLineageById === s.worktreeLineageById &&
@@ -228,24 +250,38 @@ export function applyHostLineageRefresh(
export async function refreshWorktreeLineageForSettings(
settings: AppState['settings'],
set: Parameters<StateCreator<AppState>>[0],
getState: () => Pick<AppState, 'worktreeLineageById' | 'workspaceLineageByChildKey'>,
options: BackgroundRuntimeRefreshOptions = {}
): Promise<void> {
const lineageAtRequestStart = captureLineageAtRequestStart(getState())
const lineage = await listWorktreeLineageForRuntime(settings, options)
applyHostLineageRefresh(set, getSettingsFocusedExecutionHostId(settings), lineage)
applyHostLineageRefresh(
set,
getSettingsFocusedExecutionHostId(settings),
lineage,
lineageAtRequestStart
)
}
export async function refreshRemoteWorktreeLineageBestEffort(
settings: AppState['settings'],
set: (partial: Partial<AppState> | ((state: AppState) => Partial<AppState>)) => void
set: (partial: Partial<AppState> | ((state: AppState) => Partial<AppState>)) => void,
getState: () => Pick<AppState, 'worktreeLineageById' | 'workspaceLineageByChildKey'>
): Promise<void> {
if (getActiveRuntimeTarget(settings).kind === 'local') {
return
}
try {
const lineageAtRequestStart = captureLineageAtRequestStart(getState())
const lineage = await listWorktreeLineageForRuntime(settings, {
reuseRecentCompatibilityFailure: true
})
applyHostLineageRefresh(set, getSettingsFocusedExecutionHostId(settings), lineage)
applyHostLineageRefresh(
set,
getSettingsFocusedExecutionHostId(settings),
lineage,
lineageAtRequestStart
)
} catch (err) {
// Why: lineage is supplemental, so a remote timeout here must not discard a successful worktree refresh.
console.error('Failed to fetch worktree lineage:', err)
@@ -78,7 +78,9 @@ export async function callRuntimeResult<TResult>(
): Promise<TResult> {
const response = await callRuntimeEnvelope(method, params, timeoutMs)
if (!response.ok) {
throw new Error(response.error.message)
// Why keep the code: callers classify recoverable host failures by token, and the message alone
// (e.g. "Parent selector was not found.") carries none.
throw Object.assign(new Error(response.error.message), { code: response.error.code })
}
return response.result as TResult
}
@@ -86,6 +86,8 @@ export function createWorktreesApi(): NonNullable<Partial<PreloadApi>['worktrees
}
: {}),
parentWorkspace: args.parentWorkspace,
// Why: every create through this API is an in-app action, never the CLI's parent flag.
...(args.parentWorkspace ? { parentWorkspaceOrigin: 'manual' } : {}),
workspaceStatus: args.workspaceStatus,
manualOrder: args.manualOrder,
automationProvenanceRequest: args.automationProvenanceRequest
@@ -677,6 +677,49 @@ describe('web worktree preload API', () => {
expect(runtimeCalls[0]?.params).not.toHaveProperty('nameWasGenerated')
})
// Why: without it the host records the app's parent pick as a CLI flag, which cleans up differently.
it('marks a parent-picked create as a manual action', async () => {
const runtimeCalls: { method: string; params: unknown }[] = []
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call(method: string, params?: unknown): Promise<RuntimeRpcResponse<unknown>> {
runtimeCalls.push({ method, params })
return Promise.resolve({
id: `call-${runtimeCalls.length}`,
ok: true,
result: { worktree: { id: 'repo-1::/workspace/child', path: '/workspace/child' } },
_meta: { runtimeId: 'runtime-1' }
})
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
await globals.window.api.worktrees.create({
repoId: 'repo-1',
name: 'child',
setupDecision: 'inherit'
})
await globals.window.api.worktrees.create({
repoId: 'repo-1',
name: 'child',
setupDecision: 'inherit',
parentWorkspace: 'folder:folder-1'
})
expect(runtimeCalls[0]?.params).not.toHaveProperty('parentWorkspaceOrigin')
expect(runtimeCalls[1]?.params).toMatchObject({
parentWorkspace: 'folder:folder-1',
parentWorkspaceOrigin: 'manual'
})
})
it('encodes explicit push target clears for runtime worktree updates', async () => {
const runtimeCalls: { method: string; params: unknown }[] = []
vi.doMock('./web-runtime-client', () => ({
+40 -1
View File
@@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest'
import { join } from 'node:path'
import type { WorktreeLineage } from './worktree/lineage-types'
import type { Worktree } from './worktree/types'
import { projectResolvedWorktreeLineage } from './resolved-worktree-lineage'
import type { WorktreeLineageBoundary } from './resolved-worktree-lineage'
import {
projectResolvedWorktreeLineage,
sharesWorktreeLineageBoundary
} from './resolved-worktree-lineage'
function worktree(id: string, instanceId: string, overrides: Partial<Worktree> = {}): Worktree {
return {
@@ -41,6 +45,41 @@ function lineage(overrides: Partial<WorktreeLineage> = {}): WorktreeLineage {
}
}
describe('sharesWorktreeLineageBoundary', () => {
const boundary = (overrides: Partial<WorktreeLineageBoundary> = {}): WorktreeLineageBoundary => ({
repoId: 'repo',
hostId: 'local',
projectId: 'github:stablyai/orca',
...overrides
})
it('accepts an identical repo, host, and project', () => {
expect(sharesWorktreeLineageBoundary(boundary(), boundary())).toBe(true)
})
it('rejects a differing repo even when host and project agree', () => {
expect(sharesWorktreeLineageBoundary(boundary(), boundary({ repoId: 'other-repo' }))).toBe(
false
)
})
it.each([
['child host', boundary({ hostId: undefined }), boundary()],
['parent host', boundary(), boundary({ hostId: undefined })],
['child project', boundary({ projectId: undefined }), boundary()],
['parent project', boundary(), boundary({ projectId: undefined })]
])('treats an undefined %s as compatible', (_label, child, parent) => {
expect(sharesWorktreeLineageBoundary(child, parent)).toBe(true)
})
it.each([
['host', boundary({ hostId: 'ssh:remote' })],
['project', boundary({ projectId: 'github:other/project' })]
])('rejects a defined %s mismatch', (_label, parent) => {
expect(sharesWorktreeLineageBoundary(boundary(), parent)).toBe(false)
})
})
describe('projectResolvedWorktreeLineage', () => {
const parent = worktree('parent', 'parent-instance')
const child = worktree('child', 'child-instance')
+12 -1
View File
@@ -7,7 +7,14 @@ export type WorktreeWithResolvedLineage<T extends Worktree = Worktree> = T & {
lineage: WorktreeLineage | null
}
export function sharesResolvedWorktreeLineageBoundary(child: Worktree, parent: Worktree): boolean {
/** The fields a lineage edge is scoped by. Split out so create-time callers — which have a
* projected child, not a real Worktree — can check the same rule the projection enforces. */
export type WorktreeLineageBoundary = Pick<Worktree, 'repoId' | 'hostId' | 'projectId'>
export function sharesWorktreeLineageBoundary(
child: WorktreeLineageBoundary,
parent: WorktreeLineageBoundary
): boolean {
return (
child.repoId === parent.repoId &&
(child.hostId === undefined || parent.hostId === undefined || child.hostId === parent.hostId) &&
@@ -17,6 +24,10 @@ export function sharesResolvedWorktreeLineageBoundary(child: Worktree, parent: W
)
}
export function sharesResolvedWorktreeLineageBoundary(child: Worktree, parent: Worktree): boolean {
return sharesWorktreeLineageBoundary(child, parent)
}
export function isValidResolvedWorktreeLineageEdge(
child: Worktree,
parent: Worktree,
@@ -16,6 +16,13 @@ type DockerSshRelayConnectionOptions = {
relayGracePeriodSeconds?: number
remotePath?: string
viaProxyJump?: boolean
/**
* Seed a terminal tab when the worktree has none. Default true.
*
* Why it is optional: a spec asking whether the PRODUCT adds a tab cannot tell this helper's
* tab from the one under test, so it must be able to leave the worktree empty.
*/
seedInitialTab?: boolean
}
export async function connectDockerSshRelayTarget(
@@ -24,7 +31,7 @@ export async function connectDockerSshRelayTarget(
options: DockerSshRelayConnectionOptions = {}
): Promise<ConnectedDockerSshRelayTarget> {
return page.evaluate(
async ({ target, remotePath, relayGracePeriodSeconds, viaProxyJump }) => {
async ({ target, remotePath, relayGracePeriodSeconds, viaProxyJump, seedInitialTab }) => {
const store = window.__store
if (!store) {
throw new Error('Store unavailable')
@@ -147,7 +154,7 @@ export async function connectDockerSshRelayTarget(
throw new Error(`No remote worktree found for ${result.repo.path}`)
}
store.getState().setActiveWorktree(worktree.id)
if ((store.getState().tabsByWorktree[worktree.id] ?? []).length === 0) {
if (seedInitialTab && (store.getState().tabsByWorktree[worktree.id] ?? []).length === 0) {
store.getState().createTab(worktree.id)
}
store.getState().setActiveTabType('terminal')
@@ -168,6 +175,7 @@ export async function connectDockerSshRelayTarget(
? DOCKER_SSH_PROXY_JUMP_REMOTE_REPO_PATH
: DOCKER_SSH_RELAY_REMOTE_REPO_PATH),
viaProxyJump: options.viaProxyJump ?? false,
seedInitialTab: options.seedInitialTab ?? true,
relayGracePeriodSeconds: options.relayGracePeriodSeconds ?? 1
}
)
@@ -0,0 +1,38 @@
import { expect, type Page } from '@stablyai/playwright-test'
import { waitForActivePanePtyId, waitForActiveTerminalManager } from './terminal'
/** Add one more terminal tab to a connected remote worktree and wait for it to own a PTY. */
export async function createRemoteTerminalTab(page: Page, worktreeId: string): Promise<void> {
const tabId = await page.evaluate((id) => {
const state = window.__store?.getState()
if (!state) {
throw new Error('Store unavailable')
}
const tab = state.createTab(id, undefined, undefined, { activate: true })
state.setActiveTab(tab.id)
state.setActiveTabType('terminal')
return tab.id
}, worktreeId)
await expect
.poll(() => page.evaluate(() => window.__store?.getState().activeTabId ?? null), {
timeout: 10_000
})
.toBe(tabId)
await waitForActiveTerminalManager(page, 60_000)
await waitForActivePanePtyId(page, 60_000)
}
export async function readRemoteTerminalTabs(
page: Page,
worktreeId: string
): Promise<{ id: string; ptyId: string | null }[]> {
return page.evaluate(
(id) =>
(window.__store?.getState().tabsByWorktree[id] ?? []).map((tab) => ({
id: tab.id,
ptyId: tab.ptyId
})),
worktreeId
)
}
+5 -35
View File
@@ -1,4 +1,4 @@
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
import type { ElectronApplication } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
@@ -6,6 +6,10 @@ import {
waitForActivePanePtyId,
waitForActiveTerminalManager
} from './helpers/terminal'
import {
createRemoteTerminalTab,
readRemoteTerminalTabs
} from './helpers/docker-ssh-relay-terminal-tabs'
import {
cleanupDockerSshRelayTarget,
DOCKER_SSH_RELAY_REMOTE_REPO_PATH,
@@ -21,40 +25,6 @@ const TAB_COUNT = 6
test.use({ seedTestRepo: false })
async function createRemoteTerminalTab(page: Page, worktreeId: string): Promise<void> {
const tabId = await page.evaluate((id) => {
const state = window.__store?.getState()
if (!state) {
throw new Error('Store unavailable')
}
const tab = state.createTab(id, undefined, undefined, { activate: true })
state.setActiveTab(tab.id)
state.setActiveTabType('terminal')
return tab.id
}, worktreeId)
await expect
.poll(() => page.evaluate(() => window.__store?.getState().activeTabId ?? null), {
timeout: 10_000
})
.toBe(tabId)
await waitForActiveTerminalManager(page, 60_000)
await waitForActivePanePtyId(page, 60_000)
}
async function readRemoteTerminalTabs(
page: Page,
worktreeId: string
): Promise<{ id: string; ptyId: string | null }[]> {
return page.evaluate(
(id) =>
(window.__store?.getState().tabsByWorktree[id] ?? []).map((tab) => ({
id: tab.id,
ptyId: tab.ptyId
})),
worktreeId
)
}
function readRemoteProof(target: DockerSshRelayTarget, path: string): string | null {
try {
return execDockerSshRelayTargetCommand(target, `cat ${path}`)
@@ -85,12 +85,12 @@ test.describe('SSH reconnect tab destruction', () => {
await openTerminalTabInActiveGroup(orcaPage)
// Only that the tab exists in the store — no waiting for its manager or PTY. Every wait here
// is time the debounced upload can use to land, which is what made this spec miss the bug.
const tabsBefore = await orcaPage.evaluate(() => {
const tabIdsBefore = await orcaPage.evaluate(() => {
const state = window.__store?.getState()
const worktreeId = state?.activeWorktreeId
return worktreeId ? (state?.tabsByWorktree?.[worktreeId]?.length ?? 0) : 0
return worktreeId ? (state?.tabsByWorktree?.[worktreeId] ?? []).map((tab) => tab.id) : []
})
expect(tabsBefore).toBeGreaterThanOrEqual(2)
expect(tabIdsBefore.length).toBeGreaterThanOrEqual(2)
// Deliberately NOTHING between creating the tab and reconnecting. The destruction only fires
// while the tab's creation is still unuploaded, so idling here — as waiting for a TUI to draw
@@ -101,19 +101,26 @@ test.describe('SSH reconnect tab destruction', () => {
// Checked BEFORE any paint assertion: survival and repaint are different failures, and this
// order names which one broke instead of collapsing both into "no output".
const tabCounts = await orcaPage.evaluate(() => {
const tabState = await orcaPage.evaluate(() => {
const state = window.__store?.getState()
const worktreeId = state?.activeWorktreeId
return {
inSlice: worktreeId ? (state?.tabsByWorktree?.[worktreeId]?.length ?? 0) : 0,
tabIds: worktreeId
? (state?.tabsByWorktree?.[worktreeId] ?? []).map((tab) => tab.id)
: [],
// __paneManagers is a Map. Object.keys on a Map silently returns [], which reads as
// "nothing is mounted" regardless of the truth — that cost a full debugging cycle.
paneManagers: window.__paneManagers?.size ?? 0
}
})
expect(tabCounts.inSlice, 'the reconnect destroyed the tab').toBeGreaterThanOrEqual(2)
// The exact set, not a lower bound: `>= 2` passes just as happily on a reconnect that ADDS a
// tab as on one that keeps it, so it could never fail on the accumulation half of this bug.
expect(
tabCounts.paneManagers,
tabState.tabIds.slice().sort(),
'the reconnect changed the tab set: it destroyed a tab or spuriously added one'
).toEqual(tabIdsBefore.slice().sort())
expect(
tabState.paneManagers,
'the tab survived but its pane manager did not'
).toBeGreaterThanOrEqual(1)
@@ -0,0 +1,257 @@
import type { ElectronApplication, Page, TestInfo } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { waitForActivePanePtyId, waitForActiveTerminalManager } from './helpers/terminal'
import { createRemoteTerminalTab } from './helpers/docker-ssh-relay-terminal-tabs'
import {
cleanupDockerSshRelayTarget,
startDockerSshRelayTarget,
type DockerSshRelayTarget
} from './helpers/docker-ssh-relay-target'
import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection'
import { createRestartSession } from './helpers/orca-restart'
const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1'
const RESTART_CYCLES = 3
/** Consecutive agreeing samples that count as "the strip stopped changing". */
const SETTLED_SAMPLES = 3
test.use({ seedTestRepo: false })
type WorkspaceTabSnapshot = {
/** Terminal tabs the store owns for the remote worktree. */
worktreeTabIds: string[]
/** Tabs the strip actually renders — what the user counts. */
stripTabIds: string[]
/** Why: a restart that re-adds the worktree under a new id would grow the window without growing the row above. */
totalTabCount: number
remoteWorktreeCount: number
}
async function readWorkspaceTabSnapshot(
page: Page,
worktreeId: string,
repoId: string
): Promise<WorkspaceTabSnapshot> {
const store = await page.evaluate(
({ worktreeId, repoId }) => {
const state = window.__store?.getState()
if (!state) {
throw new Error('Store unavailable')
}
return {
worktreeTabIds: (state.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id),
totalTabCount: Object.values(state.tabsByWorktree).reduce(
(total, tabs) => total + tabs.length,
0
),
remoteWorktreeCount: (state.worktreesByRepo[repoId] ?? []).length
}
},
{ worktreeId, repoId }
)
const stripTabIds = await page
.locator('.terminal-tab-strip [data-tab-id]')
.evaluateAll((elements) =>
elements.map((element) => (element as HTMLElement).dataset.tabId ?? '')
)
return { ...store, stripTabIds }
}
/**
* Sample the workspace until its tabs stop changing.
*
* Why: restore is not one event — the session rehydrates, then the relay reconnects, then worktree
* activation runs. A tab spawned by the last of those is invisible to a snapshot taken after the
* first, so sampling too early would let an accumulation bug pass.
*/
async function waitForSettledWorkspaceTabs(
page: Page,
worktreeId: string,
repoId: string
): Promise<WorkspaceTabSnapshot> {
let latest: WorkspaceTabSnapshot = {
worktreeTabIds: [],
stripTabIds: [],
totalTabCount: 0,
remoteWorktreeCount: 0
}
let previousKey = ''
let agreements = 0
await expect
.poll(
async () => {
latest = await readWorkspaceTabSnapshot(page, worktreeId, repoId)
const key = JSON.stringify(latest)
agreements = key === previousKey ? agreements + 1 : 0
previousKey = key
return agreements
},
{
timeout: 60_000,
intervals: [1_000],
message: 'the remote workspace tab set never stopped changing'
}
)
.toBeGreaterThanOrEqual(SETTLED_SAMPLES)
return latest
}
async function waitForRestoredRemoteWorktree(
page: Page,
targetId: string,
worktreeId: string
): Promise<void> {
await waitForSessionReady(page, 60_000)
await expect.poll(() => waitForActiveWorktree(page), { timeout: 60_000 }).toBe(worktreeId)
await expect
.poll(
() =>
page.evaluate(
(id) => window.__store?.getState().sshConnectionStates.get(id)?.status,
targetId
),
{ timeout: 90_000, message: 'renderer SSH state did not restore' }
)
.toBe('connected')
await waitForActiveTerminalManager(page, 60_000)
await waitForActivePanePtyId(page, 60_000)
}
/** Quit through the same beforeunload flush a real window close performs, then prove it landed. */
async function flushSessionBeforeQuit(
page: Page,
targetId: string,
worktreeId: string,
tabIds: string[]
): Promise<void> {
await page.evaluate(() => window.dispatchEvent(new Event('beforeunload')))
await expect
.poll(
() =>
page.evaluate(
async ({ targetId, worktreeId, tabIds }) => {
const persisted = await window.api.session.get()
const persistedIds = new Set(
(persisted.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)
)
return (
persisted.activeConnectionIdsAtShutdown?.includes(targetId) === true &&
tabIds.every((tabId) => persistedIds.has(tabId))
)
},
{ targetId, worktreeId, tabIds }
),
{ timeout: 15_000, message: 'SSH tabs and active target were not persisted before quit' }
)
.toBe(true)
}
function describeGrowth(baseline: WorkspaceTabSnapshot, cycles: WorkspaceTabSnapshot[]): string {
const baselineTabIds = new Set(baseline.worktreeTabIds)
const perCycle = cycles.map((cycle, index) => {
const added = cycle.worktreeTabIds.filter((tabId) => !baselineTabIds.has(tabId))
const suffix = added.length > 0 ? ` (+${added.length}: ${added.join(', ')})` : ''
return `restart${index + 1}=${cycle.worktreeTabIds.length}${suffix}/strip${cycle.stripTabIds.length}/all${cycle.totalTabCount}/worktrees${cycle.remoteWorktreeCount}`
})
return `baseline=${baseline.worktreeTabIds.length}/strip${baseline.stripTabIds.length}/all${baseline.totalTabCount}/worktrees${baseline.remoteWorktreeCount} ${perCycle.join(' ')}`
}
async function runRestartCycles(testInfo: TestInfo, initialTabCount: number): Promise<void> {
const restart = createRestartSession(testInfo)
let target: DockerSshRelayTarget | null = null
let app: ElectronApplication | null = null
try {
target = startDockerSshRelayTarget(testInfo)
const firstLaunch = await restart.launch()
app = firstLaunch.app
let page = firstLaunch.page
await waitForSessionReady(page)
const remote = await connectDockerSshRelayTarget(page, target)
await expect
.poll(() => waitForActiveWorktree(page), { timeout: 30_000 })
.toBe(remote.worktreeId)
await waitForActiveTerminalManager(page, 60_000)
await waitForActivePanePtyId(page, 60_000)
while (
(await readWorkspaceTabSnapshot(page, remote.worktreeId, remote.repoId)).worktreeTabIds
.length < initialTabCount
) {
await createRemoteTerminalTab(page, remote.worktreeId)
}
const baseline = await waitForSettledWorkspaceTabs(page, remote.worktreeId, remote.repoId)
expect(baseline.worktreeTabIds).toHaveLength(initialTabCount)
expect(baseline.stripTabIds.slice().sort()).toEqual(baseline.worktreeTabIds.slice().sort())
// Why: every cycle runs before anything is asserted, so a failure reports whether the strip
// grows by one per restart or duplicates wholesale — those have different causes.
const cycles: WorkspaceTabSnapshot[] = []
for (let cycle = 0; cycle < RESTART_CYCLES; cycle += 1) {
await flushSessionBeforeQuit(
page,
remote.targetId,
remote.worktreeId,
baseline.worktreeTabIds
)
await restart.close(app)
app = null
const relaunch = await restart.launch()
app = relaunch.app
page = relaunch.page
await waitForRestoredRemoteWorktree(page, remote.targetId, remote.worktreeId)
cycles.push(await waitForSettledWorkspaceTabs(page, remote.worktreeId, remote.repoId))
}
const growth = describeGrowth(baseline, cycles)
expect(
cycles.map((cycle) => cycle.stripTabIds.length),
`tab strip grew across restarts: ${growth}`
).toEqual(Array.from({ length: RESTART_CYCLES }, () => initialTabCount))
expect(
cycles.map((cycle) => cycle.totalTabCount),
`total tab count grew across restarts: ${growth}`
).toEqual(cycles.map(() => baseline.totalTabCount))
expect(
cycles.map((cycle) => cycle.remoteWorktreeCount),
`the remote repo gained worktree rows across restarts: ${growth}`
).toEqual(cycles.map(() => baseline.remoteWorktreeCount))
const expectedTabIds = baseline.worktreeTabIds.slice().sort()
for (const [index, cycle] of cycles.entries()) {
expect(cycle.worktreeTabIds.slice().sort(), `restart ${index + 1}: ${growth}`).toEqual(
expectedTabIds
)
expect(
cycle.stripTabIds.slice().sort(),
`restart ${index + 1} rendered different tabs: ${growth}`
).toEqual(expectedTabIds)
}
} finally {
if (app) {
await restart.close(app)
}
await restart.dispose()
cleanupDockerSshRelayTarget(target)
}
}
test.describe('SSH restart tab accumulation', () => {
test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH tests.')
test.skip(process.platform === 'win32', 'Docker SSH restore uses POSIX SSH tooling.')
// Why three restarts and not one: a single restart only proves one restore was clean. Users reach
// this by returning to the same SSH workspace day after day, so the invariant worth pinning is
// that N restarts add nothing — one spurious tab per launch is invisible to a one-shot test.
test('keeps a single restored SSH tab across repeated quit and relaunch cycles', async (// oxlint-disable-next-line no-empty-pattern -- This restart test owns every Electron launch.
{}, testInfo) => {
test.setTimeout(600_000)
await runRestartCycles(testInfo, 1)
})
// Why a second size: separates "one spurious default tab per restart" from "the set duplicates".
test('keeps every restored SSH tab across repeated quit and relaunch cycles', async (// oxlint-disable-next-line no-empty-pattern -- This restart test owns every Electron launch.
{}, testInfo) => {
test.setTimeout(600_000)
await runRestartCycles(testInfo, 3)
})
})