mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/fix-run-binding-loss
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,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', () => ({
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user