Merge origin/main into browser-guest-paint-retention

This commit is contained in:
Brennan Benson
2026-08-14 18:03:18 -07:00
131 changed files with 8302 additions and 3399 deletions
+24
View File
@@ -50,6 +50,27 @@ jobs:
- name: Check reliability gate manifest
run: pnpm run check:reliability-gates
- name: Check VM runtime rollback compatibility
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
if git diff --quiet --merge-base "$BASE_SHA" "$HEAD_SHA" -- \
src/shared/ephemeral-vm-runtime-store.ts \
src/shared/ephemeral-vm-runtime-feature-store.ts \
src/shared/ephemeral-vm-runtime-rollback-projection.ts \
src/shared/ephemeral-vm-runtimes.ts \
src/shared/ephemeral-vm-recipes.ts \
src/shared/orca-yaml-hook-types.ts \
src/main/ephemeral-vm-runtime-service.ts \
src/main/ephemeral-vm-runtime-provisioning-persistence.ts \
src/main/ephemeral-vm-failed-start-cleanup.ts; then
echo "VM runtime persistence is unchanged."
exit 0
fi
node config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs \
config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts
- name: Enforce max-lines ratchet
run: pnpm run check:max-lines-ratchet
@@ -479,6 +500,9 @@ jobs:
HEAD="${{ github.event.pull_request.head.sha }}"
CHANGED="$(git diff --name-only --diff-filter=AMCR --merge-base "$BASE" "$HEAD")"
TEST_FILES="$(printf '%s\n' "$CHANGED" | grep -E '^tests/e2e/.*\.spec\.ts$' || true)"
if printf '%s\n' "$CHANGED" | grep -Eq '^src/(main/ephemeral-vm-(runtime-(service|provisioning-persistence)|failed-start-cleanup)|shared/(ephemeral-vm-runtime-(store|feature-store|rollback-projection|runtimes)|ephemeral-vm-recipes|orca-yaml-hook-types))\.ts$'; then
TEST_FILES="$(printf '%s\n%s\n' "$TEST_FILES" 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' | sort -u)"
fi
TEST_FILES_JSON="$(printf '%s\n' "$TEST_FILES" | jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')"
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
if [ "$TEST_FILES_JSON" != '[]' ]; then
-1
View File
@@ -178,7 +178,6 @@ inline src/relay/pty-handler.test.ts
inline src/relay/pty-handler.ts
inline src/relay/relay.ts
inline src/relay/workspace-space-scan.ts
inline src/renderer/src/App.tsx
inline src/renderer/src/components/GitHubItemDialog.tsx
inline src/renderer/src/components/GitLabItemDialog.tsx
inline src/renderer/src/components/JiraIssueWorkspace.tsx
+218 -1
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-08-13",
"updatedAt": "2026-08-14",
"policy": {
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
"blockingPromotion": {
@@ -10,6 +10,223 @@
}
},
"gates": [
{
"id": "ephemeral-vm-runtime.rollback-readable-sidecar",
"title": "VM lifecycle stays rollback-readable and cleanup-retryable",
"maturity": "experimental",
"protection": "partial",
"owner": "ephemeral-vm-runtime-store",
"layer": "desktop-main-persistence",
"surfaces": [
"VM runtime sidecar",
"recipe lifecycle",
"cleanup retry",
"provisioned-root direct SSH"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["orca-server", "direct-ssh"],
"coveredPlatforms": ["macos", "linux", "windows"],
"coveredProviders": ["orca-server", "direct-ssh"],
"coverageNotes": "A pinned exact-source harness runs the pre-#14352 reader and lifecycle service against baseline, affected-main, candidate, and fix-reverted stores. Platform-neutral tests cover store compatibility, user-cancellable cleanup, branch/ref adoption, and resume ownership integrity. The direct-SSH provisioned-root Electron journey remains the live transport and destroy proof; this desktop sidecar is not mobile-facing and changes no RPC or remote wire payload.",
"motivatingLinks": [
"https://linear.app/stably/issue/STA-4274",
"https://github.com/stablyai/orca/pull/14352",
"https://github.com/stablyai/orca/issues/13044"
],
"invariant": "A sidecar written by the new build cannot make the previous production build lose otherwise compatible VM runtimes. Downgrade lifecycle writes remain authoritative after re-upgrade, provisioned-root metadata remains recoverable, and corrupt or future feature metadata is preserved without poisoning the rollback-readable v1 store. Destroy attempts have no automatic wall-clock cutoff; a user stop terminates the process tree, retains cleanup ownership, and permits a fresh retry. Provider failure retains the SSH/project context needed for safe cleanup. Unrecoverable recipe context releases only the hidden SSH registration while preserving lifecycle/provider metadata. Provisioned-root create receives the source ref's pinned commit, adoption verifies the requested branch/ref, and resume preserves the connection mode plus stable SSH ownership identity while allowing endpoint rotation.",
"oracle": "Write one ordinary schema-v1 runtime and one provisioned-root runtime. Require the exact pre-#14352 reader to return both, run the old destroy lifecycle against the projected provisioned record, then require the candidate to retain the cleaned state while restoring schema-v2 and checkout-mode metadata. The same old reader must reject affected-main and fix-reverted bytes. Malformed, oversized, unknown-record, and future-version feature sidecars must leave rollback-compatible records accessible and must not be overwritten during compatible lifecycle mutations. Require destroy to have no implicit deadline, stop a hung destroy through the public desktop action, require cleanup_failed plus a fresh successful retry, prove provider or project-host rollback failure retains every ownership record required to retry, and prove missing recipe context cannot leave an unremovable hidden SSH registration. Supply the source ref's captured commit to create, reject a provisioned checkout on the wrong branch/ref, and reject a resume that changes connection mode or SSH ownership.",
"commands": [
"node config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/shared/ephemeral-vm-runtime-store.test.ts src/shared/ephemeral-vm-runtime-store-rollback.test.ts src/shared/ephemeral-vm-recipe-process.test.ts src/main/ephemeral-vm-runtime-service.test.ts src/main/ephemeral-vm-resume-integrity.test.ts src/main/ephemeral-vm-recipe-runner.test.ts src/main/ephemeral-vm-runtime-ssh-cleanup.test.ts src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts src/main/ipc/ephemeral-vm-provision-cancel.test.ts src/main/ipc/ephemeral-vm-provisioned-root-ref.test.ts src/main/ipc/ephemeral-vm.test.ts src/main/provisioned-root-ssh-adoption.test.ts src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts src/renderer/src/lib/provisioned-root-create-options.test.ts src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts",
"ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/ephemeral-vm-provisioned-root.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"SKIP_BUILD=1 ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/ephemeral-vm-provisioned-root.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"pnpm exec playwright test tests/e2e/ephemeral-vm-cleanup-retry.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
],
"testFiles": [
"config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts",
"config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs",
"src/shared/ephemeral-vm-runtime-store-rollback.test.ts",
"src/shared/ephemeral-vm-runtime-store.test.ts",
"src/shared/ephemeral-vm-recipe-process.test.ts",
"src/main/ephemeral-vm-runtime-service.test.ts",
"src/main/ephemeral-vm-recipe-runner.test.ts",
"src/main/ephemeral-vm-runtime-ssh-cleanup.test.ts",
"src/main/ephemeral-vm-resume-integrity.test.ts",
"src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts",
"src/main/ipc/ephemeral-vm-provision-cancel.test.ts",
"src/main/ipc/ephemeral-vm-provisioned-root-ref.test.ts",
"src/main/ipc/ephemeral-vm.test.ts",
"src/main/provisioned-root-ssh-adoption.test.ts",
"src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx",
"src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts",
"src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts",
"src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts",
"src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts",
"src/renderer/src/lib/provisioned-root-create-options.test.ts",
"src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts",
"tests/e2e/ephemeral-vm-cleanup-retry.spec.ts",
"tests/e2e/ephemeral-vm-provisioned-root.spec.ts"
],
"assertionRefs": [
{
"file": "config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts",
"assertions": [
"the exact rollback reader accepts both projected records",
"the rollback lifecycle service destroys the provisioned resource using schema-v1 provider data",
"re-upgrade restores provisioned-root metadata without reverting lifecycle state"
]
},
{
"file": "src/shared/ephemeral-vm-runtime-store-rollback.test.ts",
"assertions": [
"ordinary runtime bytes remain unchanged and create no feature sidecar",
"malformed, future, oversized, and unknown feature data is preserved without poisoning v1 records",
"current-main poisoned bytes migrate to a rollback-readable projection",
"unchanged compatibility metadata is not rewritten during lifecycle updates"
]
},
{
"file": "src/main/ephemeral-vm-runtime-service.test.ts",
"assertions": [
"an unreadable companion rejects checkout-mode recipes before their create command runs",
"post-create persistence failure destroys the provider resource when possible",
"failed destroy persists a durable rollback-readable cleanup recovery record"
]
},
{
"file": "src/shared/ephemeral-vm-recipe-process.test.ts",
"assertions": [
"recipe commands have no implicit wall-clock deadline",
"a stopped process that ignores graceful termination is force-killed and settled"
]
},
{
"file": "src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx",
"assertions": [
"a running cleanup exposes a persistent Stop cleanup action and becomes retryable after stopping"
]
},
{
"file": "src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts",
"assertions": [
"unrecoverable recipe context releases the hidden SSH registration but preserves failed lifecycle state"
]
},
{
"file": "src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts",
"assertions": [
"provider failure retains runtime-owned SSH targets and confirmed success releases them",
"unknown runtime inventory retains an explicitly requested target"
]
},
{
"file": "src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts",
"assertions": ["failed or unconfirmed project-host rollback prevents provider destroy"]
},
{
"file": "src/main/provisioned-root-ssh-adoption.test.ts",
"assertions": [
"adoption rejects a checkout on an unrequested branch or ref",
"tag and commit refs remain valid when the requested local branch is attached"
]
},
{
"file": "src/main/ipc/ephemeral-vm-provisioned-root-ref.test.ts",
"assertions": [
"selected and default source ref commits are captured before provider create",
"the create recipe receives both the symbolic ref and its pinned commit",
"multi-remote bases pair the selected remote URL with a fetchable branch ref",
"an unresolved source ref rejects before provider create"
]
},
{
"file": "src/main/ipc/ephemeral-vm-provision-cancel.test.ts",
"assertions": ["cancellation during source-ref resolution aborts before provider create"]
},
{
"file": "src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts",
"assertions": [
"the captured source commit reaches the main-owned provisioned-root adoption request"
]
},
{
"file": "src/main/ephemeral-vm-resume-integrity.test.ts",
"assertions": [
"resume allows host and port rotation",
"resume rejects connection-mode and stable SSH ownership drift"
]
},
{
"file": "tests/e2e/ephemeral-vm-cleanup-retry.spec.ts",
"assertions": [
"the rendered runtime row stops a live cleanup process without an automatic deadline and a retry succeeds"
]
},
{
"file": "tests/e2e/ephemeral-vm-provisioned-root.spec.ts",
"assertions": [
"default-base create adopts the recipe checkout at the captured source commit",
"workspace removal invokes recipe destroy and removes the provider container"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-14",
"runner": "local",
"platform": "macos",
"command": "node config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts",
"result": "passed",
"durationSeconds": 11,
"summary": "The pinned baseline and affected-main oracles passed, candidate downgrade ran the exact old destroy service, candidate re-upgrade retained lifecycle state, and both affected-main and fix-reverted bytes failed the old reader as expected."
},
{
"date": "2026-08-14",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/ephemeral-vm-runtime-store.test.ts src/shared/ephemeral-vm-runtime-store-rollback.test.ts src/shared/ephemeral-vm-recipe-process.test.ts src/main/ephemeral-vm-runtime-service.test.ts src/main/ephemeral-vm-resume-integrity.test.ts src/main/ephemeral-vm-recipe-runner.test.ts src/main/ephemeral-vm-runtime-ssh-cleanup.test.ts src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts src/main/ipc/ephemeral-vm-provision-cancel.test.ts src/main/ipc/ephemeral-vm-provisioned-root-ref.test.ts src/main/ipc/ephemeral-vm.test.ts src/main/provisioned-root-ssh-adoption.test.ts src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx src/renderer/src/lib/ephemeral-vm-failed-create-cleanup.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/lib/ephemeral-vm-workspace-target.test.ts src/renderer/src/lib/ephemeral-vm-worktree-creation.test.ts src/renderer/src/lib/provisioned-root-create-options.test.ts src/renderer/src/store/slices/repos-ephemeral-vm-cleanup-retention.test.ts",
"result": "passed",
"durationSeconds": 3,
"summary": "One hundred twenty-two focused store, recipe-process, lifecycle, cleanup-handler, rendered stop-action, retry-context, source-ref, adoption-integrity, resume-integrity, and provisioning-recovery tests passed."
},
{
"date": "2026-08-14",
"runner": "local",
"platform": "macos",
"command": "ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/ephemeral-vm-provisioned-root.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"result": "passed",
"durationSeconds": 22,
"summary": "The final-tree full-build run completed the Docker Linux SSH journey: default-base create at the captured source commit, provisioned-root adoption, terminal, workspace removal, destroy, and provider-container cleanup."
}
],
"runtimeBudget": {
"p95Seconds": 180,
"scope": "pinned cross-version source oracle, focused lifecycle contracts, and one Docker SSH Electron journey"
},
"flakeHistory": {
"status": "unknown",
"evidence": "The deterministic contract runs are stable. Two local Electron attempts reached successful adoption and terminal use, then hit the existing final context-menu detach; the immediate build-reuse rerun completed removal and destroy. CI soak history has not started."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The same pinned oracle passes the pre-#14352 ordinary store, fails the affected-main mixed store with file-is-invalid, passes the candidate mixed store plus old-service destroy and re-upgrade, and fails again when the candidate compatibility files are reverted to affected main."
},
"performanceBudget": {
"required": true,
"evidence": "Ordinary writes remain byte-identical and create no feature sidecar. Reads add one bounded feature-file existence check; sorted feature comparisons avoid unchanged rewrites, and durable companion writes occur only when compatibility metadata changes. Cleanup adds one constant-size in-flight controller per active destroy and no automatic deadline; the visible settings section polls once per second only while cleanup is active. Provisioning adds one source-host Git ref-resolution command only when a ref was requested and runs it before provider create; adoption adds no remote subprocess beyond its existing checkout probes, and resume adds only in-memory comparisons."
},
"promotionCriteria": [
"Keep the pinned baseline/latest/candidate/revert oracle green in CI.",
"Keep the Docker provisioned-root create/adopt/terminal/remove/destroy journey green.",
"Keep user-stopped destroy, fresh retry, and retained-context failure tests green.",
"Collect soak history without unexplained lifecycle or sidecar flakes."
],
"knownGaps": [
"No packaged-build downgrade installer journey; the exact released source reader and service are executed in-process.",
"Live Docker SSH evidence runs locally on macOS and in the changed-file PR E2E lane on Ubuntu; Windows desktop behavior is covered by the platform-neutral filesystem codec and package job."
],
"demotionRule": "Demote if a new write fails the pinned rollback reader, a downgrade lifecycle mutation is lost after re-upgrade, malformed/future metadata overwrites recoverable bytes, ordinary v1 bytes change, user stop cannot settle a destroy process tree, cleanup loses retry ownership, an unrecoverable context leaves a permanent hidden SSH registration, or the focused gate flakes without an identified harness defect."
},
{
"id": "terminal-session.shell-ready-exec-prompt-fallback",
"title": "Startup exec falls back to the identified shell's line editor",
@@ -0,0 +1,184 @@
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterAll, expect, test } from 'vitest'
const targetRoot = process.env.STA_4274_TARGET_ROOT
const operation = process.env.STA_4274_OPERATION
const ownedDirs: string[] = []
function makeOwnedDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix))
ownedDirs.push(dir)
return dir
}
afterAll(() => {
for (const dir of ownedDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
const ordinary = {
id: 'ordinary-runtime',
recipeId: 'ordinary-recipe',
recipe: {
id: 'ordinary-recipe',
name: 'Ordinary VM',
create: './ordinary-create.sh',
destroy: './ordinary-destroy.sh'
},
status: 'running' as const,
cleanupStatus: 'not_started' as const,
createdAt: 1_000,
updatedAt: 1_000,
recipeResult: {
schemaVersion: 1 as const,
connection: {
type: 'ssh' as const,
projectRoot: '/workspace/ordinary',
target: {
label: 'Ordinary VM',
host: 'ordinary.example.com',
port: 22,
username: 'developer'
}
},
userData: { resourceId: 'ordinary-resource' }
}
}
const provisionedRoot = {
id: 'provisioned-root-runtime',
recipeId: 'provisioned-root-recipe',
recipe: {
id: 'provisioned-root-recipe',
name: 'Provisioned Root VM',
create: './provisioned-root-create.sh',
destroy: './provisioned-root-destroy.sh',
checkoutMode: 'provisioned-root' as const
},
status: 'running' as const,
cleanupStatus: 'not_started' as const,
createdAt: 2_000,
updatedAt: 2_000,
recipeResult: {
schemaVersion: 2 as const,
checkoutMode: 'provisioned-root' as const,
connection: {
type: 'ssh' as const,
projectRoot: '/workspace/provisioned',
target: {
label: 'Provisioned Root VM',
host: 'provisioned.example.com',
port: 22,
username: 'developer'
}
},
userData: { resourceId: 'provisioned-resource' }
}
}
test.skipIf(!targetRoot || !operation)(`STA-4274 ${operation ?? 'disabled'}`, async () => {
if (!targetRoot || !operation) {
throw new Error('STA_4274_TARGET_ROOT and STA_4274_OPERATION are required')
}
const userDataPath = process.env.STA_4274_USER_DATA_PATH ?? makeOwnedDir('sta-4274-')
const moduleUrl = pathToFileURL(
resolve(targetRoot, 'src/shared/ephemeral-vm-runtime-store.ts')
).href
const store = await import(/* @vite-ignore */ moduleUrl)
if (operation === 'write-legacy') {
store.upsertEphemeralVmRuntime(userDataPath, ordinary)
return
}
if (operation === 'write-mixed') {
store.upsertEphemeralVmRuntime(userDataPath, ordinary)
store.upsertEphemeralVmRuntime(userDataPath, provisionedRoot)
return
}
if (operation === 'read') {
const runtimes = store.listEphemeralVmRuntimes(userDataPath)
expect(runtimes.map((record: { id: string }) => record.id)).toEqual([
'provisioned-root-runtime',
'ordinary-runtime'
])
return
}
if (operation === 'read-rollback-projection') {
const runtimes = store.listEphemeralVmRuntimes(userDataPath)
expect(runtimes).toHaveLength(2)
expect(runtimes[0].recipe).not.toHaveProperty('checkoutMode')
expect(runtimes[0].recipeResult).toMatchObject({ schemaVersion: 1 })
return
}
if (operation === 'mutate-lifecycle') {
store.updateEphemeralVmRuntimeStatus(userDataPath, 'ordinary-runtime', {
status: 'suspended',
updatedAt: 3_000
})
const repoPath = makeOwnedDir('sta-4274-cleanup-')
const cleanupPath = join(repoPath, 'cleanup.js')
const proofPath = join(repoPath, 'cleanup-proof')
writeFileSync(
cleanupPath,
[
"let input = ''",
"process.stdin.on('data', (chunk) => { input += chunk })",
"process.stdin.on('end', () => {",
' const payload = JSON.parse(input)',
' if (payload.recipeResult.schemaVersion !== 1) process.exit(12)',
" if (payload.recipeResult.userData.resourceId !== 'provisioned-resource') process.exit(13)",
` require('fs').writeFileSync(${JSON.stringify(proofPath)}, 'destroyed')`,
'})'
].join('\n')
)
const serviceUrl = pathToFileURL(
resolve(targetRoot, 'src/main/ephemeral-vm-runtime-service.ts')
).href
const service = await import(/* @vite-ignore */ serviceUrl)
const result = await service.cleanupEphemeralVmRuntime({
userDataPath,
repoPath,
runtimeId: 'provisioned-root-runtime',
recipe: {
id: 'provisioned-root-recipe',
name: 'Provisioned Root VM',
create: './provisioned-root-create.sh',
destroy: `${JSON.stringify(process.execPath)} ${JSON.stringify(cleanupPath)}`
},
now: 3_000
})
expect(result).toMatchObject({
ok: true,
runtime: { status: 'cleaned', cleanupStatus: 'succeeded' }
})
expect(existsSync(proofPath)).toBe(true)
return
}
if (operation === 'read-after-downgrade') {
expect(store.listEphemeralVmRuntimes(userDataPath)).toEqual([
expect.objectContaining({
id: 'provisioned-root-runtime',
status: 'cleaned',
cleanupStatus: 'succeeded',
recipe: expect.objectContaining({ checkoutMode: 'provisioned-root' }),
recipeResult: expect.objectContaining({
schemaVersion: 2,
checkoutMode: 'provisioned-root'
})
}),
expect.objectContaining({ id: 'ordinary-runtime', status: 'suspended' })
])
return
}
if (operation === 'read-legacy') {
expect(
store.listEphemeralVmRuntimes(userDataPath).map((record: { id: string }) => record.id)
).toEqual(['ordinary-runtime'])
return
}
throw new Error(`Unknown operation: ${operation}`)
})
@@ -14,6 +14,9 @@ const sshDockerRunner = readFileSync(
const filterStep = prWorkflow.jobs['e2e-paths'].steps.find(
(step) => step.name === 'Filter changed E2E specs'
)
const rollbackStep = prWorkflow.jobs.static_analysis.steps.find(
(step) => step.name === 'Check VM runtime rollback compatibility'
)
const verifyStep = prWorkflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
@@ -121,4 +124,11 @@ describe('PR E2E gate contract', () => {
expect(filterStep.run).toContain('--merge-base "$BASE" "$HEAD"')
expect(filterStep.run).toContain('set -euo pipefail')
})
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')
expect(rollbackStep.run).toContain('src/shared/orca-yaml-hook-types.ts')
expect(filterStep.run).toContain('ephemeral-vm-recipes|orca-yaml-hook-types')
})
})
@@ -0,0 +1,118 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
import process from 'node:process'
const BASELINE_COMMIT = 'bf0c77d5bc800e19117084c27fd1441eda9134ad'
const AFFECTED_MAIN_COMMIT = '25abb9368d98ad84a174f530e02f4228d2269062'
const root = process.cwd()
const driver = process.argv[2] ?? 'config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts'
const config = 'config/vitest.config.ts'
const tempRoot = mkdtempSync(join(tmpdir(), 'orca-sta-4274-repro-'))
try {
const baselineRoot = extractSource(BASELINE_COMMIT, 'baseline')
const affectedRoot = extractSource(AFFECTED_MAIN_COMMIT, 'affected-main')
const revertedRoot = extractSource('HEAD', 'candidate-reverted')
restoreAffectedStoreFiles(revertedRoot)
const baselineData = makeDataDir('baseline-data')
runOracle('baseline write', baselineRoot, 'write-legacy', baselineData)
runOracle('baseline read', baselineRoot, 'read-legacy', baselineData)
const affectedData = makeDataDir('affected-data')
runOracle('affected write', affectedRoot, 'write-mixed', affectedData)
runOracle('affected current read', affectedRoot, 'read', affectedData)
runOracle('affected rollback read', baselineRoot, 'read', affectedData, {
expectFailure: 'file is invalid'
})
const candidateData = makeDataDir('candidate-data')
runOracle('candidate write', root, 'write-mixed', candidateData)
runOracle(
'candidate rollback projection',
baselineRoot,
'read-rollback-projection',
candidateData
)
runOracle('rollback lifecycle mutation', baselineRoot, 'mutate-lifecycle', candidateData)
runOracle('candidate re-upgrade', root, 'read-after-downgrade', candidateData)
const revertedData = makeDataDir('reverted-data')
runOracle('reverted write', revertedRoot, 'write-mixed', revertedData)
runOracle('reverted rollback read', baselineRoot, 'read', revertedData, {
expectFailure: 'file is invalid'
})
} finally {
rmSync(tempRoot, { recursive: true, force: true })
}
function extractSource(commit, name) {
const destination = join(tempRoot, name)
const archive = join(tempRoot, `${name}.tar`)
mkdirSync(destination)
run('git', ['archive', '--format=tar', `--output=${archive}`, commit, 'src/main', 'src/shared'])
run('tar', ['-xf', archive, '-C', destination])
return destination
}
function restoreAffectedStoreFiles(destination) {
for (const relativePath of [
'src/shared/ephemeral-vm-runtime-store.ts',
'src/shared/ephemeral-vm-runtimes.ts'
]) {
const result = run('git', ['show', `${AFFECTED_MAIN_COMMIT}:${relativePath}`], {
encoding: 'utf8'
})
writeFileSync(join(destination, relativePath), result.stdout)
}
}
function makeDataDir(name) {
const destination = join(tempRoot, name)
mkdirSync(destination)
return destination
}
function runOracle(label, targetRoot, operation, userDataPath, options = {}) {
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const result = spawnSync(pnpm, ['exec', 'vitest', 'run', driver, '--config', config], {
cwd: root,
encoding: 'utf8',
env: {
...process.env,
STA_4274_TARGET_ROOT: resolve(targetRoot),
STA_4274_OPERATION: operation,
STA_4274_USER_DATA_PATH: userDataPath
}
})
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`
if (options.expectFailure) {
if (result.status === 0 || !output.includes(options.expectFailure)) {
throw new Error(
`${label} did not fail with ${JSON.stringify(options.expectFailure)}\n${output}`
)
}
process.stdout.write(`EXPECTED_FAIL ${label}\n`)
return
}
if (result.status !== 0) {
throw new Error(`${label} failed\n${output}`)
}
process.stdout.write(`PASS ${label}\n`)
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: root,
encoding: options.encoding,
maxBuffer: 16 * 1024 * 1024
})
if (result.status !== 0) {
throw new Error(
`${command} ${args.join(' ')} failed\n${String(result.stdout ?? '')}${String(result.stderr ?? '')}`
)
}
return result
}
+15 -3
View File
@@ -470,8 +470,19 @@ SSH mode is **fundamentally different from §7c/§7f**, not a relabeling of them
`label`, `host`, `port`, `username` are required; the rest are optional — omit any you don't need.
For an explicitly requested one-VM-per-workspace checkout, the create script must read
`ORCA_RECIPE_RESULT_SCHEMA_VERSION`, `ORCA_REPO_URL`, `ORCA_REPO_REF`, and `ORCA_REPO_BRANCH`, create
that exact primary checkout at `projectRoot`, and emit the same SSH result with:
`ORCA_RECIPE_RESULT_SCHEMA_VERSION`, `ORCA_REPO_URL`, `ORCA_REPO_REF`, `ORCA_REPO_REF_HEAD`, and
`ORCA_REPO_BRANCH`. Use `ORCA_REPO_REF` to fetch the selected source, but create
`ORCA_REPO_BRANCH` at the exact `ORCA_REPO_REF_HEAD` commit; resolving the symbolic ref again can race
with an upstream update. `ORCA_REPO_URL` and `ORCA_REPO_REF` are a matched fetch pair, including when
the desktop source uses multiple remotes. Return that primary checkout at `projectRoot` and emit the
same SSH result with:
```bash
[ -n "${ORCA_REPO_REF_HEAD:-}" ] || { echo "missing pinned source commit" >&2; exit 1; }
git fetch origin "$ORCA_REPO_REF"
git cat-file -e "${ORCA_REPO_REF_HEAD}^{commit}"
git checkout -B "$ORCA_REPO_BRANCH" "$ORCA_REPO_REF_HEAD"
```
```json
{
@@ -645,7 +656,8 @@ worked script in §7g). `pairingCode` is **not** used in SSH mode.
**Optional provisioned root** — only for direct SSH and only when explicitly requested. Add
`checkoutMode: provisioned-root` to the recipe, require `ORCA_RECIPE_RESULT_SCHEMA_VERSION=2`, create
the requested branch from `ORCA_REPO_REF` at the returned `projectRoot`, and emit schema version 2 plus
the requested `ORCA_REPO_BRANCH` at the pinned `ORCA_REPO_REF_HEAD` commit (use `ORCA_REPO_REF` only
to fetch that commit) at the returned `projectRoot`, and emit schema version 2 with
`checkoutMode: "provisioned-root"`. All recipes without this field retain the schema-v1 behavior above.
Lifecycle hooks (all run locally):
File diff suppressed because one or more lines are too long
+19 -5
View File
@@ -2,7 +2,11 @@ import {
getEphemeralVmRecipeResultConnection,
type EphemeralVmRecipeResult
} from '../shared/ephemeral-vm-recipes'
import { upsertEphemeralVmRuntime } from '../shared/ephemeral-vm-runtime-store'
import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes'
import {
upsertEphemeralVmRuntime,
upsertEphemeralVmRuntimeRollbackRecovery
} from '../shared/ephemeral-vm-runtime-store'
import type { ProvisionEphemeralVmRuntimeArgs } from './ephemeral-vm-runtime-service'
import {
runEphemeralVmRecipeCleanup,
@@ -17,15 +21,15 @@ type FailedStart = {
export async function cleanupFailedEphemeralVmStart(
args: ProvisionEphemeralVmRuntimeArgs,
start: FailedStart
): Promise<void> {
): Promise<boolean> {
const cleanupError = await getCleanupError(args, start)
if (cleanupError === null) {
return
return true
}
const now = args.now ?? Date.now()
const connection = getEphemeralVmRecipeResultConnection(start.recipeResult)
upsertEphemeralVmRuntime(args.userDataPath, {
const recovery: EphemeralVmRuntimeRecord = {
id: start.context.instanceId ?? start.context.recipeId,
recipeId: args.recipe.id,
recipe: args.recipe,
@@ -42,7 +46,17 @@ export async function cleanupFailedEphemeralVmStart(
createdAt: now,
updatedAt: now,
recipeResult: start.recipeResult
})
}
try {
upsertEphemeralVmRuntime(args.userDataPath, recovery)
} catch (error) {
if (!args.recipe.checkoutMode) {
throw error
}
// Why: cleanup retry metadata must survive even when its feature companion is unreadable.
upsertEphemeralVmRuntimeRollbackRecovery(args.userDataPath, recovery)
}
return false
}
async function getCleanupError(
@@ -0,0 +1,84 @@
import type { Store } from './persistence'
import type { Repo } from '../shared/repo-types'
import { gitExecFileAsync } from './git/runner'
import { resolveDefaultBaseRefWithLocalGit } from './git/repo'
import { getLocalProjectGitExecOptions } from './project-runtime-git-options'
import { resolveWorktreeCreateBase } from './worktree-create-base'
import { resolveWorktreeAddBaseRef } from '../shared/worktree/base-ref'
const PROVISIONED_ROOT_SOURCE_REF_TIMEOUT_MS = 15_000
export async function resolveProvisionedRootSource(
store: Store,
repo: Repo,
requestedRef?: string,
signal?: AbortSignal
): Promise<{ ref: string; head: string; remoteUrl?: string } | null> {
const gitOptions = {
...getLocalProjectGitExecOptions(store, repo),
...(signal ? { signal } : {})
}
const resolveHead = async (ref: string): Promise<string | null> => {
try {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`],
{ ...gitOptions, timeout: PROVISIONED_ROOT_SOURCE_REF_TIMEOUT_MS }
)
return stdout.trim() || null
} catch {
return null
}
}
const ref = await resolveWorktreeCreateBase({
requestedBaseBranch: requestedRef?.trim() || undefined,
repoWorktreeBaseRef: repo.worktreeBaseRef,
resolveDefaultBaseRef: () => resolveDefaultBaseRefWithLocalGit(gitOptions),
isBaseUsable: async (candidate) => Boolean(await resolveHead(candidate))
})
if (!ref) {
return null
}
const head = await resolveHead(ref)
if (!head) {
return null
}
const remoteSource = await resolveRemoteSource(ref, resolveHead, gitOptions)
return remoteSource ? { ...remoteSource, head } : { ref, head }
}
async function resolveRemoteSource(
ref: string,
resolveHead: (ref: string) => Promise<string | null>,
gitOptions: { cwd: string; wslDistro?: string; signal?: AbortSignal }
): Promise<{ ref: string; remoteUrl: string } | null> {
const qualified = await resolveWorktreeAddBaseRef(ref, async (candidate) =>
Boolean(await resolveHead(candidate))
)
const prefix = 'refs/remotes/'
if (!qualified.startsWith(prefix)) {
return null
}
try {
const { stdout } = await gitExecFileAsync(['remote'], {
...gitOptions,
timeout: PROVISIONED_ROOT_SOURCE_REF_TIMEOUT_MS
})
const shortRef = qualified.slice(prefix.length)
const remote = stdout
.split(/\r?\n/)
.map((entry) => entry.trim())
.filter((entry) => shortRef.startsWith(`${entry}/`))
.sort((left, right) => right.length - left.length)[0]
const branch = remote ? shortRef.slice(remote.length + 1) : ''
if (!remote || !branch) {
return null
}
const { stdout: remoteUrl } = await gitExecFileAsync(['remote', 'get-url', remote], {
...gitOptions,
timeout: PROVISIONED_ROOT_SOURCE_REF_TIMEOUT_MS
})
return remoteUrl.trim() ? { ref: `refs/heads/${branch}`, remoteUrl: remoteUrl.trim() } : null
} catch {
return null
}
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import type {
EphemeralVmRecipeConnection,
EphemeralVmRecipeResult
} from '../shared/ephemeral-vm-recipes'
import { getProvisionedRootResumeIntegrityError } from './ephemeral-vm-resume-integrity'
type RecipeSshTarget = Extract<EphemeralVmRecipeConnection, { type: 'ssh' }>['target']
describe('getProvisionedRootResumeIntegrityError', () => {
it('allows an SSH endpoint to rotate while preserving its ownership identity', () => {
const previous = provisionedSshResult()
const resumed = provisionedSshResult({ host: 'new-host', port: 2222, label: 'Resumed VM' })
expect(getProvisionedRootResumeIntegrityError(previous, resumed)).toBeNull()
})
it.each([
['config host', { configHost: 'other-alias' }],
['username', { username: 'other-user' }],
['identity file', { identityFile: '/keys/other' }],
['identity agent', { identityAgent: '/agents/other' }],
['identities-only setting', { identitiesOnly: true }],
['proxy command', { proxyCommand: 'ssh proxy' }],
['jump host', { jumpHost: 'bastion' }]
])('rejects changed SSH %s ownership', (_label, target) => {
expect(
getProvisionedRootResumeIntegrityError(provisionedSshResult(), provisionedSshResult(target))
).toContain('SSH ownership changed')
})
it('rejects a resume that changes the connection type', () => {
const resumed: EphemeralVmRecipeResult = {
schemaVersion: 2,
checkoutMode: 'provisioned-root',
connection: {
type: 'orca-server',
pairingCode: 'pairing-code',
projectRoot: '/workspace/orca'
}
}
expect(getProvisionedRootResumeIntegrityError(provisionedSshResult(), resumed)).toContain(
'connection type changed'
)
})
})
function provisionedSshResult(target: Partial<RecipeSshTarget> = {}): EphemeralVmRecipeResult {
return {
schemaVersion: 2,
checkoutMode: 'provisioned-root',
connection: {
type: 'ssh',
projectRoot: '/workspace/orca',
target: {
label: 'VM',
host: 'host',
port: 22,
username: 'orca',
identityFile: '/keys/orca',
identityAgent: '/agents/orca',
...target
}
}
}
}
+44 -5
View File
@@ -1,19 +1,58 @@
import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path'
import {
getEphemeralVmRecipeResultCheckoutMode,
getEphemeralVmRecipeResultConnection,
getEphemeralVmRecipeResultProjectRoot,
type EphemeralVmRecipeResult
} from '../shared/ephemeral-vm-recipes'
export function provisionedRootChangedDuringResume(
export function getProvisionedRootResumeIntegrityError(
previous: EphemeralVmRecipeResult,
resumed: EphemeralVmRecipeResult
): boolean {
): string | null {
if (getEphemeralVmRecipeResultCheckoutMode(previous) !== 'provisioned-root') {
return false
return null
}
return (
if (
normalizeRuntimePathForComparison(getEphemeralVmRecipeResultProjectRoot(previous)) !==
normalizeRuntimePathForComparison(getEphemeralVmRecipeResultProjectRoot(resumed))
)
) {
return 'The provisioned workspace root changed while the runtime was suspended.'
}
const previousConnection = getEphemeralVmRecipeResultConnection(previous)
const resumedConnection = getEphemeralVmRecipeResultConnection(resumed)
if (previousConnection.type !== resumedConnection.type) {
return 'The provisioned workspace connection type changed while the runtime was suspended.'
}
if (
previousConnection.type === 'ssh' &&
resumedConnection.type === 'ssh' &&
sshOwnershipChanged(previousConnection.target, resumedConnection.target)
) {
return 'The provisioned workspace SSH ownership changed while the runtime was suspended.'
}
return null
}
const SSH_OWNERSHIP_FIELDS = [
'configHost',
'username',
'identityFile',
'identityAgent',
'identitiesOnly',
'proxyCommand',
'jumpHost'
] as const
function sshOwnershipChanged(
previous: Extract<
ReturnType<typeof getEphemeralVmRecipeResultConnection>,
{ type: 'ssh' }
>['target'],
resumed: Extract<
ReturnType<typeof getEphemeralVmRecipeResultConnection>,
{ type: 'ssh' }
>['target']
): boolean {
return SSH_OWNERSHIP_FIELDS.some((field) => previous[field] !== resumed[field])
}
@@ -0,0 +1,56 @@
import type { CleanupEphemeralVmRuntimeResult } from './ephemeral-vm-runtime-service'
type CleanupInFlight = {
controller: AbortController
promise: Promise<CleanupEphemeralVmRuntimeResult>
}
const cleanupInFlight = new Map<string, CleanupInFlight>()
export function runControlledEphemeralVmRuntimeCleanup(args: {
userDataPath: string
runtimeId: string
signal?: AbortSignal
run: (signal: AbortSignal) => Promise<CleanupEphemeralVmRuntimeResult>
}): Promise<CleanupEphemeralVmRuntimeResult> {
const key = cleanupKey(args)
const existing = cleanupInFlight.get(key)
if (existing) {
return existing.promise
}
const controller = new AbortController()
const forwardAbort = (): void => controller.abort()
if (args.signal?.aborted) {
forwardAbort()
} else {
args.signal?.addEventListener('abort', forwardAbort, { once: true })
}
const promise = args.run(controller.signal)
const inFlight = { controller, promise }
cleanupInFlight.set(key, inFlight)
const forget = (): void => {
args.signal?.removeEventListener('abort', forwardAbort)
if (cleanupInFlight.get(key) === inFlight) {
cleanupInFlight.delete(key)
}
}
void promise.then(forget, forget)
return promise
}
export function stopEphemeralVmRuntimeCleanup(args: {
userDataPath: string
runtimeId: string
}): Promise<CleanupEphemeralVmRuntimeResult> | null {
const cleanup = cleanupInFlight.get(cleanupKey(args))
if (!cleanup) {
return null
}
cleanup.controller.abort()
return cleanup.promise
}
function cleanupKey(args: { userDataPath: string; runtimeId: string }): string {
return `${args.userDataPath}\0${args.runtimeId}`
}
@@ -0,0 +1,82 @@
import { randomUUID } from 'node:crypto'
import { assertEphemeralVmRuntimeCheckoutModeCanPersist } from '../shared/ephemeral-vm-runtime-feature-store'
import { getEphemeralVmRecipeResultConnection } from '../shared/ephemeral-vm-recipes'
import {
listEphemeralVmRuntimes,
removeEphemeralVmRuntime,
upsertEphemeralVmRuntime
} from '../shared/ephemeral-vm-runtime-store'
import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes'
import type {
ProvisionEphemeralVmRuntimeArgs,
ProvisionEphemeralVmRuntimeResult
} from './ephemeral-vm-runtime-service'
import { cleanupFailedEphemeralVmStart } from './ephemeral-vm-failed-start-cleanup'
export type EphemeralVmCompatibilityPersistence = {
instanceId: string
createdAt: number
}
export function prepareEphemeralVmCompatibilityPersistence(
args: ProvisionEphemeralVmRuntimeArgs
): EphemeralVmCompatibilityPersistence | null {
if (!args.recipe.checkoutMode) {
return null
}
listEphemeralVmRuntimes(args.userDataPath)
const compatibility = {
instanceId: `orca-${randomUUID()}`,
createdAt: args.now ?? Date.now()
}
assertEphemeralVmRuntimeCheckoutModeCanPersist(args.userDataPath, {
id: compatibility.instanceId,
recipeId: args.recipe.id,
createdAt: compatibility.createdAt,
checkoutMode: args.recipe.checkoutMode
})
return compatibility
}
export async function persistProvisionedEphemeralVmRuntime(
args: ProvisionEphemeralVmRuntimeArgs,
start: Extract<ProvisionEphemeralVmRuntimeResult, { ok: true }>['start'],
compatibility: EphemeralVmCompatibilityPersistence | null
): Promise<EphemeralVmRuntimeRecord> {
const now = compatibility?.createdAt ?? args.now ?? Date.now()
const connection = getEphemeralVmRecipeResultConnection(start.result)
try {
return upsertEphemeralVmRuntime(args.userDataPath, {
id: start.context.instanceId ?? start.context.recipeId,
recipeId: args.recipe.id,
recipe: args.recipe,
...(args.repoId ? { repoId: args.repoId } : {}),
...(args.projectId ? { projectId: args.projectId } : {}),
...(args.workspaceId ? { workspaceId: args.workspaceId } : {}),
...(args.workspaceName ? { workspaceName: args.workspaceName } : {}),
status: 'running',
connectionMode: connection.type,
cleanupStatus: args.recipe.destroyDisabled ? 'disabled' : 'not_started',
...(args.recipe.destroyDisabled ? { cleanupDisabled: true } : {}),
createdAt: now,
updatedAt: now,
recipeResult: start.result
})
} catch (error) {
if (compatibility) {
const cleaned = await cleanupFailedEphemeralVmStart(args, {
context: start.context,
recipeResult: start.result
})
if (
cleaned &&
listEphemeralVmRuntimes(args.userDataPath).some(
(runtime) => runtime.id === compatibility.instanceId
)
) {
removeEphemeralVmRuntime(args.userDataPath, compatibility.instanceId)
}
}
throw error
}
}
+276 -3
View File
@@ -1,16 +1,22 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { existsSync, mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../shared/pairing'
import {
getEphemeralVmRuntimeStorePath,
listEphemeralVmRuntimes,
upsertEphemeralVmRuntime
} from '../shared/ephemeral-vm-runtime-store'
import {
getEphemeralVmRuntimeFeatureStorePath,
MAX_EPHEMERAL_VM_RUNTIME_FEATURE_STORE_FILE_BYTES
} from '../shared/ephemeral-vm-runtime-feature-store'
import {
cleanupEphemeralVmRuntime,
provisionEphemeralVmRuntime,
resumeEphemeralVmRuntime
resumeEphemeralVmRuntime,
stopEphemeralVmRuntimeCleanup
} from './ephemeral-vm-runtime-service'
import type { OrcaVmRecipe } from '../shared/orca-yaml-hook-types'
@@ -154,6 +160,63 @@ describe('ephemeral VM runtime service', () => {
expect(readFileSync(join(repoPath, 'cleanup-count.txt'), 'utf8')).toBe('x')
})
it('stops a hung destroy and starts a fresh retry', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
const cleanupPath = join(repoPath, 'cleanup.js')
const countPath = join(repoPath, 'cleanup-count.txt')
writeFileSync(
cleanupPath,
`require('fs').appendFileSync(${JSON.stringify(countPath)}, 'x'); setInterval(() => {}, 1000)`
)
const recipe: OrcaVmRecipe = {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
create: 'unused',
destroy: nodeCommand(cleanupPath)
}
upsertEphemeralVmRuntime(userDataPath, {
id: 'runtime-1',
recipeId: recipe.id,
recipe,
status: 'running',
cleanupStatus: 'not_started',
createdAt: 1_000,
updatedAt: 1_000,
recipeResult: {
schemaVersion: 1,
connection: {
type: 'ssh',
projectRoot: '/workspace/repo',
target: { label: 'VM', host: 'host', port: 22, username: 'orca' }
}
}
})
const cleanupArgs = {
userDataPath,
repoPath,
recipe,
runtimeId: 'runtime-1'
}
const cleanup = cleanupEphemeralVmRuntime(cleanupArgs)
await vi.waitFor(() => expect(readFileSync(countPath, 'utf8')).toBe('x'))
const stopping = stopEphemeralVmRuntimeCleanup({ userDataPath, runtimeId: 'runtime-1' })
expect(stopping).not.toBeNull()
await expect(stopping).resolves.toMatchObject({
ok: false,
runtime: { status: 'cleanup_failed', cleanupStatus: 'failed' },
error: 'Cleanup stopped by user.'
})
await expect(cleanup).resolves.toMatchObject({ ok: false })
writeFileSync(cleanupPath, `require('fs').appendFileSync(${JSON.stringify(countPath)}, 'x')`)
await expect(cleanupEphemeralVmRuntime(cleanupArgs)).resolves.toMatchObject({
ok: true,
runtime: { status: 'cleaned', cleanupStatus: 'succeeded' }
})
expect(readFileSync(countPath, 'utf8')).toBe('xx')
})
it('does not persist a runtime when recipe output cannot be parsed', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
@@ -179,6 +242,151 @@ describe('ephemeral VM runtime service', () => {
expect(listEphemeralVmRuntimes(userDataPath)).toEqual([])
})
it('rejects an unwritable feature store before a checkout-mode recipe creates resources', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
const startPath = join(repoPath, 'start.js')
const markerPath = join(repoPath, 'create-ran.txt')
writeFileSync(startPath, `require('fs').writeFileSync(${JSON.stringify(markerPath)}, 'yes')`)
const featurePath = getEphemeralVmRuntimeFeatureStorePath(userDataPath)
writeFileSync(featurePath, '{}')
truncateSync(featurePath, MAX_EPHEMERAL_VM_RUNTIME_FEATURE_STORE_FILE_BYTES + 1)
await expect(
provisionEphemeralVmRuntime({
userDataPath,
repoPath,
recipe: {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
checkoutMode: 'provisioned-root',
create: nodeCommand(startPath),
destroyDisabled: true
}
})
).rejects.toThrow('Could not preserve ephemeral VM runtime compatibility metadata')
expect(existsSync(markerPath)).toBe(false)
})
it('rejects an unreadable lifecycle store before a checkout-mode recipe creates resources', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
const startPath = join(repoPath, 'start.js')
const markerPath = join(repoPath, 'create-ran.txt')
writeFileSync(startPath, `require('fs').writeFileSync(${JSON.stringify(markerPath)}, 'yes')`)
writeFileSync(getEphemeralVmRuntimeStorePath(userDataPath), '{')
await expect(
provisionEphemeralVmRuntime({
userDataPath,
repoPath,
recipe: {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
checkoutMode: 'provisioned-root',
create: nodeCommand(startPath),
destroyDisabled: true
}
})
).rejects.toThrow('file is invalid')
expect(existsSync(markerPath)).toBe(false)
})
it('destroys a checkout-mode resource when compatibility persistence fails after create', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
const startPath = join(repoPath, 'start.js')
const cleanupPath = join(repoPath, 'cleanup.js')
const cleanupMarkerPath = join(repoPath, 'cleanup-ran.txt')
const featurePath = getEphemeralVmRuntimeFeatureStorePath(userDataPath)
writeFileSync(
startPath,
[
`require('fs').writeFileSync(${JSON.stringify(featurePath)}, '{')`,
'console.log(JSON.stringify({',
' schemaVersion: 2,',
' checkoutMode: "provisioned-root",',
' connection: {',
' type: "ssh",',
' projectRoot: "/workspace/repo",',
' target: { label: "VM", host: "host", port: 22, username: "orca" }',
' }',
'}))'
].join('\n')
)
writeFileSync(
cleanupPath,
`require('fs').writeFileSync(${JSON.stringify(cleanupMarkerPath)}, 'yes')`
)
await expect(
provisionEphemeralVmRuntime({
userDataPath,
repoPath,
recipe: {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
checkoutMode: 'provisioned-root',
create: nodeCommand(startPath),
destroy: nodeCommand(cleanupPath)
}
})
).rejects.toThrow('Could not preserve ephemeral VM runtime compatibility metadata')
expect(readFileSync(cleanupMarkerPath, 'utf8')).toBe('yes')
expect(listEphemeralVmRuntimes(userDataPath)).toEqual([])
})
it('keeps rollback-readable cleanup recovery when post-create destroy also fails', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
const startPath = join(repoPath, 'start.js')
const cleanupPath = join(repoPath, 'cleanup.js')
const featurePath = getEphemeralVmRuntimeFeatureStorePath(userDataPath)
writeFileSync(
startPath,
[
`require('fs').writeFileSync(${JSON.stringify(featurePath)}, '{')`,
'console.log(JSON.stringify({',
' schemaVersion: 2,',
' checkoutMode: "provisioned-root",',
' connection: {',
' type: "ssh",',
' projectRoot: "/workspace/repo",',
' target: { label: "VM", host: "host", port: 22, username: "orca" }',
' },',
' userData: { providerResourceId: "paid-vm" }',
'}))'
].join('\n')
)
writeFileSync(cleanupPath, 'process.exit(1)')
await expect(
provisionEphemeralVmRuntime({
userDataPath,
repoPath,
recipe: {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
checkoutMode: 'provisioned-root',
create: nodeCommand(startPath),
destroy: nodeCommand(cleanupPath)
},
now: 1_000
})
).rejects.toThrow('Could not preserve ephemeral VM runtime compatibility metadata')
expect(listEphemeralVmRuntimes(userDataPath)).toEqual([
expect.objectContaining({
status: 'cleanup_failed',
cleanupStatus: 'failed',
recipe: expect.not.objectContaining({ checkoutMode: expect.anything() }),
recipeResult: expect.objectContaining({
schemaVersion: 1,
userData: { providerResourceId: 'paid-vm' }
})
})
])
})
it('destroys a provisioned resource when its checkout handshake is incompatible', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
@@ -366,4 +574,69 @@ describe('ephemeral VM runtime service', () => {
}
})
})
it('rejects provisioned-root connection-mode drift during resume', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
const resumePath = join(repoPath, 'resume.js')
writeFileSync(
resumePath,
`console.log(${JSON.stringify(
JSON.stringify({
schemaVersion: 2,
checkoutMode: 'provisioned-root',
connection: {
type: 'orca-server',
pairingCode: makePairingCode(),
projectRoot: '/workspace/original'
}
})
)})`
)
const recipe: OrcaVmRecipe = {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
checkoutMode: 'provisioned-root',
create: 'unused',
resume: nodeCommand(resumePath),
destroyDisabled: true
}
upsertEphemeralVmRuntime(userDataPath, {
id: 'runtime-1',
recipeId: recipe.id,
recipe,
status: 'suspended',
connectionMode: 'ssh',
cleanupStatus: 'disabled',
cleanupDisabled: true,
createdAt: 1,
updatedAt: 1,
recipeResult: {
schemaVersion: 2,
checkoutMode: 'provisioned-root',
connection: {
type: 'ssh',
projectRoot: '/workspace/original',
target: { label: 'VM', host: 'host', port: 22, username: 'orca' }
}
}
})
const resumed = await resumeEphemeralVmRuntime({
userDataPath,
repoPath,
recipe,
runtimeId: 'runtime-1'
})
expect(resumed).toMatchObject({
ok: false,
error: 'The provisioned workspace connection type changed while the runtime was suspended.',
runtime: {
status: 'resume_failed',
connectionMode: 'ssh',
recipeResult: { connection: { type: 'ssh' } }
}
})
})
})
+26 -43
View File
@@ -1,11 +1,9 @@
import type { OrcaVmRecipe } from '../shared/orca-yaml-hook-types'
import {
listEphemeralVmRuntimes,
updateEphemeralVmRuntimeStatus,
upsertEphemeralVmRuntime
updateEphemeralVmRuntimeStatus
} from '../shared/ephemeral-vm-runtime-store'
import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes'
import { getEphemeralVmRecipeResultConnection } from '../shared/ephemeral-vm-recipes'
import {
runEphemeralVmRecipeCleanup,
runEphemeralVmRecipeResume,
@@ -16,7 +14,14 @@ import {
type EphemeralVmRecipeStartSuccess
} from './ephemeral-vm-recipe-runner'
import { cleanupFailedEphemeralVmStart } from './ephemeral-vm-failed-start-cleanup'
import { provisionedRootChangedDuringResume } from './ephemeral-vm-resume-integrity'
import {
persistProvisionedEphemeralVmRuntime,
prepareEphemeralVmCompatibilityPersistence
} from './ephemeral-vm-runtime-provisioning-persistence'
import { getProvisionedRootResumeIntegrityError } from './ephemeral-vm-resume-integrity'
import { runControlledEphemeralVmRuntimeCleanup } from './ephemeral-vm-runtime-cleanup-control'
export { stopEphemeralVmRuntimeCleanup } from './ephemeral-vm-runtime-cleanup-control'
export type ProvisionEphemeralVmRuntimeArgs = {
userDataPath: string
@@ -29,6 +34,7 @@ export type ProvisionEphemeralVmRuntimeArgs = {
repoUrl?: string
branch?: string
ref?: string
expectedRefHead?: string
orcaVersion?: string
now?: number
signal?: AbortSignal
@@ -94,11 +100,10 @@ export type ResumeEphemeralVmRuntimeResult =
error: string
}
const cleanupInFlight = new Map<string, Promise<CleanupEphemeralVmRuntimeResult>>()
export async function provisionEphemeralVmRuntime(
args: ProvisionEphemeralVmRuntimeArgs
): Promise<ProvisionEphemeralVmRuntimeResult> {
const compatibility = prepareEphemeralVmCompatibilityPersistence(args)
const start = await runEphemeralVmRecipeStart({
repoPath: args.repoPath,
recipe: args.recipe,
@@ -109,7 +114,9 @@ export async function provisionEphemeralVmRuntime(
repoUrl: args.repoUrl,
branch: args.branch,
ref: args.ref,
orcaVersion: args.orcaVersion
expectedRefHead: args.expectedRefHead,
orcaVersion: args.orcaVersion,
...(compatibility ? { instanceId: compatibility.instanceId } : {})
},
signal: args.signal,
onStdout: args.onStdout,
@@ -125,24 +132,7 @@ export async function provisionEphemeralVmRuntime(
return { ok: false, start }
}
const now = args.now ?? Date.now()
const connection = getEphemeralVmRecipeResultConnection(start.result)
const runtime = upsertEphemeralVmRuntime(args.userDataPath, {
id: start.context.instanceId ?? start.context.recipeId,
recipeId: args.recipe.id,
recipe: args.recipe,
...(args.repoId ? { repoId: args.repoId } : {}),
...(args.projectId ? { projectId: args.projectId } : {}),
...(args.workspaceId ? { workspaceId: args.workspaceId } : {}),
...(args.workspaceName ? { workspaceName: args.workspaceName } : {}),
status: 'running',
connectionMode: connection.type,
cleanupStatus: args.recipe.destroyDisabled ? 'disabled' : 'not_started',
...(args.recipe.destroyDisabled ? { cleanupDisabled: true } : {}),
createdAt: now,
updatedAt: now,
recipeResult: start.result
})
const runtime = await persistProvisionedEphemeralVmRuntime(args, start, compatibility)
return { ok: true, start, runtime }
}
@@ -150,21 +140,12 @@ export async function provisionEphemeralVmRuntime(
export function cleanupEphemeralVmRuntime(
args: CleanupEphemeralVmRuntimeArgs
): Promise<CleanupEphemeralVmRuntimeResult> {
const key = `${args.userDataPath}\0${args.runtimeId}`
const existing = cleanupInFlight.get(key)
if (existing) {
return existing
}
const cleanup = cleanupEphemeralVmRuntimeOnce(args)
cleanupInFlight.set(key, cleanup)
const forget = (): void => {
if (cleanupInFlight.get(key) === cleanup) {
cleanupInFlight.delete(key)
}
}
void cleanup.then(forget, forget)
return cleanup
return runControlledEphemeralVmRuntimeCleanup({
userDataPath: args.userDataPath,
runtimeId: args.runtimeId,
signal: args.signal,
run: (signal) => cleanupEphemeralVmRuntimeOnce({ ...args, signal })
})
}
async function cleanupEphemeralVmRuntimeOnce(
@@ -282,13 +263,15 @@ export async function resumeEphemeralVmRuntime(
return { ok: false, runtime: failed, error: resume.error }
}
if (!resume.skipped && provisionedRootChangedDuringResume(existing.recipeResult, resume.result)) {
const error = 'The provisioned workspace root changed while the runtime was suspended.'
const resumeIntegrityError = resume.skipped
? null
: getProvisionedRootResumeIntegrityError(existing.recipeResult, resume.result)
if (resumeIntegrityError) {
const failed = updateEphemeralVmRuntimeStatus(args.userDataPath, existing.id, {
status: 'resume_failed',
updatedAt: Date.now()
})
return { ok: false, runtime: failed, error }
return { ok: false, runtime: failed, error: resumeIntegrityError }
}
const runtime = updateEphemeralVmRuntimeStatus(args.userDataPath, existing.id, {
+120
View File
@@ -42,11 +42,20 @@ import {
parseWorktreeList,
removeWorktree,
WORKTREE_ADD_TIMEOUT_MS,
WORKTREE_ADD_TIMEOUT_MAX_MS,
resolveWorktreeAddTimeoutMs,
WORKTREE_LIST_TIMEOUT_MS
} from './worktree'
beforeEach(() => {
clearGitCapabilityStateForTests()
// Why: addWorktree reads the override at call time, so a developer's ambient value must not leak in.
// `undefined` deletes the key, matching production's unset case rather than an empty string.
vi.stubEnv('ORCA_WORKTREE_ADD_TIMEOUT_MS', undefined)
})
afterEach(() => {
vi.unstubAllEnvs()
})
describe('listWorktrees in-flight sharing', () => {
@@ -814,6 +823,20 @@ describe('addWorktree', () => {
expect(WORKTREE_ADD_TIMEOUT_MS).toBeGreaterThan(0)
})
it('raises the worktree add timeout from ORCA_WORKTREE_ADD_TIMEOUT_MS (#12696)', async () => {
vi.stubEnv('ORCA_WORKTREE_ADD_TIMEOUT_MS', '600000')
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
await addWorktree('/repo', '/repo-feature', 'feature/test', 'feature/test', false, false, {
checkoutExistingBranch: true
})
const worktreeAddCall = gitExecFileAsyncMock.mock.calls.find(
([argv]) => Array.isArray(argv) && argv[0] === 'worktree' && argv[1] === 'add'
)
expect(worktreeAddCall?.[1]).toMatchObject({ timeout: 600_000 })
})
it('does not write branch base config when no base branch is provided', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'true\n' }) // push.autoSetupRemote already set
@@ -1875,3 +1898,100 @@ describe('removeWorktree', () => {
])
})
})
describe('resolveWorktreeAddTimeoutMs', () => {
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
warnSpy.mockRestore()
})
// Why: pin the literals so a future edit to either bound has to be deliberate.
it('bounds the override to [180s, 30min]', () => {
expect(WORKTREE_ADD_TIMEOUT_MS).toBe(180_000)
expect(WORKTREE_ADD_TIMEOUT_MAX_MS).toBe(1_800_000)
})
it('falls back to the default when the override is unset, blank, or unparseable', () => {
expect(resolveWorktreeAddTimeoutMs({})).toBe(WORKTREE_ADD_TIMEOUT_MS)
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: ' ' })).toBe(
WORKTREE_ADD_TIMEOUT_MS
)
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: 'nope' })).toBe(
WORKTREE_ADD_TIMEOUT_MS
)
})
it('raises the timeout up to the closed maximum', () => {
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '300000' })).toBe(300_000)
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '300000.9' })).toBe(300_000)
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '999999999' })).toBe(
1_800_000
)
})
// Why: `=Infinity` is the natural way to say "stop killing my checkout"; collapsing it to the
// default would hand the operator back the exact failure they set the variable to escape.
it('treats an infinite override as the maximum, not the default', () => {
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: 'Infinity' })).toBe(
1_800_000
)
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '1e400' })).toBe(1_800_000)
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '-Infinity' })).toBe(
WORKTREE_ADD_TIMEOUT_MS
)
// Why: `Infinity` is a number that got clamped, so reporting "is not a number" while naming the
// number it used would misdirect exactly the operator this override exists for.
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('"Infinity" is outside [180000, 1800000]ms; using 1800000ms')
)
})
// Why: trimming and fractional truncation alter the value but are not operator error.
it('stays quiet when the value is unset, blank, or lands in range', () => {
resolveWorktreeAddTimeoutMs({})
resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: ' ' })
resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '600000' })
resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '300000.9' })
expect(warnSpy).not.toHaveBeenCalled()
})
// Why: the seconds/ms mixup is the whole reason the floor exists — it must not be silent.
it('warns with the range when it clamps an out-of-range value', () => {
resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '300' })
expect(warnSpy).toHaveBeenCalledTimes(1)
// Why: pin the prefix and variable name too — a warning nobody can grep for is not a diagnostic.
expect(warnSpy).toHaveBeenCalledWith(
'[git/worktree] ORCA_WORKTREE_ADD_TIMEOUT_MS="300" is outside [180000, 1800000]ms; using 180000ms'
)
})
// Why: `600_000` copied out of worktree.ts is NaN, not out of range — a range complaint would misdirect.
it('warns that an unparseable value is not a number rather than out of range', () => {
resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '600_000' })
expect(warnSpy).toHaveBeenCalledTimes(1)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('"600_000" is not a number; using 180000ms')
)
})
// Why: `=300` means seconds to most operators; clamping it up keeps every create working instead of failing in 300ms.
it('never lowers the timeout below the stall-guard default', () => {
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '300' })).toBe(
WORKTREE_ADD_TIMEOUT_MS
)
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '0' })).toBe(
WORKTREE_ADD_TIMEOUT_MS
)
expect(resolveWorktreeAddTimeoutMs({ ORCA_WORKTREE_ADD_TIMEOUT_MS: '-1' })).toBe(
WORKTREE_ADD_TIMEOUT_MS
)
})
})
+32 -3
View File
@@ -91,13 +91,42 @@ const SPARSE_CHECKOUT_DETECTION_CONCURRENCY = 8
const PRUNABLE_EXISTENCE_PROBE_CONCURRENCY = 8
// Why: bound `git worktree add` so a OneDrive cloud-placeholder stall fails fast (STA-1292); generous enough for a legit large checkout (#7225).
// Why: bound `git worktree add` so a OneDrive cloud-placeholder stall fails fast (STA-1292); ample for an ordinary large checkout, but not one behind a slow content filter (#12696).
// Doubles as the floor for ORCA_WORKTREE_ADD_TIMEOUT_MS — lowering it to fail faster also lowers the minimum any override can request.
export const WORKTREE_ADD_TIMEOUT_MS = 180_000
// Why: ceiling for ORCA_WORKTREE_ADD_TIMEOUT_MS (#12696) — ~8x the slowest reported checkout (3.5 min). The cost is that a genuine stall now blocks a create for up to 30 min instead of 3.
export const WORKTREE_ADD_TIMEOUT_MAX_MS = 30 * 60_000
export const WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS = 30_000
export const WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS = 30_000
// Why: one wedged shared scan otherwise hangs every later list, including create's post-add re-list.
export const WORKTREE_LIST_TIMEOUT_MS = 30_000
/**
* `ORCA_WORKTREE_ADD_TIMEOUT_MS` clamped into [{@link WORKTREE_ADD_TIMEOUT_MS},
* {@link WORKTREE_ADD_TIMEOUT_MAX_MS}]; unset, blank, or unparseable yields the default.
* Warns when a non-blank value is rejected or clamped; trimming and fractional truncation are silent.
* `env` is injectable for tests.
*/
export function resolveWorktreeAddTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
const raw = env.ORCA_WORKTREE_ADD_TIMEOUT_MS?.trim()
const requested = Math.floor(Number(raw))
// Why: `=300` reads as seconds to most operators, so clamp rather than obey.
const resolved = Number.isNaN(requested)
? WORKTREE_ADD_TIMEOUT_MS
: Math.min(Math.max(requested, WORKTREE_ADD_TIMEOUT_MS), WORKTREE_ADD_TIMEOUT_MAX_MS)
// Why: an `isNaN` guard here would delete the unparseable-value warning — comparing against NaN is unequal, and that is what catches it.
if (raw && resolved !== requested) {
const problem = Number.isNaN(requested)
? // Why: `600_000` copied out of this file is NaN, not out of range — say which.
'is not a number'
: `is outside [${WORKTREE_ADD_TIMEOUT_MS}, ${WORKTREE_ADD_TIMEOUT_MAX_MS}]ms`
console.warn(
`[git/worktree] ORCA_WORKTREE_ADD_TIMEOUT_MS="${raw}" ${problem}; using ${resolved}ms`
)
}
return resolved
}
function gitExecOptions(
cwd: string,
options: GitWorktreeExecOptions = {}
@@ -984,8 +1013,8 @@ async function performAddWorktree(
}
await gitExecFileAsync(args, {
...gitExecOptions(repoPath, options),
// Why: bound the checkout so a OneDrive cloud-placeholder stall (STA-1292) fails fast instead of hanging.
timeout: WORKTREE_ADD_TIMEOUT_MS
// Why: resolve per call — hoisting this to a module const would freeze the override at import.
timeout: resolveWorktreeAddTimeoutMs()
})
if (options.checkoutExistingBranch) {
@@ -293,6 +293,7 @@ describe('dashboard payload validation', () => {
localWindowsConpty: true,
osRelease: '10.0.22631',
windowsShiftEnterEncoding: 'alt-enter',
windowsInputRecordPasteNewline: 'alt-enter',
ctrlEnterCsiU: false,
kittyKeyboardAdvertised: false
}
@@ -307,6 +308,8 @@ describe('dashboard payload validation', () => {
{ ...terminalInput, localWindowsConpty: 'true' },
{ ...terminalInput, osRelease: 'x'.repeat(1_025) },
{ ...terminalInput, windowsShiftEnterEncoding: 'enter' },
{ ...terminalInput, forceBracketedMultilineTextPaste: false },
{ ...terminalInput, windowsInputRecordPasteNewline: 'enter' },
{ ...terminalInput, ctrlEnterCsiU: 'true' },
{ ...terminalInput, kittyKeyboardAdvertised: 1 }
]) {
@@ -53,6 +53,7 @@ const DASHBOARD_HOST_PLATFORMS = new Set([
'win32'
])
const WINDOWS_SHIFT_ENTER_ENCODINGS = new Set(['alt-enter', 'csi-u'])
const WINDOWS_INPUT_RECORD_PASTE_NEWLINES = new Set(['alt-enter', 'csi-u'])
function isBoundedString(value: unknown, maxLength: number, allowEmpty = false): value is string {
return typeof value === 'string' && value.length <= maxLength && (allowEmpty || value.length > 0)
@@ -308,6 +309,11 @@ function isDashboardTerminalInput(value: unknown): boolean {
isOptionalBoundedString(input.osRelease, MAX_LABEL_LENGTH) &&
typeof input.windowsShiftEnterEncoding === 'string' &&
WINDOWS_SHIFT_ENTER_ENCODINGS.has(input.windowsShiftEnterEncoding) &&
(input.forceBracketedMultilineTextPaste === undefined ||
input.forceBracketedMultilineTextPaste === true) &&
(input.windowsInputRecordPasteNewline === undefined ||
(typeof input.windowsInputRecordPasteNewline === 'string' &&
WINDOWS_INPUT_RECORD_PASTE_NEWLINES.has(input.windowsInputRecordPasteNewline))) &&
typeof input.ctrlEnterCsiU === 'boolean' &&
typeof input.kittyKeyboardAdvertised === 'boolean'
)
@@ -0,0 +1,91 @@
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
const handlers = new Map<string, (_event: unknown, args: never) => unknown>()
const { getPathMock, handleMock, resolveProvisionedRootSourceMock } = vi.hoisted(() => ({
getPathMock: vi.fn(),
handleMock: vi.fn(),
resolveProvisionedRootSourceMock: vi.fn()
}))
vi.mock('electron', () => ({
app: { getPath: getPathMock },
ipcMain: { handle: handleMock, removeHandler: vi.fn() }
}))
vi.mock('../ephemeral-vm-provisioned-root-source', () => ({
resolveProvisionedRootSource: resolveProvisionedRootSourceMock
}))
import { registerEphemeralVmHandlers } from './ephemeral-vm'
const tempDirs: string[] = []
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
vi.clearAllMocks()
})
it('cancels source resolution before a provisioned-root recipe creates resources', async () => {
const userDataPath = makeTempDir('orca-vm-cancel-user-data-')
const repoPath = makeTempDir('orca-vm-cancel-repo-')
const createMarker = join(repoPath, 'create-ran')
getPathMock.mockReturnValue(userDataPath)
handleMock.mockImplementation((channel: string, handler: never) => handlers.set(channel, handler))
writeRecipe(repoPath, createMarker)
resolveProvisionedRootSourceMock.mockImplementation(
(_store, _repo, _ref, signal: AbortSignal | undefined) =>
new Promise((resolve) =>
signal?.addEventListener('abort', () => resolve(null), { once: true })
)
)
registerEphemeralVmHandlers(makeStore(repoPath) as never)
const provision = handlers.get('ephemeralVm:provision')?.(null, {
repoId: 'repo-1',
recipeId: 'cloud-sandbox',
provisionId: 'provision-source-ref'
} as never) as Promise<{ ok: boolean; error?: string }>
await vi.waitFor(() => expect(resolveProvisionedRootSourceMock).toHaveBeenCalledOnce())
const cancelled = await handlers.get('ephemeralVm:cancelProvision')?.(null, {
provisionId: 'provision-source-ref'
} as never)
const result = await provision
expect(cancelled).toEqual({ cancelled: true })
expect(result).toEqual({ ok: false, error: 'Provisioning cancelled.', stdout: '', stderr: '' })
expect(existsSync(createMarker)).toBe(false)
})
function makeTempDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix))
tempDirs.push(dir)
return dir
}
function makeStore(repoPath: string) {
const repo = { id: 'repo-1', path: repoPath, displayName: 'Repo', addedAt: 0 }
return {
getRepo: vi.fn((id: string) => (id === repo.id ? repo : null)),
getRepos: vi.fn(() => [repo]),
getSettings: vi.fn(() => ({ activeRuntimeEnvironmentId: null }))
}
}
function writeRecipe(repoPath: string, createMarker: string): void {
writeFileSync(
join(repoPath, 'orca.yaml'),
[
'environmentRecipes:',
' - id: cloud-sandbox',
' name: Cloud Sandbox',
' checkoutMode: provisioned-root',
` create: ${JSON.stringify(`"${process.execPath}" -e "require('node:fs').writeFileSync('${createMarker}','x')"`)}`,
' destroy: none'
].join('\n')
)
}
@@ -0,0 +1,194 @@
import { execFileSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
const handlers = new Map<string, (_event: unknown, args: never) => unknown>()
const { connectRuntimeOwnedSshTargetMock, getPathMock, handleMock, removeHandlerMock } = vi.hoisted(
() => ({
connectRuntimeOwnedSshTargetMock: vi.fn(),
getPathMock: vi.fn(),
handleMock: vi.fn(),
removeHandlerMock: vi.fn()
})
)
vi.mock('electron', () => ({
app: { getPath: getPathMock },
ipcMain: { handle: handleMock, removeHandler: removeHandlerMock }
}))
vi.mock('../ephemeral-vm-runtime-ssh', () => ({
connectRuntimeOwnedSshTarget: connectRuntimeOwnedSshTargetMock,
disconnectRuntimeOwnedSshTarget: vi.fn(),
removeRuntimeOwnedSshTarget: vi.fn()
}))
import { registerEphemeralVmHandlers } from './ephemeral-vm'
let userDataPath: string
let repoPath: string
beforeEach(() => {
handlers.clear()
userDataPath = mkdtempSync(join(tmpdir(), 'orca-vm-ref-user-data-'))
repoPath = mkdtempSync(join(tmpdir(), 'orca-vm-ref-repo-'))
getPathMock.mockReturnValue(userDataPath)
handleMock.mockImplementation((channel: string, handler: never) => handlers.set(channel, handler))
connectRuntimeOwnedSshTargetMock.mockResolvedValue({
targetId: 'runtime-ssh-ref-test',
target: { id: 'runtime-ssh-ref-test' }
})
})
afterEach(() => {
rmSync(userDataPath, { recursive: true, force: true })
rmSync(repoPath, { recursive: true, force: true })
vi.clearAllMocks()
})
it('captures the source ref commit before a provisioned-root recipe starts', async () => {
createGitFixtureCommit(repoPath)
const expectedRefHead = createBranchCommit(repoPath, 'selected')
const createCountPath = join(repoPath, 'create-count.txt')
const createEnvPath = join(repoPath, 'create-env.json')
writeRecipe(repoPath, createCountPath, createEnvPath)
registerEphemeralVmHandlers(makeStore(repoPath) as never)
const result = await provision({ ref: 'selected' })
expect(result).toMatchObject({ ok: true, connectionType: 'ssh', expectedRefHead })
expect(readFileSync(createCountPath, 'utf8')).toBe('x')
expect(JSON.parse(readFileSync(createEnvPath, 'utf8'))).toEqual({
ref: 'selected',
refHead: expectedRefHead,
repoUrl: ''
})
})
it('captures the effective default ref when no start ref is selected', async () => {
const expectedRefHead = createGitFixtureCommit(repoPath)
const createCountPath = join(repoPath, 'create-count.txt')
const createEnvPath = join(repoPath, 'create-env.json')
writeRecipe(repoPath, createCountPath, createEnvPath)
registerEphemeralVmHandlers(makeStore(repoPath) as never)
const result = await provision({})
expect(result).toMatchObject({ ok: true, connectionType: 'ssh', expectedRefHead })
expect(JSON.parse(readFileSync(createEnvPath, 'utf8'))).toEqual({
ref: 'main',
refHead: expectedRefHead,
repoUrl: ''
})
})
it('pairs a multi-remote base with its fetch URL and remote branch ref', async () => {
createGitFixtureCommit(repoPath)
const expectedRefHead = createBranchCommit(repoPath, 'upstream-source')
const remoteUrl = 'https://example.com/acme/upstream.git'
execFileSync('git', ['remote', 'add', 'upstream', remoteUrl], { cwd: repoPath })
execFileSync('git', ['update-ref', 'refs/remotes/upstream/main', expectedRefHead], {
cwd: repoPath
})
const createCountPath = join(repoPath, 'create-count.txt')
const createEnvPath = join(repoPath, 'create-env.json')
writeRecipe(repoPath, createCountPath, createEnvPath)
registerEphemeralVmHandlers(makeStore(repoPath) as never)
const result = await provision({ ref: 'upstream/main' })
expect(result).toMatchObject({ ok: true, connectionType: 'ssh', expectedRefHead })
expect(JSON.parse(readFileSync(createEnvPath, 'utf8'))).toEqual({
ref: 'refs/heads/main',
refHead: expectedRefHead,
repoUrl: remoteUrl
})
})
it('rejects an unresolved source ref before the recipe creates resources', async () => {
createGitFixtureCommit(repoPath)
const createCountPath = join(repoPath, 'create-count.txt')
writeRecipe(repoPath, createCountPath)
registerEphemeralVmHandlers(makeStore(repoPath) as never)
const result = await provision({ ref: 'refs/heads/missing' })
expect(result).toMatchObject({
ok: false,
error: 'Could not resolve provisioned-root start ref: refs/heads/missing'
})
expect(() => readFileSync(createCountPath, 'utf8')).toThrow()
})
function provision(args: { ref?: string }): Promise<unknown> {
return handlers.get('ephemeralVm:provision')?.(null, {
repoId: 'repo-1',
recipeId: 'cloud-sandbox',
branch: 'fix-sandbox',
...(args.ref ? { ref: args.ref } : {})
} as never) as Promise<unknown>
}
function makeStore(path: string) {
const repo = { id: 'repo-1', path, displayName: 'Repo', badgeColor: '#000', addedAt: 0 }
return {
getRepo: vi.fn((id: string) => (id === repo.id ? repo : null)),
getRepos: vi.fn(() => [repo]),
getSettings: vi.fn(() => ({ activeRuntimeEnvironmentId: null }))
}
}
function createGitFixtureCommit(path: string): string {
execFileSync('git', ['init'], { cwd: path })
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: path })
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: path })
execFileSync('git', ['branch', '-M', 'main'], { cwd: path })
writeFileSync(join(path, 'fixture.txt'), 'fixture')
execFileSync('git', ['add', 'fixture.txt'], { cwd: path })
execFileSync('git', ['commit', '-m', 'fixture'], { cwd: path })
return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: path, encoding: 'utf8' }).trim()
}
function createBranchCommit(path: string, branch: string): string {
execFileSync('git', ['checkout', '-b', branch], { cwd: path })
writeFileSync(join(path, 'fixture.txt'), branch)
execFileSync('git', ['add', 'fixture.txt'], { cwd: path })
execFileSync('git', ['commit', '-m', branch], { cwd: path })
const head = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: path,
encoding: 'utf8'
}).trim()
execFileSync('git', ['checkout', 'main'], { cwd: path })
return head
}
function writeRecipe(path: string, createCountPath: string, createEnvPath?: string): void {
const startPath = join(path, 'start.js')
writeFileSync(
startPath,
[
`require('node:fs').appendFileSync(${JSON.stringify(createCountPath)}, 'x')`,
...(createEnvPath
? [
`require('node:fs').writeFileSync(${JSON.stringify(createEnvPath)}, JSON.stringify({ref:process.env.ORCA_REPO_REF,refHead:process.env.ORCA_REPO_REF_HEAD,repoUrl:process.env.ORCA_REPO_URL}))`
]
: []),
'console.log(JSON.stringify({schemaVersion:2,checkoutMode:"provisioned-root",',
'connection:{type:"ssh",projectRoot:"/workspace/repo",',
'target:{label:"Sandbox",host:"host",port:22,username:"root"}}}))'
].join('\n')
)
writeFileSync(
join(path, 'orca.yaml'),
[
'environmentRecipes:',
' - id: cloud-sandbox',
' name: Cloud Sandbox',
' checkoutMode: provisioned-root',
` create: ${JSON.stringify(`"${process.execPath}" "${startPath}"`)}`,
' destroy: none'
].join('\n')
)
}
@@ -1,4 +1,4 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
@@ -29,6 +29,10 @@ import { registerEphemeralVmRuntimeHandlers } from './ephemeral-vm-runtime-handl
const tempDirs: string[] = []
function nodeCommand(scriptPath: string): string {
return `"${process.execPath}" "${scriptPath}"`
}
beforeEach(() => {
handlers.clear()
handleMock.mockReset()
@@ -47,7 +51,7 @@ afterEach(() => {
}
})
it('removes the hidden SSH target when recipe context is unavailable', async () => {
it('removes the hidden SSH target when provider cleanup cannot start', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-vm-runtime-handler-'))
tempDirs.push(userDataPath)
getPathMock.mockReturnValue(userDataPath)
@@ -79,7 +83,68 @@ it('removes the hidden SSH target when recipe context is unavailable', async ()
expect(cleaned).toMatchObject({
status: 'cleanup_failed',
cleanupStatus: 'failed',
connectionMode: undefined,
sshTargetId: undefined
})
expect(removeRuntimeOwnedSshTargetMock).toHaveBeenCalledWith('runtime-ssh-missing-context')
})
it('stops in-flight cleanup and retains the runtime for retry', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-vm-runtime-handler-'))
const repoPath = mkdtempSync(join(tmpdir(), 'orca-vm-runtime-repo-'))
tempDirs.push(userDataPath, repoPath)
getPathMock.mockReturnValue(userDataPath)
const destroyPath = join(repoPath, 'destroy.js')
const destroyStartedPath = join(repoPath, 'destroy-started.txt')
writeFileSync(
destroyPath,
`require('fs').writeFileSync(${JSON.stringify(destroyStartedPath)}, 'yes'); setInterval(() => {}, 1000)`
)
upsertEphemeralVmRuntime(userDataPath, {
id: 'runtime-stop',
recipeId: 'cloud-sandbox',
recipe: {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
create: 'unused',
destroy: nodeCommand(destroyPath)
},
repoId: 'repo-1',
status: 'running',
cleanupStatus: 'not_started',
createdAt: 1,
updatedAt: 1,
recipeResult: {
schemaVersion: 1,
connection: {
type: 'ssh',
projectRoot: '/workspace/repo',
target: { label: 'VM', host: 'host', port: 22, username: 'orca' }
}
}
})
registerEphemeralVmRuntimeHandlers({
getRepo: vi.fn(() => ({
id: 'repo-1',
path: repoPath,
displayName: 'Repo',
badgeColor: '#000',
addedAt: 0
}))
} as never)
const cleanup = handlers.get('ephemeralVm:cleanup')?.(null, {
runtimeId: 'runtime-stop'
}) as Promise<{ status: string }>
await vi.waitFor(() => expect(existsSync(destroyStartedPath)).toBe(true))
const stopped = await handlers.get('ephemeralVm:stopCleanup')?.(null, {
runtimeId: 'runtime-stop'
})
expect(stopped).toMatchObject({
status: 'cleanup_failed',
cleanupStatus: 'failed',
cleanupLastError: 'Cleanup stopped by user.'
})
await expect(cleanup).resolves.toMatchObject({ status: 'cleanup_failed' })
})
+34 -2
View File
@@ -16,6 +16,7 @@ import {
import {
cleanupEphemeralVmRuntime,
resumeEphemeralVmRuntime,
stopEphemeralVmRuntimeCleanup,
suspendEphemeralVmRuntime
} from '../ephemeral-vm-runtime-service'
import { removeEphemeralVmRuntimeSshTarget } from '../ephemeral-vm-runtime-ssh-cleanup'
@@ -44,6 +45,7 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
ipcMain.removeHandler('ephemeralVm:attachWorkspace')
ipcMain.removeHandler('ephemeralVm:listRuntimes')
ipcMain.removeHandler('ephemeralVm:cleanup')
ipcMain.removeHandler('ephemeralVm:stopCleanup')
ipcMain.removeHandler('ephemeralVm:suspendWorkspace')
ipcMain.removeHandler('ephemeralVm:resumeWorkspace')
ipcMain.removeHandler('ephemeralVm:getCleanupCommand')
@@ -111,8 +113,9 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
// environment row; users can still remove that manually.
}
}
// Remove even on cleanup_failed (removal is idempotent via the deterministic
// id) so a terminal cleanup never orphans the hidden SSH target.
if (!result.ok) {
return result.runtime
}
return removeEphemeralVmRuntimeSshTarget({
userDataPath,
runtime: result.runtime,
@@ -121,6 +124,35 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
}
)
ipcMain.handle(
'ephemeralVm:stopCleanup',
async (_event, args: { runtimeId: string }): Promise<EphemeralVmRuntimeRecord> => {
const userDataPath = app.getPath('userData')
const stopping = stopEphemeralVmRuntimeCleanup({
userDataPath,
runtimeId: args.runtimeId
})
if (stopping) {
return (await stopping).runtime
}
const runtime = listEphemeralVmRuntimes(userDataPath).find(
(entry) => entry.id === args.runtimeId
)
if (!runtime) {
throw new Error(`Unknown ephemeral VM runtime: ${args.runtimeId}`)
}
if (runtime.cleanupStatus !== 'running') {
return runtime
}
return updateEphemeralVmRuntimeStatus(userDataPath, runtime.id, {
status: 'cleanup_failed',
cleanupStatus: 'failed',
cleanupLastError: 'Cleanup was interrupted. Retry cleanup to continue.'
})
}
)
ipcMain.handle(
'ephemeralVm:suspendWorkspace',
async (_event, args: { workspaceId: string }): Promise<EphemeralVmRuntimeRecord | null> => {
+4 -6
View File
@@ -498,7 +498,7 @@ describe('registerEphemeralVmHandlers', () => {
expect(listEnvironments(userDataPath)).toEqual([])
})
it('removes the runtime-owned SSH target on cleanup even when destroy fails', async () => {
it('retains the runtime-owned SSH target when destroy fails', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-ipc-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-ipc-repo-')
getPathMock.mockReturnValue(userDataPath)
@@ -523,8 +523,6 @@ describe('registerEphemeralVmHandlers', () => {
'}))'
].join('\n')
)
// Why: a failing destroy drives the cleanup_failed branch; the runtime-owned
// SSH target must still be torn down so it never orphans (see Fix D).
writeFileSync(destroyPath, 'process.exit(1)')
writeFileSync(
join(repoPath, 'orca.yaml'),
@@ -554,9 +552,9 @@ describe('registerEphemeralVmHandlers', () => {
expect(cleaned).toEqual(expect.objectContaining({ status: 'cleanup_failed' }))
expect(cleaned.cleanupLastError).toBeTruthy()
expect(removeRuntimeOwnedSshTargetMock).toHaveBeenCalledWith('runtime-ssh-orca-instance-1')
expect(cleaned.connectionMode).toBeUndefined()
expect(cleaned.sshTargetId).toBeUndefined()
expect(removeRuntimeOwnedSshTargetMock).not.toHaveBeenCalled()
expect(cleaned.connectionMode).toBe('ssh')
expect(cleaned.sshTargetId).toBe('runtime-ssh-orca-instance-1')
})
it('retries hidden SSH teardown without rerunning completed provider cleanup', async () => {
+33 -5
View File
@@ -35,6 +35,7 @@ import {
import { registerEphemeralVmRuntimeHandlers } from './ephemeral-vm-runtime-handlers'
import type { PluginService } from '../plugins/plugin-service'
import { getApprovedPluginVmRecipes } from '../plugins/plugin-approved-vm-recipes'
import { resolveProvisionedRootSource } from '../ephemeral-vm-provisioned-root-source'
const activeProvisionControllers = new Map<string, AbortController>()
@@ -52,6 +53,7 @@ export type EphemeralVmProvisionIpcResult =
connectionType: 'ssh'
runtime: EphemeralVmRuntimeRecord
sshTargetId: string
expectedRefHead?: string
stderr: string
warnings: EphemeralVmRecipeResultWarning[]
}
@@ -128,10 +130,6 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin
if (!recipe) {
return { ok: false, error: `Recipe not found: ${args.recipeId}`, stdout: '', stderr: '' }
}
const repoUrl = getProvisionedRootRecipeRepoUrl(
recipe.checkoutMode,
repo.repo.gitRemoteIdentity?.remoteUrl
)
const controller = args.provisionId ? new AbortController() : null
if (args.provisionId && controller) {
activeProvisionControllers.set(args.provisionId, controller)
@@ -151,6 +149,34 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin
// abort during the up-to-10s SSH connect window. Removing it in the provision
// promise's own .finally() would deregister it before SSH connect even starts.
try {
let recipeRepoUrl = repo.repo.gitRemoteIdentity?.remoteUrl
let sourceRef = args.ref
let expectedRefHead: string | undefined
if (recipe.checkoutMode === 'provisioned-root') {
const source = await resolveProvisionedRootSource(
store,
repo.repo,
args.ref,
controller?.signal
)
if (controller?.signal.aborted) {
return { ok: false, error: 'Provisioning cancelled.', stdout: '', stderr: '' }
}
if (!source) {
return {
ok: false,
error: args.ref
? `Could not resolve provisioned-root start ref: ${args.ref}`
: 'Could not resolve a default provisioned-root start ref.',
stdout: '',
stderr: ''
}
}
sourceRef = source.ref
expectedRefHead = source.head
recipeRepoUrl = source.remoteUrl ?? recipeRepoUrl
}
const repoUrl = getProvisionedRootRecipeRepoUrl(recipe.checkoutMode, recipeRepoUrl)
const result = await provisionEphemeralVmRuntime({
userDataPath: app.getPath('userData'),
repoPath: repo.repo.path,
@@ -161,7 +187,8 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin
workspaceName: args.workspaceName,
...(repoUrl ? { repoUrl } : {}),
...(args.branch ? { branch: args.branch } : {}),
...(args.ref ? { ref: args.ref } : {}),
...(sourceRef ? { ref: sourceRef } : {}),
...(expectedRefHead ? { expectedRefHead } : {}),
...(controller ? { signal: controller.signal } : {}),
onStdout: (chunk) => sendProvisionEvent('stdout', chunk),
onStderr: (chunk) => sendProvisionEvent('stderr', chunk)
@@ -194,6 +221,7 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin
connectionType: 'ssh',
runtime,
sshTargetId: ssh.targetId,
...(expectedRefHead ? { expectedRefHead } : {}),
stderr: redactEphemeralVmRecipeDiagnosticText(result.start.stderr),
warnings: getEphemeralVmRecipeResultWarnings(result.start.result)
}
@@ -73,6 +73,119 @@ describe('adoptProvisionedRootSshCheckout', () => {
})
})
it('rejects a recipe checkout on a branch Orca did not request', async () => {
seedRuntime(userDataPath, projectRoot)
registerSshGitProvider(connectionId, {
listWorktrees: vi
.fn()
.mockResolvedValue([gitWorktree(projectRoot, { branch: 'refs/heads/wrong-branch' })]),
exec: sparseCheckoutProbe(false)
} as never)
const { store, setWorktreeMeta } = makeStore()
await expect(
adoptProvisionedRootSshCheckout({
userDataPath,
request: request(projectRoot),
repo: repo(projectRoot),
store,
isRepoCurrent: () => true
})
).rejects.toThrow('requested branch')
expect(setWorktreeMeta).not.toHaveBeenCalled()
})
it('rejects a recipe checkout that did not start from the requested ref', async () => {
seedRuntime(userDataPath, projectRoot)
const exec = sparseCheckoutProbe(false)
registerSshGitProvider(connectionId, {
listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot, { head: 'wrong-head' })]),
exec
} as never)
const { store, setWorktreeMeta } = makeStore()
await expect(
adoptProvisionedRootSshCheckout({
userDataPath,
request: {
...request(projectRoot),
baseBranch: 'v1.2.3',
expectedRefHead: 'expected-head'
},
repo: repo(projectRoot),
store,
isRepoCurrent: () => true
})
).rejects.toThrow('requested ref')
expect(exec).toHaveBeenCalledOnce()
expect(setWorktreeMeta).not.toHaveBeenCalled()
})
it('accepts an attached requested branch created from a tag or commit ref', async () => {
seedRuntime(userDataPath, projectRoot)
const exec = sparseCheckoutProbe(false)
registerSshGitProvider(connectionId, {
listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot)]),
exec
} as never)
const { store } = makeStore()
await expect(
adoptProvisionedRootSshCheckout({
userDataPath,
request: {
...request(projectRoot),
baseBranch: 'v1.2.3',
expectedRefHead: 'abc123'
},
repo: repo(projectRoot),
store,
isRepoCurrent: () => true
})
).resolves.toMatchObject({ worktree: { branch: 'refs/heads/fix-sandbox' } })
})
it('rejects a requested ref without its source-host commit identity', async () => {
seedRuntime(userDataPath, projectRoot)
registerSshGitProvider(connectionId, {
listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot)]),
exec: sparseCheckoutProbe(false)
} as never)
const { store, setWorktreeMeta } = makeStore()
await expect(
adoptProvisionedRootSshCheckout({
userDataPath,
request: { ...request(projectRoot), baseBranch: 'missing-ref' },
repo: repo(projectRoot),
store,
isRepoCurrent: () => true
})
).rejects.toThrow('ref identity is missing')
expect(setWorktreeMeta).not.toHaveBeenCalled()
})
it('uses an explicit branch override as the requested local branch', async () => {
seedRuntime(userDataPath, projectRoot)
registerSshGitProvider(connectionId, {
listWorktrees: vi
.fn()
.mockResolvedValue([gitWorktree(projectRoot, { branch: 'refs/heads/feature/review' })]),
exec: sparseCheckoutProbe(false)
} as never)
const { store } = makeStore()
await expect(
adoptProvisionedRootSshCheckout({
userDataPath,
request: { ...request(projectRoot), branchNameOverride: 'feature/review' },
repo: repo(projectRoot),
store,
isRepoCurrent: () => true
})
).resolves.toMatchObject({ worktree: { branch: 'refs/heads/feature/review' } })
})
it('rejects a linked worktree and sparse checkout', async () => {
seedRuntime(userDataPath, projectRoot)
const listWorktrees = vi
+10
View File
@@ -90,6 +90,16 @@ export async function adoptProvisionedRootSshCheckout(args: {
if (gitWorktree.isSparse || sparseCheckoutEnabled) {
throw new Error('Provisioned-root recipes cannot adopt a sparse checkout.')
}
const requestedBranch = request.branchNameOverride ?? request.name
if (gitWorktree.branch !== `refs/heads/${requestedBranch}`) {
throw new Error("The recipe projectRoot is not checked out on Orca's requested branch.")
}
if (request.baseBranch && !request.expectedRefHead) {
throw new Error('The requested provisioned-root ref identity is missing.')
}
if (request.expectedRefHead && gitWorktree.head !== request.expectedRefHead) {
throw new Error("The recipe projectRoot was not created from Orca's requested ref.")
}
const worktreeId = `${repo.id}::${gitWorktree.path}`
attachEphemeralVmRuntimeToWorkspace({
+2
View File
@@ -45,6 +45,7 @@ export type EphemeralVmApi = {
connectionType: 'ssh'
runtime: EphemeralVmRuntimeRecord
sshTargetId: string
expectedRefHead?: string
stderr: string
warnings: EphemeralVmRecipeResultWarning[]
}
@@ -62,6 +63,7 @@ export type EphemeralVmApi = {
suspendWorkspace: (args: { workspaceId: string }) => Promise<EphemeralVmRuntimeRecord | null>
resumeWorkspace: (args: { workspaceId: string }) => Promise<EphemeralVmRuntimeRecord | null>
cleanup: (args: { runtimeId: string }) => Promise<EphemeralVmRuntimeRecord>
stopCleanup: (args: { runtimeId: string }) => Promise<EphemeralVmRuntimeRecord>
getCleanupCommand: (args: { runtimeId: string }) => Promise<{
runtimeId: string
command: string | null
+1
View File
@@ -3047,6 +3047,7 @@ const api = {
suspendWorkspace: (args) => ipcRenderer.invoke('ephemeralVm:suspendWorkspace', args),
resumeWorkspace: (args) => ipcRenderer.invoke('ephemeralVm:resumeWorkspace', args),
cleanup: (args) => ipcRenderer.invoke('ephemeralVm:cleanup', args),
stopCleanup: (args) => ipcRenderer.invoke('ephemeralVm:stopCleanup', args),
getCleanupCommand: (args) => ipcRenderer.invoke('ephemeralVm:getCleanupCommand', args)
} satisfies PreloadApi['ephemeralVm'],
+41 -2775
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
import { Suspense } from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import { AgentHibernationGate } from '../components/AgentHibernationGate'
import { AiVaultTabTitleSyncGate } from '../components/AiVaultTabTitleSyncGate'
import RetainedAgentsSyncGate from '../components/dashboard/RetainedAgentsSyncGate'
import { WorkspacePortScanner } from '../components/ports/WorkspacePortScanner'
import { MacosTccPromptNoticeHost } from '../hooks/MacosTccPromptNoticeHost'
import { useAppStore } from '../store'
const DashboardPopoutBridge = lazy(() => import('../components/dashboard/DashboardPopoutBridge'))
/**
* App-level gates that render nothing. Each lives here rather than inside the surface that
* needs it so its high-churn store subscriptions stay out of the App render tree.
*/
export function AppBackgroundServices(): React.JSX.Element {
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
const dashboardPopoutEnabled = useAppStore(
(s) => s.settings?.experimentalAgentDashboardPopout === true
)
return (
<>
<WorkspacePortScanner enabled={workspaceSessionReady} />
{/* Why: plugin language-pack discovery must not re-render the App shell. */}
<MacosTccPromptNoticeHost />
{/* Why: leaf-mounted retention sync keeps agent-status subscriptions out of the App render tree. */}
<RetainedAgentsSyncGate />
<AiVaultTabTitleSyncGate />
{dashboardPopoutEnabled ? (
<Suspense fallback={null}>
<DashboardPopoutBridge />
</Suspense>
) : null}
<AgentHibernationGate />
</>
)
}
@@ -0,0 +1,368 @@
import { Suspense } from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import { translate } from '@/i18n/i18n'
import { RecoverableRenderErrorBoundary } from '../components/error-boundaries/RecoverableRenderErrorBoundary'
import NewWorkspaceComposerModal from '../components/NewWorkspaceComposerModal'
import { CrashReportDialog } from '../components/crash-report/CrashReportDialog'
import { MarkdownTemplatePicker } from '../components/editor/MarkdownTemplatePicker'
import RecentTabSwitcher from '../components/tab-bar/RecentTabSwitcher'
import { SkillFreshnessUpdateDialog } from '../components/skills/SkillFreshnessUpdateDialog'
import { StarNagCard } from '../components/StarNagCard'
import { StarNagAgentValueMomentObserver } from '../components/star-nag/StarNagAgentValueMomentObserver'
import { StarNagToastHost } from '../components/star-nag/StarNagToastHost'
import { TelemetryFirstLaunchSurface } from '../components/TelemetryFirstLaunchSurface'
import { ZoomOverlay } from '../components/ZoomOverlay'
import { shouldRenderPetOverlay } from '../components/pet/pet-overlay-visibility'
import { useAppStore } from '../store'
import type { UpdateStatus } from '../../../shared/update-status-types'
import { useLazyModalMounts } from './use-lazy-modal-mounts'
import type { FloatingWorkspacePanelState } from './use-floating-workspace-panel'
import type { OnboardingGate } from './use-onboarding-and-feature-tips'
const QuickOpen = lazy(() => import('../components/QuickOpen'))
const WorktreeJumpPalette = lazy(() => import('../components/WorktreeJumpPalette'))
const WorkspaceCleanupDialog = lazy(
() => import('../components/workspace-cleanup/WorkspaceCleanupDialog')
)
const StatusBar = lazy(() =>
import('../components/status-bar/StatusBar').then((module) => ({ default: module.StatusBar }))
)
const SetupGuideModal = lazy(() => import('../components/setup-guide/SetupGuideModal'))
const FeatureWallModal = lazy(() => import('../components/feature-wall/FeatureWallModal'))
const FeatureTipsModal = lazy(() => import('../components/feature-tips/FeatureTipsModal'))
const AddRepoDialog = lazy(() => import('../components/sidebar/AddRepoDialog'))
const NonGitFolderDialog = lazy(() => import('../components/sidebar/NonGitFolderDialog'))
const AddProjectFromFolderDialog = lazy(
() => import('../components/sidebar/AddProjectFromFolderDialog')
)
const ProjectAddedDialog = lazy(() => import('../components/sidebar/ProjectAddedDialog'))
const DeleteWorktreeDialog = lazy(() => import('../components/sidebar/DeleteWorktreeDialog'))
const PreservedBranchBatchReviewModal = lazy(
() => import('../components/sidebar/PreservedBranchBatchReviewModal')
)
const DictationController = lazy(() =>
import('../components/dictation/DictationController').then((module) => ({
default: module.DictationController
}))
)
const SshPassphraseDialog = lazy(() =>
import('../components/settings/SshPassphraseDialog').then((module) => ({
default: module.SshPassphraseDialog
}))
)
const UpdateCard = lazy(() =>
import('../components/UpdateCard').then((module) => ({ default: module.UpdateCard }))
)
const RemoteServerUpdateDialog = lazy(
() => import('../components/settings/RemoteServerUpdateDialog')
)
const ContextualTourOverlay = lazy(() =>
import('../components/contextual-tours/ContextualTourOverlay').then((module) => ({
default: module.ContextualTourOverlay
}))
)
const SetupGuideTelemetryObserver = lazy(() =>
import('../components/setup-guide/SetupGuideTelemetryObserver').then((module) => ({
default: module.SetupGuideTelemetryObserver
}))
)
const FloatingTerminalPanel = lazy(() =>
import('../components/floating-terminal/FloatingTerminalPanel').then((module) => ({
default: module.FloatingTerminalPanel
}))
)
// Why: lazy so the WebP asset + overlay module aren't fetched unless the experimental flag is on.
const PetOverlay = lazy(() => import('../components/pet/PetOverlay'))
// Why: lazy so onboarding's step modules + assets aren't fetched for users past first-launch.
const OnboardingFlow = lazy(() => import('../components/onboarding/OnboardingFlow'))
type BoundaryProps = {
boundaryId: string
resetKey?: string | number | boolean | null
title?: string
description?: string
children: React.ReactNode
}
function ModalBoundary({ children, ...props }: BoundaryProps): React.JSX.Element {
return (
<RecoverableRenderErrorBoundary surface="modal" compact {...props}>
{children}
</RecoverableRenderErrorBoundary>
)
}
function OverlayBoundary({ children, ...props }: BoundaryProps): React.JSX.Element {
return (
<RecoverableRenderErrorBoundary surface="overlay" compact {...props}>
{children}
</RecoverableRenderErrorBoundary>
)
}
function shouldMountUpdateCardForStatus(status: UpdateStatus): boolean {
if (status.state === 'idle') {
return false
}
if (status.state === 'checking' || status.state === 'not-available') {
return status.userInitiated === true
}
return true
}
/**
* Every overlay and modal hosted at the App root, in a fixed sibling order so stacking stays
* stable. Each is gated so its chunk is only fetched once the surface can actually appear.
*/
export function AppRootSurfaces(props: {
floatingWorkspace: FloatingWorkspacePanelState
onboardingGate: OnboardingGate
}): React.JSX.Element {
const { floatingWorkspace, onboardingGate } = props
const { mountedLazyModalIds, shouldMountAddRepoDialog } = useLazyModalMounts()
const activeView = useAppStore((s) => s.activeView)
const activeModal = useAppStore((s) => s.activeModal)
const settings = useAppStore((s) => s.settings)
const statusBarVisible = useAppStore((s) => s.statusBarVisible)
const persistedUIReady = useAppStore((s) => s.persistedUIReady)
const petVisible = useAppStore((s) => s.petVisible)
const petEnabled = useAppStore((s) => s.settings?.experimentalPet === true)
const dictationState = useAppStore((s) => s.dictationState)
const updateStatus = useAppStore((s) => s.updateStatus)
const activeContextualTourId = useAppStore((s) => s.activeContextualTourId)
const hasSshCredentialRequest = useAppStore((s) => s.sshCredentialQueue.length > 0)
const shouldMountSetupGuideTelemetryObserver = persistedUIReady
const shouldMountUpdateCard = shouldMountUpdateCardForStatus(updateStatus)
const shouldMountDictationController =
settings?.voice?.enabled === true || dictationState !== 'idle'
const renderPetOverlay = shouldRenderPetOverlay({ persistedUIReady, petEnabled, petVisible })
return (
<>
{floatingWorkspace.shouldMountPanel ? (
<Suspense fallback={null}>
<OverlayBoundary
boundaryId="overlay.floating-workspace"
resetKey={floatingWorkspace.open}
title={translate('auto.App.1b3024bcd6', 'The floating workspace hit an error.')}
description={translate(
'auto.App.7cbfbf622f',
'Retry the floating workspace or close and reopen it.'
)}
>
<FloatingTerminalPanel
open={floatingWorkspace.open}
onOpenChange={floatingWorkspace.setOpenWithFocus}
tourInteractionSnapshot={floatingWorkspace.tourInteractionSnapshotRef.current}
/>
</OverlayBoundary>
</Suspense>
) : null}
{statusBarVisible ? (
<Suspense
fallback={
<div className="h-6 min-h-[24px] shrink-0 border-t border-border bg-[var(--bg-titlebar,var(--card))]" />
}
>
<OverlayBoundary
boundaryId="overlay.status-bar"
resetKey={activeView}
title={translate('auto.App.2e8ff36f94', 'The status bar hit an error.')}
description={translate(
'auto.App.8a023cea1f',
'Retry the status bar to remount its controls.'
)}
>
<StatusBar floatingTerminalOpen={floatingWorkspace.open} />
</OverlayBoundary>
</Suspense>
) : null}
{/* Why: keep in the entry bundle so a stale/corrupt lazy chunk can't strand users at Create. */}
{activeModal === 'new-workspace-composer' ? (
<ModalBoundary boundaryId="modal.new-workspace-composer" resetKey>
<NewWorkspaceComposerModal />
</ModalBoundary>
) : null}
<Suspense fallback={null}>
{shouldMountAddRepoDialog ? (
<ModalBoundary boundaryId="modal.add-repo" resetKey={activeModal === 'add-repo'}>
<AddRepoDialog />
</ModalBoundary>
) : null}
{/* Why: Settings can start Add Project without Sidebar, so its handoff dialogs must share the root host. */}
{activeModal === 'confirm-non-git-folder' ? (
<ModalBoundary boundaryId="modal.confirm-non-git-folder" resetKey>
<NonGitFolderDialog />
</ModalBoundary>
) : null}
{activeModal === 'confirm-add-project-from-folder' ? (
<ModalBoundary boundaryId="modal.confirm-add-project-from-folder" resetKey>
<AddProjectFromFolderDialog />
</ModalBoundary>
) : null}
{activeModal === 'project-added' ? (
<ModalBoundary boundaryId="modal.project-added" resetKey>
<ProjectAddedDialog />
</ModalBoundary>
) : null}
</Suspense>
{/* Why: root overlays can render Radix <Tooltip>s; keep inside the shared provider so lazy surfaces mount from any entry point. */}
<Suspense fallback={null}>
{mountedLazyModalIds.has('workspace-cleanup') ? (
<ModalBoundary
boundaryId="modal.workspace-cleanup"
resetKey={activeModal === 'workspace-cleanup'}
>
<WorkspaceCleanupDialog />
</ModalBoundary>
) : null}
</Suspense>
<Suspense fallback={null}>
{mountedLazyModalIds.has('quick-open') ? (
<ModalBoundary boundaryId="modal.quick-open" resetKey={activeModal === 'quick-open'}>
<QuickOpen />
</ModalBoundary>
) : null}
{mountedLazyModalIds.has('worktree-palette') ? (
<ModalBoundary
boundaryId="modal.worktree-palette"
resetKey={activeModal === 'worktree-palette'}
>
<WorktreeJumpPalette />
</ModalBoundary>
) : null}
{mountedLazyModalIds.has('setup-guide') ? (
<ModalBoundary boundaryId="modal.setup-guide" resetKey={activeModal === 'setup-guide'}>
<SetupGuideModal />
</ModalBoundary>
) : null}
{mountedLazyModalIds.has('feature-wall') ? (
<ModalBoundary boundaryId="modal.feature-wall" resetKey={activeModal === 'feature-wall'}>
<FeatureWallModal />
</ModalBoundary>
) : null}
{mountedLazyModalIds.has('feature-tips') ? (
<ModalBoundary boundaryId="modal.feature-tips" resetKey={activeModal === 'feature-tips'}>
<FeatureTipsModal />
</ModalBoundary>
) : null}
</Suspense>
{shouldMountSetupGuideTelemetryObserver ? (
<Suspense fallback={null}>
<SetupGuideTelemetryObserver />
</Suspense>
) : null}
{activeContextualTourId !== null ? (
<Suspense fallback={null}>
<ContextualTourOverlay />
</Suspense>
) : null}
{/* Why: mount only after UI hydration, else a hidden pet flashes while the store still holds default visibility. */}
{renderPetOverlay ? (
<Suspense fallback={null}>
<OverlayBoundary boundaryId="overlay.pet" resetKey={petVisible}>
<PetOverlay />
</OverlayBoundary>
</Suspense>
) : null}
{shouldMountUpdateCard ? (
<Suspense fallback={null}>
<OverlayBoundary boundaryId="overlay.update-card" resetKey={activeView}>
<UpdateCard />
</OverlayBoundary>
</Suspense>
) : null}
<OverlayBoundary boundaryId="overlay.star-nag" resetKey={activeView}>
<StarNagCard />
</OverlayBoundary>
<OverlayBoundary boundaryId="overlay.star-nag-toast" resetKey={activeView}>
<StarNagToastHost />
</OverlayBoundary>
<StarNagAgentValueMomentObserver />
{/* Why: mount at App root to render once per session; internal cohort gate limits it to pre-telemetry users — see telemetry-plan.md §First-launch experience. */}
<OverlayBoundary
boundaryId="overlay.telemetry-first-launch"
resetKey={settings?.telemetry?.optedIn ?? 'unknown'}
>
<TelemetryFirstLaunchSurface />
</OverlayBoundary>
<OverlayBoundary boundaryId="overlay.zoom" resetKey={activeView}>
<ZoomOverlay />
</OverlayBoundary>
<Suspense fallback={null}>
{activeModal === 'delete-worktree' ? (
<ModalBoundary boundaryId="modal.delete-worktree" resetKey>
<DeleteWorktreeDialog />
</ModalBoundary>
) : null}
{activeModal === 'preserved-branch-review' ? (
<ModalBoundary boundaryId="modal.preserved-branch-review" resetKey>
<PreservedBranchBatchReviewModal />
</ModalBoundary>
) : null}
</Suspense>
{hasSshCredentialRequest ? (
<Suspense fallback={null}>
<ModalBoundary boundaryId="modal.ssh-passphrase" resetKey={activeModal}>
<SshPassphraseDialog />
</ModalBoundary>
</Suspense>
) : null}
<ModalBoundary boundaryId="modal.markdown-template-picker" resetKey={activeModal}>
<MarkdownTemplatePicker />
</ModalBoundary>
<RecoverableRenderErrorBoundary
boundaryId="modal.crash-report"
surface="modal"
reportAsCrash={false}
resetKey={activeModal}
compact
title={translate('auto.App.722d03aa62', 'The crash report dialog hit an error.')}
description={translate(
'auto.App.acd66311dc',
'Use the Help menu after retrying if you still need diagnostics.'
)}
>
<CrashReportDialog />
</RecoverableRenderErrorBoundary>
{onboardingGate.onboarding && onboardingGate.shouldRender ? (
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
boundaryId="modal.onboarding"
surface="modal"
title={translate('auto.App.f02d37278a', 'Onboarding hit an error.')}
description={translate(
'auto.App.221a95ba38',
'Retry onboarding or close it and continue in the app.'
)}
>
<OnboardingFlow
onboarding={onboardingGate.onboarding}
onOnboardingChange={onboardingGate.setOnboarding}
/>
</RecoverableRenderErrorBoundary>
</Suspense>
) : null}
{shouldMountDictationController ? (
<Suspense fallback={null}>
<OverlayBoundary boundaryId="overlay.dictation" resetKey={activeView}>
<DictationController />
</OverlayBoundary>
</Suspense>
) : null}
<OverlayBoundary boundaryId="overlay.recent-tab-switcher" resetKey={activeView}>
<RecentTabSwitcher />
</OverlayBoundary>
{/* Why: hosts a live terminal pane needing the link-routing preference context; mounting outside crashes it. */}
<OverlayBoundary boundaryId="overlay.skill-freshness-update-dialog">
<SkillFreshnessUpdateDialog />
</OverlayBoundary>
<Suspense fallback={null}>
<OverlayBoundary boundaryId="overlay.remote-server-update-dialog">
<RemoteServerUpdateDialog />
</OverlayBoundary>
</Suspense>
</>
)
}
@@ -0,0 +1,245 @@
import { Suspense, useRef } from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import { translate } from '@/i18n/i18n'
import Sidebar from '../components/Sidebar'
import RightSidebar from '../components/right-sidebar'
import { RecoverableRenderErrorBoundary } from '../components/error-boundaries/RecoverableRenderErrorBoundary'
import { FloatingTerminalToggleButton } from '../components/floating-terminal/FloatingTerminalToggleButton'
import { TerminalWorkbenchContainer } from '../components/TerminalWorkbenchContainer'
import type { VirtualizedScrollAnchor } from '../hooks/useVirtualizedScrollAnchor'
import { TitlebarLeftControls } from './TitlebarLeftControls'
import { RightSidebarToggle, TitlebarMainStrip } from './TitlebarMainStrip'
import type { AppChromeLayout } from './use-app-chrome-layout'
import type { FloatingWorkspacePanelState } from './use-floating-workspace-panel'
const Landing = lazy(() => import('../components/Landing'))
const WorktreeCreationPanel = lazy(
() => import('../components/worktree-creation/WorktreeCreationPanel')
)
const TaskPage = lazy(() => import('../components/TaskPage'))
const AutomationsPage = lazy(() => import('../components/automations/AutomationsPage'))
const ActivityPrototypePage = lazy(() => import('../components/activity/ActivityPrototypePage'))
const Settings = lazy(() => import('../components/settings/Settings'))
const ArtifactsPage = lazy(() => import('../components/artifacts/ArtifactsPage'))
const WorkspaceSpacePage = lazy(() => import('../components/workspace-space/WorkspaceSpacePage'))
const MobilePage = lazy(() => import('../components/mobile/MobilePage'))
const Terminal = lazy(() => import('../components/Terminal'))
type WorktreeSidebarScrollRefs = {
scrollOffsetRef: React.MutableRefObject<number>
scrollAnchorRef: React.MutableRefObject<VirtualizedScrollAnchor>
}
function WorktreeSidebar({
layout,
scrollRefs
}: {
layout: AppChromeLayout
scrollRefs: WorktreeSidebarScrollRefs
}): React.JSX.Element {
return (
<RecoverableRenderErrorBoundary
boundaryId="sidebar.worktrees"
surface="sidebar"
resetKey={layout.activeView}
title={translate('auto.App.1468601e7b', 'The workspace list hit an error.')}
description={
layout.leftTitlebarChromeLayout.shouldMount
? translate(
'auto.App.bdc71dddc9',
'The active workspace remains open. Retry the list or switch views.'
)
: translate(
'auto.App.cba0fafda5',
'The active page remains open. Retry the list or switch views.'
)
}
>
<Sidebar
worktreeScrollOffsetRef={scrollRefs.scrollOffsetRef}
worktreeScrollAnchorRef={scrollRefs.scrollAnchorRef}
/>
</RecoverableRenderErrorBoundary>
)
}
function ActivePage({ layout }: { layout: AppChromeLayout }): React.JSX.Element {
const { activeView, activeWorktreeId, activePendingCreationId, creationLayoutActive } = layout
return (
<>
{activeView === 'settings' ? <Settings /> : null}
{activeView === 'artifacts' ? <ArtifactsPage /> : null}
{activeView === 'tasks' ? <TaskPage /> : null}
{activeView === 'automations' ? <AutomationsPage /> : null}
{activeView === 'activity' ? <ActivityPrototypePage /> : null}
{activeView === 'space' ? <WorkspaceSpacePage /> : null}
{activeView === 'mobile' ? <MobilePage /> : null}
{activeView === 'terminal' && creationLayoutActive && activePendingCreationId ? (
<WorktreeCreationPanel
creationId={activePendingCreationId}
reserveCollapsedSidebarHeaderSpace={layout.leftTitlebarChromeLayout.isFloating}
/>
) : null}
{activeView === 'terminal' && !activeWorktreeId && !creationLayoutActive ? <Landing /> : null}
</>
)
}
/** The left sidebar + titlebar + page/workbench content area + right sidebar. */
export function AppWorkspaceShell(props: {
layout: AppChromeLayout
floatingWorkspace: FloatingWorkspacePanelState
}): React.JSX.Element {
const { layout, floatingWorkspace } = props
const titlebarLeftControls = <TitlebarLeftControls layout={layout} />
const titlebarMainStrip = <TitlebarMainStrip layout={layout} />
// Why: keep virtualized scroll memory above the sidebar's workspace/landing remount so the left list doesn't restart at scrollTop 0.
const scrollOffsetRef = useRef(0)
const scrollAnchorRef = useRef<VirtualizedScrollAnchor>(null)
const sidebarScrollRefs = { scrollOffsetRef, scrollAnchorRef }
return (
// Why: workspace activation is a hot path; activeWorktreeId in reset keys would remount whole surfaces during wake.
<RecoverableRenderErrorBoundary
boundaryId="app.workspace-shell"
surface="workspace-shell"
resetKey={layout.activeView}
title={translate('auto.App.df1d56bf87', 'The workspace shell hit an error.')}
description={translate(
'auto.App.8504ddf267',
'The app is still running. Retry the shell or use the menu to report the crash details.'
)}
>
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
{/* Why: keep the non-workspace titlebar inside this left+center wrapper so it doesn't span over the right-sidebar column. */}
<div className="flex flex-col flex-1 min-w-0 min-h-0">
{/* Why: workspace view drops the full-width titlebar so tab groups extend to the top; settings/landing/tasks keep it. */}
{!layout.leftTitlebarChromeLayout.shouldMount ? (
<div className="titlebar">
<div className="flex items-center shrink-0 mr-2">{titlebarLeftControls}</div>
{titlebarMainStrip}
</div>
) : null}
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
{layout.showSidebar ? (
layout.leftTitlebarChromeLayout.shouldMount ? (
/* Why: when the sidebar is collapsed, take this titlebar-height header out of flex layout so the terminal/editor reclaim the left edge. */
<div
className={`flex min-h-0 flex-col shrink-0${layout.sidebarOpen ? '' : ' relative w-0 overflow-visible'}`}
>
<div
// Why: floating titlebar-left occludes the center column's border-l seam; border-r restores that line, w-max sizes it to its own controls.
className={`titlebar-left${
layout.leftTitlebarChromeLayout.isFloating
? ' titlebar-left-floating absolute top-0 left-0 z-10 w-max border-r border-border'
: ''
}`}
style={{
// Why: custom sidebar appearances are scoped to the sidebar root; mirror those vars onto the header in the same left-column panel.
...(layout.sidebarOpen ? layout.leftSidebarStyle : undefined),
// Why: size from the wrapper's live width so the header tracks in-flight drag resizes (persisted to Zustand only on mouseup).
width: layout.sidebarOpen ? '100%' : undefined
}}
>
{titlebarLeftControls}
</div>
{/* Why: flex-1/min-h-0 slot needed under the fixed 36px header, else the sidebar collapses to content height and loses its scroll viewport. */}
<div className="flex min-h-0 flex-1">
<WorktreeSidebar layout={layout} scrollRefs={sidebarScrollRefs} />
</div>
</div>
) : (
<WorktreeSidebar layout={layout} scrollRefs={sidebarScrollRefs} />
)
) : null}
<div className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden">
{/* Why: automations owns its page header; the stacked titlebar would be an empty 36px stripe. */}
{layout.stackedSidebarOpen && layout.activeView !== 'automations' ? (
<div className="titlebar">{titlebarMainStrip}</div>
) : null}
<div className="relative flex flex-1 min-w-0 min-h-0 overflow-hidden">
{/* Why: match the RightSidebar header's 36px/top-0 so the toggle's vertical center is identical open vs closed — else the icon jitters. */}
{layout.workspaceChromeActive && !layout.rightSidebarOpen && (
<div
className="absolute top-0 z-10 flex items-center h-[36px]"
style={
{
// Why: --window-controls-width keeps the toggle clear of the fixed window-controls overlay (138px on custom chrome, 0px otherwise); no internal spacer — one would cover the pane-actions Ellipsis button with an unclickable div.
right: 'var(--window-controls-width)',
WebkitAppRegion: 'no-drag'
} as React.CSSProperties
}
>
{layout.showRightSidebarControls ? <RightSidebarToggle /> : null}
</div>
)}
<div className="flex flex-1 min-w-0 min-h-0 flex-col">
{layout.shouldMountTerminalWorkbench ? (
<TerminalWorkbenchContainer isVisible={layout.terminalWorkbenchVisible}>
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
boundaryId="terminal.workbench"
surface="terminal-workbench"
resetKey="terminal"
title={translate(
'auto.App.5a9519aef0',
'The workspace workbench hit an error.'
)}
description={translate(
'auto.App.98d4ea2823',
'Terminal, browser, or editor rendering failed in this workspace. Retry to remount it.'
)}
>
<Terminal />
</RecoverableRenderErrorBoundary>
</Suspense>
</TerminalWorkbenchContainer>
) : null}
<Suspense fallback={null}>
<RecoverableRenderErrorBoundary
boundaryId={`page.${layout.activeView}`}
surface="page"
resetKey={layout.activeView}
title={translate('auto.App.b7a714db1e', 'This page hit an error.')}
description={translate(
'auto.App.03a14f6b5b',
'Retry the page or navigate to another Orca surface.'
)}
>
<ActivePage layout={layout} />
</RecoverableRenderErrorBoundary>
</Suspense>
</div>
{floatingWorkspace.showToggleButton ? (
<FloatingTerminalToggleButton
open={floatingWorkspace.open}
onToggle={() => floatingWorkspace.setOpenWithFocus((open) => !open)}
/>
) : null}
</div>
</div>
</div>
</div>
{/* Why: keep the shell mounted for layout stability (heavy panels disconnect while closed); unmount on the distraction-free tasks view. */}
{layout.showRightSidebarControls ? (
<RecoverableRenderErrorBoundary
boundaryId="right-sidebar"
surface="right-sidebar"
resetKey={
layout.rightSidebarTab === 'explorer'
? `${layout.rightSidebarTab}:${layout.rightSidebarExplorerView}`
: layout.rightSidebarTab
}
title={translate('auto.App.ed6b168d00', 'The right sidebar hit an error.')}
description={translate(
'auto.App.8d1e160ed1',
'Retry the sidebar or switch tabs to reload this surface.'
)}
>
<RightSidebar />
</RecoverableRenderErrorBoundary>
) : null}
</div>
</RecoverableRenderErrorBoundary>
)
}
@@ -0,0 +1,153 @@
import { ArrowLeft, ArrowRight, MoreHorizontal, PanelLeft } from 'lucide-react'
import logo from '../../../../resources/logo.svg'
import { translate } from '@/i18n/i18n'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { shouldShowWorktreeHistoryControls } from '../lib/titlebar-worktree-history-controls'
import {
canGoBackWorktreeHistory,
canGoForwardWorktreeHistory
} from '@/store/slices/worktree-nav-history'
import { useShortcutLabel } from '../hooks/useShortcutLabel'
import { useAppStore } from '../store'
import { hasCustomTitleBar, isMac } from './app-window-chrome'
import type { AppChromeLayout } from './use-app-chrome-layout'
/**
* The titlebar's left cluster: window chrome padding, app name, sidebar toggle, and the
* worktree back/forward pair. Shared by the full-width titlebar and the sidebar-width left
* header so the agent badge popover isn't duplicated.
*/
export function TitlebarLeftControls({ layout }: { layout: AppChromeLayout }): React.JSX.Element {
const toggleSidebar = useAppStore((s) => s.toggleSidebar)
const updateSettings = useAppStore((s) => s.updateSettings)
const canGoBackWorktree = useAppStore(canGoBackWorktreeHistory)
const canGoForwardWorktree = useAppStore(canGoForwardWorktreeHistory)
const leftSidebarShortcutLabel = useShortcutLabel('sidebar.left.toggle')
const historyBackShortcutLabel = useShortcutLabel('worktree.history.back')
const historyForwardShortcutLabel = useShortcutLabel('worktree.history.forward')
return (
// Why: measure the ENTIRE row so TabGroupPanel's collapse spacer reserves enough width; measuring only the inner cluster left back/forward over the first tab.
// Why: collapsed mode floats in a w-0 wrapper; w-max stops Windows Chromium from shrinking the app name to one glyph.
<div
ref={layout.titlebarLeftControlsRef}
className={`flex h-full shrink-0 items-center${
layout.leftTitlebarChromeLayout.isFloating ? ' w-max' : ' w-full'
}`}
>
<div className="flex h-full items-center">
{isMac && !layout.isFullScreen ? (
<div className="titlebar-traffic-light-pad" />
) : hasCustomTitleBar ? (
/* Why: Windows/Linux remove the native title bar, so render the logo plus a ··· button that pops the application menu (as Alt does). */
<>
<img src={logo} alt="" aria-hidden className="titlebar-logo" />
<Tooltip>
<TooltipTrigger asChild>
<button
className="titlebar-icon-button"
aria-label={translate('auto.App.8b0b8eb54f', 'Application menu')}
onClick={() => window.api.ui.popupMenu()}
>
<MoreHorizontal size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.App.8b0b8eb54f', 'Application menu')}
</TooltipContent>
</Tooltip>
</>
) : (
<div className="pl-2" />
)}
{layout.showSidebar && !hasCustomTitleBar && layout.showTitlebarAppName && (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className="titlebar-app-name"
aria-label={translate('auto.App.5096cbbc86', 'Orca')}
>
<span className="titlebar-app-name-main">
{translate('auto.App.5096cbbc86', 'Orca')}
</span>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onSelect={() => {
void updateSettings({ showTitlebarAppName: false })
}}
>
{translate('auto.App.e81217c1b7', 'Hide App Name')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)}
{layout.showSidebar && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="sidebar-toggle"
onClick={toggleSidebar}
aria-label={translate('auto.App.e4b9e7dff7', 'Toggle sidebar')}
>
<PanelLeft size={16} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.App.ce37cf5279', 'Toggle sidebar ({{value0}})', {
value0: leftSidebarShortcutLabel
})}
</TooltipContent>
</Tooltip>
)}
</div>
{/* Why: Back/Forward span worktree + page history, so show the cluster wherever the shortcut is live (hidden in Settings/non-stack views). */}
{shouldShowWorktreeHistoryControls(layout.activeView) && (
// With the sidebar collapsed the header shrink-wraps and ml-auto has no spare width, so keep a fixed gutter before Back.
<div className="ml-auto mr-3 flex items-center pl-2">
<Tooltip>
<TooltipTrigger asChild>
<button
className="sidebar-toggle sidebar-toggle-compact"
onClick={() => useAppStore.getState().goBackWorktree()}
disabled={!canGoBackWorktree}
aria-label={translate('auto.App.064bd07810', 'Go back')}
>
<ArrowLeft size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.App.fe21e8f6f5', 'Go back ({{value0}})', {
value0: historyBackShortcutLabel
})}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
className="sidebar-toggle sidebar-toggle-compact"
onClick={() => useAppStore.getState().goForwardWorktree()}
disabled={!canGoForwardWorktree}
aria-label={translate('auto.App.cf9099fe98', 'Go forward')}
>
<ArrowRight size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.App.f7aa73e785', 'Go forward ({{value0}})', {
value0: historyForwardShortcutLabel
})}
</TooltipContent>
</Tooltip>
</div>
)}
</div>
)
}
@@ -0,0 +1,80 @@
import { Minimize2, PanelRight } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
import { ActivityTitlebarControls } from '../components/activity/ActivityTitlebarControls'
import { useShortcutLabel } from '../hooks/useShortcutLabel'
import { useAppStore } from '../store'
import { hasCustomTitleBar } from './app-window-chrome'
import type { AppChromeLayout } from './use-app-chrome-layout'
export function RightSidebarToggle(): React.JSX.Element {
const toggleRightSidebar = useAppStore((s) => s.toggleRightSidebar)
const rightSidebarShortcutLabel = useShortcutLabel('sidebar.right.toggle')
return (
<Tooltip>
<TooltipTrigger asChild>
<button
className="sidebar-toggle mr-2"
onClick={toggleRightSidebar}
aria-label={translate('auto.App.9e0b441a91', 'Toggle right sidebar')}
>
<PanelRight size={16} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.App.c184e056de', 'Toggle right sidebar ({{value0}})', {
value0: rightSidebarShortcutLabel
})}
</TooltipContent>
</Tooltip>
)
}
/** The titlebar's center/right strip: the tab-strip portal slot and the trailing chrome buttons. */
export function TitlebarMainStrip({ layout }: { layout: AppChromeLayout }): React.JSX.Element {
const handleToggleExpand = (): void => {
if (!layout.effectiveActiveTabId) {
return
}
window.dispatchEvent(
new CustomEvent(TOGGLE_TERMINAL_PANE_EXPAND_EVENT, {
detail: { tabId: layout.effectiveActiveTabId }
})
)
}
return (
<>
{layout.activeView === 'activity' ? (
<ActivityTitlebarControls />
) : layout.creationLayoutActive ? null : (
<div
id="titlebar-tabs"
className={`flex flex-1 min-w-0 self-stretch${!layout.workspaceChromeActive ? ' invisible pointer-events-none' : ''}`}
/>
)}
{layout.showTitlebarExpandButton && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="titlebar-icon-button"
onClick={handleToggleExpand}
aria-label={translate('auto.App.c1cf0b0e4a', 'Collapse pane')}
disabled={!layout.activeTabCanExpand}
>
<Minimize2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.App.c1cf0b0e4a', 'Collapse pane')}
</TooltipContent>
</Tooltip>
)}
{/* Why: the open right sidebar's header renders its own close button, so hide this duplicate. */}
{layout.showRightSidebarControls && !layout.rightSidebarOpen ? <RightSidebarToggle /> : null}
{/* Why: reserve space so the Windows/Linux window-controls overlay doesn't obscure content. */}
{hasCustomTitleBar && <div className="window-controls-titlebar-spacer" />}
</>
)
}
@@ -0,0 +1,65 @@
import { useEffect, useState } from 'react'
import { translate } from '@/i18n/i18n'
// Why: Windows and Linux both remove the native title bar, so we render our own min/max/close buttons (Fluent/Win11-style SVGs).
export function WindowControls(): React.JSX.Element {
const [maximized, setMaximized] = useState(false)
useEffect(() => {
// Why: maximize-changed only fires on transitions; seed from main on mount so a startup-maximized window shows the right icon.
let cancelled = false
void window.api.ui.isMaximized().then((value) => {
if (!cancelled) {
setMaximized(value)
}
})
const unsubscribe = window.api.ui.onMaximizeChanged(setMaximized)
return () => {
cancelled = true
unsubscribe()
}
}, [])
return (
<div className="window-controls">
<button
className="window-controls-btn"
aria-label={translate('auto.App.bbb7f90669', 'Minimize')}
onClick={() => window.api.ui.minimize()}
>
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<path d="M0 5h10v1H0z" fill="currentColor" />
</svg>
</button>
<button
className="window-controls-btn"
aria-label={
maximized
? translate('auto.App.66f0a552e5', 'Restore')
: translate('auto.App.c9d6f98459', 'Maximize')
}
onClick={() => window.api.ui.maximize()}
>
{maximized ? (
// Restore icon (two overlapping squares)
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<path d="M2 0v2H0v8h8V8h2V0H2zm6 9H1V3h7v6zM9 7H8V2H3V1h6v6z" fill="currentColor" />
</svg>
) : (
// Maximize icon (single square outline)
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<path d="M0 0v10h10V0H0zm9 9H1V1h8v8z" fill="currentColor" />
</svg>
)}
</button>
<button
className="window-controls-btn window-controls-close"
aria-label={translate('auto.App.e960d18540', 'Close')}
// Why: route close through main so the 'close' event fires the terminal-running confirmation guard; window.close() is unreliable in sandboxed renderers.
onClick={() => window.api.ui.requestClose()}
>
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
<path d="M1 0L0 1l4 4-4 4 1 1 4-4 4 4 1-1-4-4 4-4-1-1-4 4-4-4z" fill="currentColor" />
</svg>
</button>
</div>
)
}
@@ -0,0 +1,248 @@
import { useShallow } from 'zustand/react/shallow'
import { canShowRightSidebarForView } from '@/lib/right-sidebar-visibility'
import { isFloatingWorkspacePanelFocused } from '@/lib/floating-workspace-terminal-actions'
import { requestScrollToCurrentWorkspaceRevealAndRename } from '@/lib/scroll-to-current-workspace-status'
import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification'
import { shouldShowWorktreeHistoryControls } from '../lib/titlebar-worktree-history-controls'
import { TOGGLE_WORKSPACE_BOARD_EVENT } from '../components/sidebar/useWorkspaceBoardPanel'
import { useAppStore } from '../store'
import type { usePluginCommands } from '@/store/plugin-panels'
import { isGitRepoKind } from '../../../shared/repo-kind'
import type {
KeybindingActionId,
KeybindingContext,
PhysicalModifierToken
} from '../../../shared/keybindings'
import { shortcutPlatform } from './app-window-chrome'
type AppStoreState = ReturnType<typeof useAppStore.getState>
// Abstraction over a real KeyboardEvent and a synthetic double-tap gesture so one dispatch path serves both; KeybindingInput-compatible.
export type ShortcutDispatchInput = {
key?: string
code?: string
altKey?: boolean
metaKey?: boolean
ctrlKey?: boolean
shiftKey?: boolean
doubleTapModifier?: PhysicalModifierToken
target: EventTarget | null
defaultPrevented: boolean
preventDefault: () => void
}
export type AppShortcutActions = ReturnType<typeof useAppShortcutActions>
export type AppShortcutState = {
activeView: AppStoreState['activeView']
activeWorktreeId: AppStoreState['activeWorktreeId']
actions: AppShortcutActions
creationLayoutActive: boolean
floatingTerminalEnabled: boolean
floatingTerminalOpen: boolean
floatingVisibleTabCount: number
keybindings: AppStoreState['keybindings']
openFloatingWorkspaceMaximized: () => void
pluginCommands: ReturnType<typeof usePluginCommands>
setFloatingTerminalOpen: (open: boolean) => void
terminalShortcutPolicy: NonNullable<AppStoreState['settings']>['terminalShortcutPolicy']
workspaceChromeActive: boolean
}
export function useAppShortcutActions() {
// Why: consolidate action refs into one useShallow subscription so React runs one equality check per store mutation instead of one per action.
return useAppStore(
useShallow((s) => ({
toggleSidebar: s.toggleSidebar,
toggleRightSidebar: s.toggleRightSidebar,
setRightSidebarOpen: s.setRightSidebarOpen,
setRightSidebarTab: s.setRightSidebarTab,
showRightSidebarFiles: s.showRightSidebarFiles,
showRightSidebarSearch: s.showRightSidebarSearch,
openDiffNotesSendMenuForActiveWorktree: s.openDiffNotesSendMenuForActiveWorktree
}))
)
}
export function getKeybindingContext(target: EventTarget | null): KeybindingContext {
return target instanceof HTMLElement && target.classList.contains('xterm-helper-textarea')
? 'terminal'
: 'app'
}
/**
* Builds the app-level handlers for every keybindable action. Each returns whether it claimed
* the chord, so an unavailable surface (settings view, closed floating panel) falls through to
* the terminal or the next handler instead of silently no-opping.
*
* `input` is absent when a command is invoked from the palette/menu rather than a key event.
*/
export function createAppCommandHandlers(
state: AppShortcutState,
input?: ShortcutDispatchInput,
keybindingContext: KeybindingContext = 'app'
): Map<KeybindingActionId, () => boolean> {
const {
activeView,
activeWorktreeId,
actions,
creationLayoutActive,
floatingTerminalEnabled,
floatingTerminalOpen,
keybindings,
openFloatingWorkspaceMaximized,
terminalShortcutPolicy,
workspaceChromeActive
} = state
const floatingWorkspaceFocused = isFloatingWorkspacePanelFocused()
const canRevealRightSidebar = !creationLayoutActive && canShowRightSidebarForView(activeView)
const claim = (actionId: KeybindingActionId, run: () => void): boolean => {
input?.preventDefault()
if (
input &&
keybindingContext === 'terminal' &&
(terminalShortcutPolicy ?? 'orca-first') === 'orca-first'
) {
showTerminalShortcutCaptureNotification({
actionId,
platform: shortcutPlatform,
keybindings
})
}
run()
return true
}
const revealRightSidebarTab = (
actionId: KeybindingActionId,
tab: Parameters<AppShortcutActions['setRightSidebarTab']>[0]
): boolean =>
canRevealRightSidebar
? claim(actionId, () => {
actions.setRightSidebarTab(tab)
actions.setRightSidebarOpen(true)
})
: false
return new Map<KeybindingActionId, () => boolean>([
[
'worktree.history.back',
() => {
if (creationLayoutActive || !shouldShowWorktreeHistoryControls(activeView)) {
return false
}
return claim('worktree.history.back', () => useAppStore.getState().goBackWorktree())
}
],
[
'worktree.history.forward',
() => {
if (creationLayoutActive || !shouldShowWorktreeHistoryControls(activeView)) {
return false
}
return claim('worktree.history.forward', () => useAppStore.getState().goForwardWorktree())
}
],
['sidebar.left.toggle', () => claim('sidebar.left.toggle', () => actions.toggleSidebar())],
[
'sidebar.sleepingWorkspaces.toggle',
() =>
claim('sidebar.sleepingWorkspaces.toggle', () => {
const store = useAppStore.getState()
const nextShowSleeping = !store.showSleepingWorkspaces
store.setShowSleepingWorkspaces(nextShowSleeping)
if (nextShowSleeping) {
store.setSidebarOpen(true)
}
})
],
[
'floatingWorkspace.maximize',
() => {
if (floatingTerminalOpen || !floatingTerminalEnabled) {
return false
}
return claim('floatingWorkspace.maximize', openFloatingWorkspaceMaximized)
}
],
[
'tab.rename',
() => {
const store = useAppStore.getState()
if (
!workspaceChromeActive ||
floatingWorkspaceFocused ||
store.activeTabType !== 'terminal' ||
!store.activeTabId
) {
return false
}
return claim('tab.rename', () => store.setRenamingTabId(store.activeTabId!))
}
],
[
'workspace.rename',
() => {
if (!workspaceChromeActive || floatingWorkspaceFocused || !activeWorktreeId) {
return false
}
return claim('workspace.rename', () => {
useAppStore.getState().setSidebarOpen(true)
requestScrollToCurrentWorkspaceRevealAndRename()
})
}
],
[
'workspace.openBoard',
() => {
if (activeView === 'settings') {
return false
}
return claim('workspace.openBoard', () => {
useAppStore.getState().setSidebarOpen(true)
window.dispatchEvent(new CustomEvent(TOGGLE_WORKSPACE_BOARD_EVENT))
})
}
],
[
'view.tasks',
() => {
const store = useAppStore.getState()
if (activeView === 'settings' || !store.repos.some((repo) => isGitRepoKind(repo))) {
return false
}
return claim('view.tasks', () => store.openTaskPage())
}
],
[
'sidebar.right.toggle',
() =>
canRevealRightSidebar
? claim('sidebar.right.toggle', () => actions.toggleRightSidebar())
: false
],
[
'sidebar.explorer.toggle',
() =>
canRevealRightSidebar
? claim('sidebar.explorer.toggle', () => actions.showRightSidebarFiles())
: false
],
[
'sidebar.search.toggle',
() =>
canRevealRightSidebar
? claim('sidebar.search.toggle', () => actions.showRightSidebarSearch())
: false
],
[
'sidebar.sourceControl.toggle',
() =>
// Why: the terminal's own find bar owns this chord while it is open.
document.querySelector('[data-terminal-search-root]')
? false
: revealRightSidebarTab('sidebar.sourceControl.toggle', 'source-control')
],
['sidebar.checks.toggle', () => revealRightSidebarTab('sidebar.checks.toggle', 'checks')],
['sidebar.ports.toggle', () => revealRightSidebarTab('sidebar.ports.toggle', 'ports')]
])
}
@@ -0,0 +1,13 @@
import {
isPairedWebClientWindow,
shouldRenderDesktopWindowChrome
} from '@/lib/desktop-window-chrome'
export const isMac = navigator.userAgent.includes('Mac')
const isWindows = !isMac && navigator.userAgent.includes('Windows')
export const shortcutPlatform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux'
// Why: Windows and Linux remove the native title bar so the renderer draws its own chrome; paired web clients run in a browser tab and must not.
export const hasCustomTitleBar = shouldRenderDesktopWindowChrome({
platform: shortcutPlatform,
isWebClient: isPairedWebClientWindow()
})
@@ -0,0 +1,147 @@
import { useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { SYNC_FIT_PANES_EVENT } from '@/constants/terminal'
import { canShowRightSidebarForView } from '@/lib/right-sidebar-visibility'
import { resolveLeftSidebarStyleVariables } from '@/lib/left-sidebar-appearance'
import { resolveLeftTitlebarChromeLayout } from '@/lib/titlebar-left-chrome'
import { shouldShowWorktreeCreationSurface } from '@/lib/worktree-creation-surface'
import { useAppStore } from '../store'
import { selectActiveTerminalChromeState } from '../store/active-terminal-chrome-selector'
import { useSystemPrefersDark } from '../components/terminal-pane/use-system-prefers-dark'
import {
hasRequestedBackgroundTerminalWorktreeMount,
subscribeBackgroundTerminalWorktreeMountRequests
} from '../components/terminal/background-terminal-worktree-mount'
export type AppChromeLayout = ReturnType<typeof useAppChromeLayout>
/**
* Derives the App shell's layout decisions — which titlebar variant mounts, whether the
* workspace owns the tab strip, and which surfaces stay mounted — from store state.
*/
export function useAppChromeLayout() {
const activeView = useAppStore((s) => s.activeView)
const sidebarOpen = useAppStore((s) => s.sidebarOpen)
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
const rightSidebarTab = useAppStore((s) => s.rightSidebarTab)
const rightSidebarExplorerView = useAppStore((s) => s.rightSidebarExplorerView)
const isFullScreen = useAppStore((s) => s.isFullScreen)
const settings = useAppStore((s) => s.settings)
const activePendingCreationId = useAppStore((s) => s.activePendingCreationId)
// Why: the creation surface owns the tab strip from the first pending frame; gating on the delayed loader flag swapped the tab bar mid-create.
const activePendingCreationExists = useAppStore(
(s) =>
s.activePendingCreationId !== null &&
s.pendingWorktreeCreations[s.activePendingCreationId] !== undefined
)
const {
activeWorktreeId,
tabCount,
effectiveActiveTabId,
activeTabCanExpand,
effectiveActiveTabExpanded
} = useAppStore(useShallow(selectActiveTerminalChromeState))
const backgroundTerminalMountRequested = useSyncExternalStore(
subscribeBackgroundTerminalWorktreeMountRequests,
hasRequestedBackgroundTerminalWorktreeMount,
hasRequestedBackgroundTerminalWorktreeMount
)
const systemPrefersDark = useSystemPrefersDark()
const leftSidebarStyle = useMemo(
() => resolveLeftSidebarStyleVariables(settings, systemPrefersDark),
[settings, systemPrefersDark]
) as React.CSSProperties | undefined
const canMountTerminalWorkbenchNow = activeWorktreeId !== null || backgroundTerminalMountRequested
// Why a latch in state, not a ref: the write has to be visible to the next render, and a
// render-phase ref write would also survive a render React discards. Setting state during
// render is the supported way to derive it, and the `||` below keeps this render correct.
const [hasMountedTerminalWorkbench, setHasMountedTerminalWorkbench] = useState(false)
if (canMountTerminalWorkbenchNow && !hasMountedTerminalWorkbench) {
setHasMountedTerminalWorkbench(true)
}
// Why: skip the terminal bundle on the landing path, but once mounted keep hidden panes alive through sleep/shutdown when activeWorktreeId briefly goes null.
const shouldMountTerminalWorkbench = canMountTerminalWorkbenchNow || hasMountedTerminalWorkbench
// Why: visible worktree creation owns its faux tab strip start to finish; keep the previous workspace mounted for retention without real chrome.
const creationLayoutActive = shouldShowWorktreeCreationSurface({
activeView,
activePendingCreationId,
hasActivePendingCreation: activePendingCreationExists
})
const workspaceChromeActive =
activeView === 'terminal' && activeWorktreeId !== null && !creationLayoutActive
const hasTabBar = tabCount >= 2
// Activity/Space are full-page navigation surfaces (like Settings), so the worktree sidebar is hidden there.
const showSidebar =
activeView !== 'settings' && activeView !== 'activity' && activeView !== 'space'
// Tasks/Landing show the full titlebar only when the sidebar is collapsed; open, they mirror workspace view (creation suppresses it).
const stackedSidebarOpen =
!workspaceChromeActive && !creationLayoutActive && showSidebar && sidebarOpen
// Visible creation keeps only the top-left window chrome; tabs and right-sidebar chrome stay gated by workspaceChromeActive.
const leftTitlebarChromeLayout = resolveLeftTitlebarChromeLayout({
workspaceChromeActive,
stackedSidebarOpen,
creationLayoutActive,
sidebarOpen
})
// Why: useLayoutEffect fires before paint, so dispatching SYNC_FIT_PANES_EVENT reflows the terminal in the same frame as the width change — no wrongly-sized transient.
useLayoutEffect(() => {
window.dispatchEvent(new CustomEvent(SYNC_FIT_PANES_EVENT))
}, [sidebarOpen, rightSidebarOpen])
const titlebarLeftControlsRef = useRef<HTMLDivElement | null>(null)
const [collapsedSidebarHeaderWidth, setCollapsedSidebarHeaderWidth] = useState(0)
useLayoutEffect(() => {
const controls = titlebarLeftControlsRef.current
if (!controls) {
return
}
const updateWidth = (): void => {
setCollapsedSidebarHeaderWidth(controls.getBoundingClientRect().width)
}
updateWidth()
const observer = new ResizeObserver(() => {
updateWidth()
})
observer.observe(controls)
return () => observer.disconnect()
}, [
isFullScreen,
settings?.showTitlebarAppName,
showSidebar,
leftTitlebarChromeLayout.isFloating,
sidebarOpen
])
return {
activeView,
activeWorktreeId,
activePendingCreationId,
activeTabCanExpand,
effectiveActiveTabId,
collapsedSidebarHeaderWidth,
creationLayoutActive,
isFullScreen,
leftSidebarStyle,
leftTitlebarChromeLayout,
rightSidebarExplorerView,
rightSidebarOpen,
rightSidebarTab,
shouldMountTerminalWorkbench,
showSidebar,
// Full-page navigation surfaces own the whole content area, so suppress right-sidebar controls.
showRightSidebarControls: !creationLayoutActive && canShowRightSidebarForView(activeView),
showTitlebarAppName: settings?.showTitlebarAppName !== false,
showTitlebarExpandButton: workspaceChromeActive && !hasTabBar && effectiveActiveTabExpanded,
sidebarOpen,
stackedSidebarOpen,
// Why: the workbench stays mounted while hidden, so visibility tracks the same condition separately.
terminalWorkbenchVisible: workspaceChromeActive,
titlebarLeftControlsRef,
workspaceChromeActive
}
}
@@ -0,0 +1,198 @@
import { useEffect } from 'react'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '../store'
import { isRemoteWorkspaceSnapshotApplyInProgress } from '../hooks/useIpcEvents'
import { createSessionWriteSubscriber } from '../lib/session-write-subscriber'
import { buildActiveViewUnloadPatch } from '../lib/active-view-persist'
import {
isIntentionalAppRestartInProgress,
registerUpdaterBeforeUnloadBypass
} from '../lib/updater-beforeunload'
import {
buildWorkspaceSessionPayload,
shouldPersistWorkspaceSession
} from '../lib/workspace-session'
import {
buildWorkspaceSessionHostSnapshots,
patchWorkspaceSessionByHost
} from '../lib/workspace-session-host-persistence'
import {
createShutdownCheckpointBeforeUnloadHandler,
createShutdownCheckpointGuard
} from '../lib/shutdown-checkpoint-guard'
import { shutdownBufferCaptures } from '../components/terminal-pane/shutdown-buffer-captures'
import { dispatchWindowCloseRequest } from '../components/window-close-request-coordinator'
import {
ORCA_APP_RESTART_ABORTED_EVENT,
ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT
} from '../../../shared/updater-renderer-events'
import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../../../shared/renderer-shutdown-events'
import type { RemoteWorkspacePatchResult } from '../../../shared/remote-workspace-types'
// Why: bound the resume-record loss window on a hard kill to ~1 min; capture skips unchanged records so per-tick cost is negligible.
const SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS = 60_000
function applyRemoteWorkspacePatchStatus(
targetId: string,
result: RemoteWorkspacePatchResult
): void {
const store = useAppStore.getState()
if (result.ok) {
store.setRemoteWorkspaceSyncStatus(targetId, {
phase: 'synced',
direction: 'push',
revision: result.snapshot.revision,
updatedAt: result.snapshot.updatedAt,
lastSyncedAt: Date.now(),
message: translate('auto.App.332dbfa497', 'Workspace uploaded')
})
return
}
store.setRemoteWorkspaceSyncStatus(targetId, {
phase: result.reason === 'stale-revision' ? 'conflict' : 'offline',
direction: 'push',
revision: result.snapshot?.revision,
updatedAt: result.snapshot?.updatedAt,
lastSyncedAt: Date.now(),
message:
result.message ??
(result.reason === 'stale-revision'
? translate(
'auto.hooks.useIpcEvents.workspaceChangedOnAnotherDevice',
'Workspace changed on another device'
)
: translate('auto.hooks.useIpcEvents.2fe88c2e06', 'Remote workspace sync unavailable'))
})
}
/**
* Writes durable renderer session state to disk: the debounced per-host writer, the remote
* workspace upload chain, and the synchronous shutdown checkpoint.
*/
export function useAppSessionPersistence(): void {
useEffect(() => registerUpdaterBeforeUnloadBypass(), [])
// Why: session persistence only writes to disk; a Zustand subscribe() outside React drops ~15 render-cycle subscriptions and their re-renders on every tab/file/browser change.
useEffect(() => {
return createSessionWriteSubscriber({
store: useAppStore,
shouldSchedulePersist: () => !isRemoteWorkspaceSnapshotApplyInProgress(),
persist: ({ patch }) => {
const state = useAppStore.getState()
// Why: route each host's worktree-scoped slice to its own partition; return the local write so the remote-workspace upload chain below keeps its ordering.
const localWrite = patchWorkspaceSessionByHost(window.api.session, patch, state)
void localWrite
const hydratedTargetIds = Array.from(state.remoteWorkspaceHydratedTargetIds).filter(
(targetId) => state.remoteWorkspaceSyncStatusByTargetId[targetId]?.phase !== 'conflict'
)
if (hydratedTargetIds.length > 0) {
void localWrite
.then(() => window.api.remoteWorkspace?.setForConnectedTargets({ hydratedTargetIds }))
.then((results) => {
for (const { targetId, result } of results ?? []) {
applyRemoteWorkspacePatchStatus(targetId, result)
}
})
.catch((err) => {
for (const targetId of hydratedTargetIds) {
useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, {
phase: 'error',
direction: 'push',
message: err instanceof Error ? err.message : 'Workspace upload failed'
})
}
})
}
}
})
}, [])
// On shutdown, capture terminal scrollback buffers and flush all durable
// renderer state through one synchronous main-process checkpoint.
useEffect(() => {
// Why: beforeunload fires twice during a manual quit — once from the
// synthetic dispatch in the onWindowCloseRequested handler (captures
// good data while TerminalPanes are still mounted), and again from the
// native window close triggered by confirmWindowClose(). Between these
// two firings, PTY exit events can arrive and unmount TerminalPanes,
// emptying shutdownBufferCaptures. The guard prevents the second call
// from overwriting the good session data with an empty snapshot.
const shutdownCheckpoint = createShutdownCheckpointGuard(() => {
const shouldCaptureSession = shouldPersistWorkspaceSession(useAppStore.getState())
if (shouldCaptureSession) {
for (const capture of shutdownBufferCaptures.values()) {
try {
capture({ includeLocalBuffers: false })
} catch {
// Don't let one pane's failure block the rest.
}
}
// Why: agent provider session ids live only in agentStatusByPaneKey,
// which is in-memory. Capture them into the persisted sleeping-session
// map so a daemon/session death while the app is closed can still
// cold-restore via the agent's resume command (#5232).
useAppStore.getState().captureAllSleepingAgentSessions('quit')
}
// Why: re-read state after capture() calls populated scrollback buffers
// into the store via Zustand setters. The earlier read is only for the
// gating flags and would miss those updates.
const freshState = useAppStore.getState()
let sessionSnapshots: ReturnType<typeof buildWorkspaceSessionHostSnapshots> = []
try {
sessionSnapshots = shouldCaptureSession
? buildWorkspaceSessionHostSnapshots(buildWorkspaceSessionPayload(freshState), freshState)
: []
} catch (error) {
// Why: dirty drafts exist only in the full session snapshot.
if (
!isIntentionalAppRestartInProgress() ||
freshState.openFiles.some((file) => file.isDirty)
) {
throw error
}
console.error('[app] Full renderer session snapshot failed; using durable session', error)
window.api.app.stageBeforeUnloadSync({
sessions: [],
ui: buildActiveViewUnloadPatch(freshState)
})
return
}
window.api.app.stageBeforeUnloadSync({
sessions: sessionSnapshots,
ui: buildActiveViewUnloadPatch(freshState)
})
})
const persistBeforeUnload = createShutdownCheckpointBeforeUnloadHandler(shutdownCheckpoint)
window.addEventListener('beforeunload', persistBeforeUnload)
window.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, shutdownCheckpoint.reset)
window.addEventListener(ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, shutdownCheckpoint.reset)
window.addEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, shutdownCheckpoint.reset)
return () => {
window.removeEventListener('beforeunload', persistBeforeUnload)
window.removeEventListener(ORCA_APP_RESTART_ABORTED_EVENT, shutdownCheckpoint.reset)
window.removeEventListener(
ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT,
shutdownCheckpoint.reset
)
window.removeEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, shutdownCheckpoint.reset)
}
}, [])
// Why: beforeunload never fires on a hard kill (crash, forced update, TerminateProcess), so periodically capture agent session ids (not scrollback) so live agents keep a resume record.
useEffect(() => {
const timer = window.setInterval(() => {
if (!shouldPersistWorkspaceSession(useAppStore.getState())) {
return
}
useAppStore.getState().captureAllSleepingAgentSessions('periodic')
}, SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS)
return () => window.clearInterval(timer)
}, [])
// Why: subscribe at the always-mounted App root — Terminal owns the confirm flow but isn't mounted on the landing page, so subscribing there left File→Exit / Ctrl+Q with no listener (#5144).
useEffect(() => {
return window.api.ui.onWindowCloseRequested(dispatchWindowCloseRequest)
}, [])
// Why no periodic scrollback save: the old 3-min re-serialize (#461) stalled the main thread for seconds; the out-of-process daemon (#729) is the durable replacement, non-daemon users lose in-session scrollback on unexpected exit.
}
@@ -0,0 +1,49 @@
import { useAppStore } from '../store'
import { useIpcEvents } from '../hooks/useIpcEvents'
import { useAutomationDispatchEvents } from '../hooks/useAutomationDispatchEvents'
import { useAutoAckViewedAgent } from '../hooks/useAutoAckViewedAgent'
import { useEditorExternalWatch } from '../hooks/useEditorExternalWatch'
import { useGlobalFileDrop } from '../hooks/useGlobalFileDrop'
import { useAppMenuPaste } from '../hooks/useAppMenuPaste'
import { useAppMenuSelectionActions } from '../hooks/useAppMenuSelectionActions'
import { useLargeTextControlPaste } from '../hooks/useLargeTextControlPaste'
import {
resolvePrimarySelectionMiddleClickPaste,
usePrimarySelectionPaste
} from '../hooks/usePrimarySelectionPaste'
import { useRadixBodyPointerEventsRecovery } from '../hooks/useRadixBodyPointerEventsRecovery'
import { useGitStatusPolling } from '../components/right-sidebar/useGitStatusPolling'
import { useOsc52ClipboardDefaultOnNotice } from '../components/terminal-pane/osc52-clipboard-default-on-notice'
import { useWebSessionTabsSync } from '../runtime/web-session-tabs-sync'
import { useRemoteRuntimeRecoveryTriggers } from '../runtime/use-remote-runtime-recovery-triggers'
/**
* App-level subscriptions that must outlive any individual surface. Each one is here because
* the component that consumes its result unmounts (right sidebar, explorer, terminal) or is
* absent entirely on the landing path.
*/
export function useAppShellServices(): void {
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
const persistedUIReady = useAppStore((s) => s.persistedUIReady)
const primarySelectionMiddleClickPaste = useAppStore((s) =>
resolvePrimarySelectionMiddleClickPaste(s.settings?.primarySelectionMiddleClickPaste)
)
useRadixBodyPointerEventsRecovery()
useWebSessionTabsSync()
// Subscribe to IPC push events
useIpcEvents()
useRemoteRuntimeRecoveryTriggers()
useAutomationDispatchEvents()
// Why: git polling lives at App level (RightSidebar unmounts when closed, stranding stale Rebasing/Merging badges); gate on workspaceSessionReady so it doesn't compete with first paint.
useGitStatusPolling({ enabled: workspaceSessionReady })
// Why: wire file-change watching at App level so the editor keeps hearing FS changes when Explorer unmounts (right-sidebar switches to Source Control/Checks).
useEditorExternalWatch()
useGlobalFileDrop()
useAutoAckViewedAgent()
useAppMenuPaste()
useAppMenuSelectionActions()
useLargeTextControlPaste()
usePrimarySelectionPaste(primarySelectionMiddleClickPaste)
useOsc52ClipboardDefaultOnNotice(persistedUIReady)
}
@@ -0,0 +1,335 @@
import { useEffect, useRef } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { syncZoomCSSVar } from '@/lib/ui-zoom'
import { installCodexDetachedPaneRestartExecutor } from '@/components/terminal-pane/codex-detached-pane-restart-scheduler'
import { useAppStore } from '../store'
import { WORKTREE_REFRESH_CONCURRENCY } from '../store/slices/worktrees'
import { sweepRestoredCodexPanesForStaleAccounts } from '../lib/codex-stale-pane-sweep'
import { fetchWorkspaceSessionWithRuntimeHostOwners } from '../lib/workspace-session-host-persistence'
import {
collectFolderWorkspaceKeysFromSession,
collectWorktreeHydrationRepoIdsFromSession
} from '../lib/workspace-session-hydration-keys'
import { hydratePersistedUIAfterStartupRead } from '../lib/startup-ui-hydration'
import {
logRendererStartupDiagnostic,
timeRendererStartupStep,
timeRendererStartupSyncStep
} from '../startup/startup-diagnostics'
import { recoverFromDegradedStartup } from '../startup/startup-degraded-recovery'
import { restoreSshConnectionsForStartup } from '../startup/startup-ssh-connection-restore'
import { publishTerminalViewAttributesAtAppStart } from '../components/terminal-pane/terminal-appearance'
import { getSystemPrefersDark } from '../lib/terminal-theme'
import {
collectTerminalProviderSnapshotPtyIds,
refreshTerminalProviderSnapshotCapabilities
} from '../components/terminal/terminal-provider-snapshot-capability'
import {
getRepoExecutionHostId,
isRuntimeOwnedSshTargetId,
parseExecutionHostId,
toRuntimeExecutionHostId,
type ExecutionHostId
} from '../../../shared/execution-host'
import { mapWithConcurrency } from '../../../shared/map-with-concurrency'
import type { OnboardingState } from '../../../shared/onboarding-state-types'
async function listRuntimeSessionHostIdsForStartup(): Promise<ExecutionHostId[]> {
try {
return (await window.api.runtimeEnvironments.list()).map((environment) =>
toRuntimeExecutionHostId(environment.id)
)
} catch (err) {
console.warn('Failed to list runtime session hosts for startup:', err)
return []
}
}
function useStartupActions() {
// Why: consolidate action refs into one useShallow subscription so React runs one equality check per store mutation instead of one per action.
return useAppStore(
useShallow((s) => ({
fetchReposForAllHosts: s.fetchReposForAllHosts,
awaitLocalRepoCatalogSettlement: s.awaitLocalRepoCatalogSettlement,
fetchProjectGroupsForAllHosts: s.fetchProjectGroupsForAllHosts,
fetchFolderWorkspacesForAllHosts: s.fetchFolderWorkspacesForAllHosts,
fetchAllWorktrees: s.fetchAllWorktrees,
fetchWorktrees: s.fetchWorktrees,
fetchWorktreeLineage: s.fetchWorktreeLineage,
fetchOrcaProfiles: s.fetchOrcaProfiles,
fetchSettings: s.fetchSettings,
fetchKeybindings: s.fetchKeybindings,
initGitHubCache: s.initGitHubCache,
hydrateWorkspaceSession: s.hydrateWorkspaceSession,
hydrateTabsSession: s.hydrateTabsSession,
hydrateEditorSession: s.hydrateEditorSession,
hydrateBrowserSession: s.hydrateBrowserSession,
fetchBrowserSessionProfiles: s.fetchBrowserSessionProfiles,
reconnectPersistedTerminals: s.reconnectPersistedTerminals,
setDeferredSshReconnectTargets: s.setDeferredSshReconnectTargets,
setSshConnectionState: s.setSshConnectionState,
hydratePersistedUI: s.hydratePersistedUI,
setHydrationSucceeded: s.setHydrationSucceeded,
pruneLastVisitedTimestamps: s.pruneLastVisitedTimestamps,
seedActiveWorktreeLastVisitedIfMissing: s.seedActiveWorktreeLastVisitedIfMissing
}))
)
}
/**
* Runs the renderer's one-shot boot chain: settings, persisted UI, the local repo catalog,
* the workspace session, SSH reconnect, and terminal restoration — then unlocks the session
* writer. A failure anywhere leaves disk state untouched and boots in degraded no-save mode.
*/
export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingState) => void): void {
const actions = useStartupActions()
// Why a ref: the boot chain must not restart if a caller passes a new callback identity.
// Synced in an effect (declared before the chain below, so it lands first on mount) because
// a render-phase write can leak from a render React discards.
const onOnboardingLoadedRef = useRef(onOnboardingLoaded)
useEffect(() => {
onOnboardingLoadedRef.current = onOnboardingLoaded
}, [onOnboardingLoaded])
useEffect(() => installCodexDetachedPaneRestartExecutor(), [])
// Fetch initial data + hydrate GitHub cache from disk
useEffect(() => {
let cancelled = false
// Why: declared outside the async block so cleanup can abort it — under StrictMode the first (unmounted) pass would otherwise keep spawning PTYs.
const abortController = new AbortController()
// Why (issue #1158): hydrate persisted UI right after ui.get() succeeds; the UI writer is gated only on persistedUIReady, so later default fallback would serialize defaults to disk.
let uiHydrated = false
// Why (issue #1158): track whether success-path reconnect started so the catch doesn't re-run it — re-entering on partially-mutated state would double-set ptyIds and drain pending* twice.
let reconnectStarted = false
void (async () => {
const startupStartedAt = performance.now()
logRendererStartupDiagnostic('startup-chain-start')
try {
// Why: nothing in the hydration chain reads profile state synchronously, so don't let it add a serial IPC round-trip before fetchSettings.
void actions.fetchOrcaProfiles()
// Why: repo/worktree hydration routes through settings.activeRuntimeEnvironmentId; load settings first so a persisted remote runtime doesn't hydrate stale local state.
await timeRendererStartupStep('fetch-settings', () => actions.fetchSettings())
// Why: hidden-at-launch PTYs can query OSC 10/11 before any pane mounts; publish view attributes as soon as settings exist so main's silent-until-push responder has data.
publishTerminalViewAttributesAtAppStart(
useAppStore.getState().settings,
getSystemPrefersDark()
)
// Why: start keybindings + onboarding now so their IPC overlaps the local catalog scans; await them at their original spots. The .catch marks rejections handled if an earlier await throws first.
// Why: browser session profiles are NOT started early — on a remote runtime the RPC may be unconnected and a failed fetch clears the list.
const keybindingsPromise = timeRendererStartupStep('fetch-keybindings', () =>
actions.fetchKeybindings()
)
keybindingsPromise.catch(() => {})
const onboardingPromise = timeRendererStartupStep('onboarding-get', () =>
window.api.onboarding.get()
)
onboardingPromise.catch(() => {})
// Why: await ui.get() (not overlap) so persisted view settings hydrate before the local catalog/session steps and first paint reflects them.
const persistedUI = await timeRendererStartupStep('ui-get', () => window.api.ui.get())
uiHydrated = timeRendererStartupSyncStep('hydrate-persisted-ui', () =>
hydratePersistedUIAfterStartupRead({
persistedUI,
cancelled,
hydratePersistedUI: actions.hydratePersistedUI
})
)
// Why: list-runtime-session-hosts reads no repo state, so overlap it with the repo scan
// instead of paying its IPC round-trip serially before repos. .catch marks rejections handled
// if an earlier await throws first; the value is awaited below and surfaces any error there.
const runtimeHostsPromise = timeRendererStartupStep(
'list-runtime-session-hosts',
listRuntimeSessionHostIdsForStartup
)
runtimeHostsPromise.catch(() => {})
// Why: saved remote runtimes can spend the full connect timeout; load only the local catalog for first paint and refresh remotes after hydration.
await timeRendererStartupStep('fetch-repos-local', () =>
actions.fetchReposForAllHosts({ remoteHosts: 'skip' })
)
await timeRendererStartupStep('repo-catalog-settlement', () =>
actions.awaitLocalRepoCatalogSettlement()
)
// Why: folder workspaces merge against projectGroups (repos.ts fetchFolderWorkspacesForAllHosts),
// so keep this chain ordered while overlapping it with session-scoped hydration.
const localCatalogChain = (async () => {
await timeRendererStartupStep('fetch-project-groups-local', () =>
actions.fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' })
)
await timeRendererStartupStep('fetch-folder-workspaces-local', () =>
actions.fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' })
)
})()
const sessionReadPromise = runtimeHostsPromise.then((startupRuntimeHostIds) =>
// Why: include saved runtime host ids so per-host worktree session slices restore from local settings without waiting on network reachability; unreadable partitions skip.
timeRendererStartupStep('session-get', () =>
fetchWorkspaceSessionWithRuntimeHostOwners(
window.api.session,
useAppStore.getState().repos,
startupRuntimeHostIds
)
)
)
const hydrationSessionChain = sessionReadPromise.then(async (sessionRead) => {
const hydrationRepoIds = collectWorktreeHydrationRepoIdsFromSession(
sessionRead.session,
sessionRead.runtimeHostIdByWorkspaceSessionKey
)
const hydrationRepoIdSet = new Set(hydrationRepoIds)
const hydrationRepos = useAppStore.getState().repos.filter(
(repo) =>
hydrationRepoIdSet.has(repo.id) &&
// Why: disconnected SSH repos hydrate from local metadata; only runtime-owned repos use placeholders.
parseExecutionHostId(getRepoExecutionHostId(repo))?.kind !== 'runtime'
)
// Why: worktree refresh can spawn host Git; wait for main's shell-PATH generation fence first.
await timeRendererStartupStep('first-window-services-await', () =>
window.api.app.awaitFirstWindowStartupServices()
)
await timeRendererStartupStep('fetch-hydration-worktrees', () =>
mapWithConcurrency(hydrationRepos, WORKTREE_REFRESH_CONCURRENCY, (repo) =>
actions.fetchWorktrees(repo.id, { executionHostId: getRepoExecutionHostId(repo) })
)
)
return sessionRead
})
// Why: wait for both writers to settle before recovery so neither can mutate hydrated state afterward.
const [sessionOutcome, catalogOutcome] = await Promise.allSettled([
hydrationSessionChain,
localCatalogChain
])
if (sessionOutcome.status === 'rejected') {
throw sessionOutcome.reason
}
if (catalogOutcome.status === 'rejected') {
throw catalogOutcome.reason
}
const sessionRead = sessionOutcome.value
await keybindingsPromise
await timeRendererStartupStep('repo-catalog-final-settlement', () =>
actions.awaitLocalRepoCatalogSettlement()
)
if (!cancelled) {
const sessionHydrationOptions = {
additionalValidWorkspaceKeys: collectFolderWorkspaceKeysFromSession(sessionRead.session)
}
timeRendererStartupSyncStep('hydrate-session-stores', () => {
actions.hydrateWorkspaceSession(sessionRead.session, {
...sessionHydrationOptions,
runtimeHostIdByWorkspaceSessionKey: sessionRead.runtimeHostIdByWorkspaceSessionKey
})
actions.hydrateTabsSession(sessionRead.session, sessionHydrationOptions)
actions.hydrateEditorSession(sessionRead.session, sessionHydrationOptions)
actions.hydrateBrowserSession(sessionRead.session, sessionHydrationOptions)
})
// Why: prune visit timestamps AFTER hydration (earlier, worktreesByRepo may be empty and prune would drop entries for worktrees about to appear); seed the active worktree if missing.
// See docs/cmd-j-empty-query-ordering.md.
timeRendererStartupSyncStep('visit-timestamp-prune', () => {
actions.pruneLastVisitedTimestamps()
actions.seedActiveWorktreeLastVisitedIfMissing()
})
await timeRendererStartupStep('fetch-browser-session-profiles', () =>
actions.fetchBrowserSessionProfiles()
)
const onboardingState = await onboardingPromise
if (!cancelled) {
onOnboardingLoadedRef.current(onboardingState)
}
// Why: re-establish SSH before terminal reconnect so SSH-backed tabs route through pty.attach; passphrase targets defer to tab focus to avoid stacked credential dialogs.
// Why: never dial runtime-owned (ephemeral-VM) targets from the renderer — ssh.connect would dispose the runtime layer's live relay session; filter them out here too.
const connectionIds = (sessionRead.session.activeConnectionIdsAtShutdown ?? []).filter(
(targetId) => !isRuntimeOwnedSshTargetId(targetId)
)
if (connectionIds.length > 0) {
try {
await restoreSshConnectionsForStartup({
connectionIds,
setDeferredSshReconnectTargets: actions.setDeferredSshReconnectTargets,
publishSshConnectionState: actions.setSshConnectionState
})
} catch (err) {
console.warn('SSH startup reconnect failed:', err)
}
} else {
logRendererStartupDiagnostic('ssh-reconnect-skipped', { connectionIds: 0 })
}
// first-window-services-await already fenced worktree hydration; terminal recovery reuses that ready state.
await timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect', () =>
window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()
)
await timeRendererStartupStep('terminal-provider-snapshot-capabilities', () => {
return refreshTerminalProviderSnapshotCapabilities(
collectTerminalProviderSnapshotPtyIds(useAppStore.getState())
)
})
reconnectStarted = true
await timeRendererStartupStep('reconnect-terminals', () =>
actions.reconnectPersistedTerminals(abortController.signal)
)
await timeRendererStartupStep('recover-legacy-worker-terminals-post-reconnect', () =>
window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()
)
// Why here: reconnect just published restored PTY ids; sweeping them now
// re-offers stale Codex panes whose tabs never mount this session.
sweepRestoredCodexPanesForStaleAccounts(useAppStore.getState())
syncZoomCSSVar()
// Why (issue #1158): unlock the session writer only after hydration and all dependent steps succeeded, so a mid-startup throw can't serialize partially-mutated state to disk.
actions.setHydrationSucceeded(true)
logRendererStartupDiagnostic('startup-hydration-done', {
durationMs: Math.round(performance.now() - startupStartedAt)
})
void (async () => {
try {
try {
await timeRendererStartupStep('remote-catalog-refresh', async () => {
await actions.fetchReposForAllHosts()
await actions.fetchProjectGroupsForAllHosts()
await actions.fetchFolderWorkspacesForAllHosts()
})
} catch (err) {
console.warn('Remote startup catalog refresh failed:', err)
}
if (!cancelled) {
try {
await timeRendererStartupStep('remote-worktree-refresh', async () => {
// Why: the full scan is not required for session recovery, so keep it off the startup-critical path.
await actions.fetchAllWorktrees()
// Why: the startup prune only saw session-referenced repos; use the deferred scan's
// authoritative results to drop deleted-worktree visit timestamps that would
// otherwise accumulate unbounded (disconnected SSH stays non-authoritative and is kept).
actions.pruneLastVisitedTimestamps()
await actions.fetchWorktreeLineage()
})
} catch (err) {
console.warn('Deferred startup worktree refresh failed:', err)
}
}
} finally {
if (!cancelled) {
useAppStore.setState({ startupWorktreeRefreshCompleted: true })
}
}
})()
}
} catch (error) {
await recoverFromDegradedStartup({
error,
uiHydrated,
reconnectStarted,
isCancelled: () => cancelled,
hydratePersistedUI: actions.hydratePersistedUI,
reconnectPersistedTerminals: actions.reconnectPersistedTerminals,
abortSignal: abortController.signal
})
}
void actions.initGitHubCache()
})()
return () => {
cancelled = true
abortController.abort()
}
}, [actions])
}
@@ -0,0 +1,41 @@
import { useEffect } from 'react'
import { buildAppFontFamily } from '@/lib/app-font-family'
import { applyDocumentTheme } from '../lib/document-theme'
import { scheduleRuntimeGraphSync } from '../runtime/sync-runtime-graph'
import { useAppStore } from '../store'
/** Applies the settings-driven theme and app font to the document root. */
export function useDocumentAppearance(): void {
const settings = useAppStore((s) => s.settings)
useEffect(() => {
if (!settings) {
return
}
if (settings.theme === 'dark') {
applyDocumentTheme('dark')
return undefined
} else if (settings.theme === 'light') {
applyDocumentTheme('light')
return undefined
}
// system
const mq = window.matchMedia('(prefers-color-scheme: dark)')
applyDocumentTheme('system')
const handler = (): void => {
applyDocumentTheme('system')
// System theme changes don't mutate the store, so mobile terminal colors need an explicit graph republish.
scheduleRuntimeGraphSync()
}
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [settings])
useEffect(() => {
document.documentElement.style.setProperty(
'--app-font-family',
buildAppFontFamily(settings?.appFontFamily)
)
}, [settings?.appFontFamily])
}
@@ -0,0 +1,145 @@
import { useCallback, useEffect, useRef, useState, type SetStateAction } from 'react'
import {
TOGGLE_FLOATING_TERMINAL_EVENT,
requestFloatingTerminalOpenMaximized
} from '@/lib/floating-terminal'
import { createFloatingWorkspaceTourInteractionSnapshot } from '@/lib/floating-workspace-tour-interaction-snapshot'
import {
persistFloatingTerminalPanelOpen,
readPersistedFloatingTerminalPanelViewState
} from '../components/floating-terminal/floating-terminal-panel-view-state'
import { useAppStore } from '../store'
import { selectFloatingVisibleTabCount } from '../store/selectors'
export type FloatingWorkspacePanelState = ReturnType<typeof useFloatingWorkspacePanel>
/**
* Owns the floating workspace overlay's open state: persistence, the focus it hands back on
* close, and the toggle/disable paths that can flip it from outside React.
*/
export function useFloatingWorkspacePanel() {
// Why restored: leaving the panel closed forces the user to reopen and re-maximize it,
// and that size jump reflows a live TUI's buffer (see floating-terminal-panel-view-state).
const [open, setOpen] = useState(
() => readPersistedFloatingTerminalPanelViewState()?.open === true
)
const tourInteractionSnapshotRef = useRef<{
wasPreviouslyInteracted?: boolean
persisted?: Promise<void>
recordFeatureInteractionForTour: boolean
} | null>(null)
const enabled = useAppStore((s) => s.settings?.floatingTerminalEnabled === true)
// Why tracked separately: the flag reads false while settings are still loading, and a
// false read at boot must not be treated as the user disabling the feature. The store
// initializes `settings` to null (not undefined) - fetchSettings replaces it atomically.
const settingsHydrated = useAppStore((s) => s.settings != null)
const triggerLocation = useAppStore(
(s) => s.settings?.floatingTerminalTriggerLocation ?? 'floating-button'
)
const statusBarVisible = useAppStore((s) => s.statusBarVisible)
const visibleTabCount = useAppStore(selectFloatingVisibleTabCount)
// Why: floating workspace is a transient overlay; hotkey minimize returns focus to the surface the user came from.
const returnFocusRef = useRef<HTMLElement | null>(null)
const returnFocusFrameRef = useRef<number | null>(null)
const cancelReturnFocusFrame = useCallback((): void => {
if (returnFocusFrameRef.current === null) {
return
}
cancelAnimationFrame(returnFocusFrameRef.current)
returnFocusFrameRef.current = null
}, [])
const rememberReturnFocus = useCallback((): void => {
const active = document.activeElement
if (!(active instanceof HTMLElement)) {
returnFocusRef.current = null
return
}
if (
active.closest('[data-floating-terminal-panel]') ||
active.closest('[data-floating-terminal-toggle]')
) {
return
}
returnFocusRef.current = active
}, [])
const restoreReturnFocus = useCallback((): void => {
const target = returnFocusRef.current
returnFocusRef.current = null
if (!target || !document.contains(target)) {
return
}
cancelReturnFocusFrame()
returnFocusFrameRef.current = requestAnimationFrame(() => {
returnFocusFrameRef.current = null
if (!document.contains(target)) {
return
}
target.focus({ preventScroll: true })
})
}, [cancelReturnFocusFrame])
const setOpenWithFocus = useCallback(
(nextOpen: SetStateAction<boolean>): void => {
const resolvedOpen = typeof nextOpen === 'function' ? nextOpen(open) : nextOpen
// Why: recordFeatureInteraction updates Zustand subscribers; running it inside the state updater logs a render-phase update warning.
if (resolvedOpen && !open) {
tourInteractionSnapshotRef.current = createFloatingWorkspaceTourInteractionSnapshot(
useAppStore.getState()
)
rememberReturnFocus()
} else if (!resolvedOpen && open) {
restoreReturnFocus()
}
setOpen(resolvedOpen)
// Why gated on the flag: `settings` is undefined until it hydrates, so the
// feature-off effect force-closes the panel on every boot. Persisting there would
// overwrite the user's restored `open` with a value they never chose.
if (enabled) {
persistFloatingTerminalPanelOpen(resolvedOpen)
}
},
[enabled, open, rememberReturnFocus, restoreReturnFocus]
)
const openMaximized = useCallback((): void => {
requestFloatingTerminalOpenMaximized()
setOpenWithFocus(true)
}, [setOpenWithFocus])
useEffect(() => {
const toggleFloatingTerminal = (): void => {
if (enabled) {
setOpenWithFocus((current) => !current)
}
}
window.addEventListener(TOGGLE_FLOATING_TERMINAL_EVENT, toggleFloatingTerminal)
return () => window.removeEventListener(TOGGLE_FLOATING_TERMINAL_EVENT, toggleFloatingTerminal)
}, [enabled, setOpenWithFocus])
useEffect(() => {
// Why the hydration gate: this effect fires on every boot while settings are still
// undefined, and closing there discards the restored open state before the real flag
// value arrives. Only a hydrated flag-off is an actual disable.
if (settingsHydrated && !enabled) {
setOpenWithFocus(false)
}
}, [settingsHydrated, enabled, setOpenWithFocus])
return {
cancelReturnFocusFrame,
enabled,
open,
openMaximized,
setOpenWithFocus,
// Why: once the floating workspace owns tabs, keep it mounted while closed so hidden terminal/browser/editor panes retain local state.
shouldMountPanel: enabled && (open || visibleTabCount > 0),
showToggleButton: enabled && (triggerLocation === 'floating-button' || !statusBarVisible),
tourInteractionSnapshotRef,
visibleTabCount
}
}
@@ -0,0 +1,322 @@
import { useEffect, useLayoutEffect, useRef } from 'react'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { canShowRightSidebarForView } from '@/lib/right-sidebar-visibility'
import { isEditableTarget } from '../lib/editable-target'
import { getSelectedTextForFileSearch } from '../lib/file-search-selection'
import { registerAppCommandDispatcher } from '@/lib/app-command-dispatch'
import { executePluginCommand } from '@/lib/plugin-command-execution'
import { findPluginCommandForKeybinding } from '@/lib/plugin-command-keybindings'
import {
isFloatingWorkspacePanelFocused,
isFloatingWorkspaceTerminalInputTarget,
matchFloatingWorkspacePanelChord,
shouldMinimizeFloatingWorkspacePanelOnCloseShortcut
} from '@/lib/floating-workspace-terminal-actions'
import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification'
import {
folderRelativePathToIncludeGlob,
selectedExplorerFolderRelativePath
} from '../components/right-sidebar/file-search-include-pattern'
import { usePluginCommands } from '@/store/plugin-panels'
import { useAppStore } from '../store'
import {
keybindingMatchesAction,
type KeybindingActionId,
type KeybindingMatchOptions
} from '../../../shared/keybindings'
import { PLUGIN_COMMAND_ALIAS_ACTION_IDS } from '../../../shared/plugins/plugin-command-actions'
import {
ModifierDoubleTapDetector,
toModifierDoubleTapEvent
} from '../../../shared/modifier-double-tap-detector'
import { shortcutPlatform } from './app-window-chrome'
import {
createAppCommandHandlers,
getKeybindingContext,
useAppShortcutActions,
type AppShortcutState,
type ShortcutDispatchInput
} from './app-command-handlers'
import type { AppChromeLayout } from './use-app-chrome-layout'
import type { FloatingWorkspacePanelState } from './use-floating-workspace-panel'
/**
* Registers the window-level shortcut listeners and the app command dispatcher.
*
* Window key listeners are global and long-lived: one registration, but the handler reads
* current shortcut state each key event through a ref.
*/
export function useGlobalKeybindings(args: {
layout: AppChromeLayout
floatingWorkspace: FloatingWorkspacePanelState
}): void {
const { layout, floatingWorkspace } = args
const actions = useAppShortcutActions()
const keybindings = useAppStore((s) => s.keybindings)
const terminalShortcutPolicy = useAppStore((s) => s.settings?.terminalShortcutPolicy)
const pluginCommands = usePluginCommands()
const shortcutState: AppShortcutState = {
activeView: layout.activeView,
activeWorktreeId: layout.activeWorktreeId,
actions,
creationLayoutActive: layout.creationLayoutActive,
floatingTerminalEnabled: floatingWorkspace.enabled,
floatingTerminalOpen: floatingWorkspace.open,
floatingVisibleTabCount: floatingWorkspace.visibleTabCount,
keybindings,
openFloatingWorkspaceMaximized: floatingWorkspace.openMaximized,
pluginCommands,
setFloatingTerminalOpen: floatingWorkspace.setOpenWithFocus,
terminalShortcutPolicy,
workspaceChromeActive: layout.workspaceChromeActive
}
const shortcutStateRef = useRef(shortcutState)
// Why useLayoutEffect: the mirror must be current before any key event can read it, and a
// render-phase write would also publish state from a render React discards. Key events are
// discrete, so they always observe the committed value.
useLayoutEffect(() => {
shortcutStateRef.current = shortcutState
})
useEffect(() => {
const doubleTapDetector = new ModifierDoubleTapDetector()
const unregisterAppCommandDispatcher = registerAppCommandDispatcher((actionId) =>
(createAppCommandHandlers(shortcutStateRef.current).get(actionId) ?? (() => false))()
)
const dispatchShortcutInput = (input: ShortcutDispatchInput): void => {
const state = shortcutStateRef.current
const {
activeView,
activeWorktreeId,
actions,
creationLayoutActive,
floatingTerminalEnabled,
floatingTerminalOpen,
floatingVisibleTabCount,
keybindings,
openFloatingWorkspaceMaximized,
pluginCommands,
setFloatingTerminalOpen,
terminalShortcutPolicy
} = state
// Child handlers (e.g. terminal search) share this window capture phase and fire first; bail if they already preventDefault'd so both don't act.
if (input.defaultPrevented) {
return
}
// The Settings shortcut recorder captures existing shortcuts, so global handlers must not fire while its button has focus.
if (
input.target instanceof Element &&
input.target.closest('[data-shortcut-recorder-active]') !== null
) {
return
}
const context = getKeybindingContext(input.target)
// Note: some shortcuts are also intercepted in createMainWindow.ts before-input-event (for browser-guest focus); the renderer keeps handlers for local focus.
const matchShortcut = (actionId: KeybindingActionId): boolean =>
keybindingMatchesAction(actionId, input, shortcutPlatform, keybindings, {
context,
terminalShortcutPolicy
})
const notifyTerminalCapture = (actionId: KeybindingActionId): void => {
if (context !== 'terminal' || (terminalShortcutPolicy ?? 'orca-first') !== 'orca-first') {
return
}
showTerminalShortcutCaptureNotification({
actionId,
platform: shortcutPlatform,
keybindings
})
}
const canRevealRightSidebar = !creationLayoutActive && canShowRightSidebarForView(activeView)
if (matchShortcut('sidebar.search.toggle') && canRevealRightSidebar) {
// With a folder selected in the explorer, Cmd/Ctrl+Shift+F means "Find in Folder" — seed the include pattern with it, not a text search.
const selectedFolderRelativePath =
document.activeElement instanceof Element
? selectedExplorerFolderRelativePath(document.activeElement)
: null
if (selectedFolderRelativePath !== null && activeWorktreeId) {
input.preventDefault()
notifyTerminalCapture('sidebar.search.toggle')
actions.showRightSidebarSearch({
includePattern: folderRelativePathToIncludeGlob(selectedFolderRelativePath)
})
return
}
const selectedText = getSelectedTextForFileSearch()
if (selectedText) {
input.preventDefault()
notifyTerminalCapture('sidebar.search.toggle')
actions.showRightSidebarSearch({ query: selectedText })
return
}
}
// An empty floating workspace has no tab to close, so Cmd/Ctrl+W hides the overlay before other surfaces act.
if (
keybindingMatchesAction('tab.close', input, shortcutPlatform, keybindings, {
context: 'app'
}) &&
shouldMinimizeFloatingWorkspacePanelOnCloseShortcut({
floatingTerminalOpen,
floatingVisibleTabCount
})
) {
input.preventDefault()
setFloatingTerminalOpen(false)
return
}
// Floating panel closed → its keydown handler is gone, so honor the maximize chord here by opening it pre-maximized (no-op while it's open).
if (
!floatingTerminalOpen &&
matchShortcut('floatingWorkspace.maximize') &&
floatingTerminalEnabled
) {
input.preventDefault()
openFloatingWorkspaceMaximized()
return
}
// Skip editable surfaces so TipTap's Cmd+B bold works; this renderer-side fallback covers the blur→press IPC race (docs/markdown-cmd-b-bold-design.md).
if (isEditableTarget(input.target)) {
return
}
// Let floating-terminal SSH/tmux control chords reach the terminal (xterm's helper textarea isn't a generic editable target).
if (isFloatingWorkspaceTerminalInputTarget(input.target)) {
return
}
// Only short-circuit chords the floating panel itself claims; suppressing others here would silently no-op them when focus is in the panel.
if (isFloatingWorkspacePanelFocused()) {
const floatingMatchOptions: KeybindingMatchOptions = { context, terminalShortcutPolicy }
if (
matchFloatingWorkspacePanelChord(
input,
shortcutPlatform,
null,
keybindings,
floatingMatchOptions
) !== null
) {
return
}
}
// Plugin chords are user-reviewed instructional content. They win over
// built-in defaults only in app focus; terminal/editor/browser handlers
// retain their own shortcut authority.
if (context === 'app') {
const pluginCommand = findPluginCommandForKeybinding(
pluginCommands,
input,
shortcutPlatform,
keybindings,
Boolean(activeWorktreeId)
)
if (pluginCommand) {
input.preventDefault()
void executePluginCommand(pluginCommand, 'plugin-keybinding').catch(() => {
toast.error(
translate('auto.App.pluginCommandFailed', 'Could not run the plugin command.')
)
})
return
}
}
const handlers = createAppCommandHandlers(state, input, context)
for (const actionId of PLUGIN_COMMAND_ALIAS_ACTION_IDS) {
if (matchShortcut(actionId) && handlers.get(actionId)?.()) {
return
}
}
// Unbound by default, so it runs after the built-in alias handlers above; only consumes the chord when the active worktree has unsent notes.
if (canRevealRightSidebar && matchShortcut('sourceControl.sendReviewNotes')) {
if (actions.openDiffNotesSendMenuForActiveWorktree()) {
input.preventDefault()
notifyTerminalCapture('sourceControl.sendReviewNotes')
}
}
}
const onKeyDown = (e: KeyboardEvent): void => {
const detected = doubleTapDetector.process(
toModifierDoubleTapEvent({
type: 'keyDown',
code: e.code,
key: e.key,
shift: e.shiftKey,
control: e.ctrlKey,
alt: e.altKey,
meta: e.metaKey,
isAutoRepeat: e.repeat
}),
Date.now()
)
if (e.repeat) {
return
}
if (detected) {
// Synthetic input: no key/modifier flags, so only DoubleTap bindings match.
dispatchShortcutInput({
doubleTapModifier: detected.modifier,
target: e.target,
defaultPrevented: e.defaultPrevented,
preventDefault: () => e.preventDefault()
})
return
}
dispatchShortcutInput({
key: e.key,
code: e.code,
altKey: e.altKey,
metaKey: e.metaKey,
ctrlKey: e.ctrlKey,
shiftKey: e.shiftKey,
target: e.target,
defaultPrevented: e.defaultPrevented,
preventDefault: () => e.preventDefault()
})
}
const onKeyUp = (e: KeyboardEvent): void => {
doubleTapDetector.process(
toModifierDoubleTapEvent({
type: 'keyUp',
code: e.code,
key: e.key,
shift: e.shiftKey,
control: e.ctrlKey,
alt: e.altKey,
meta: e.metaKey
}),
Date.now()
)
}
// Why: a window blur mid-gesture must not leave the detector armed.
const onBlur = (): void => doubleTapDetector.reset()
window.addEventListener('keydown', onKeyDown, { capture: true })
window.addEventListener('keyup', onKeyUp, { capture: true })
window.addEventListener('blur', onBlur)
return () => {
unregisterAppCommandDispatcher()
window.removeEventListener('keydown', onKeyDown, { capture: true })
window.removeEventListener('keyup', onKeyUp, { capture: true })
window.removeEventListener('blur', onBlur)
}
}, [])
}
@@ -0,0 +1,48 @@
import { useEffect, useRef, useState } from 'react'
import { resolveMountedLazyModalIds, type LazyModalId } from '../lazy-modal-mount-state'
import { useAppStore } from '../store'
/**
* Tracks which lazy modal chunks are mounted. Modals load on first use and then stay mounted
* so repeat opens preserve state and avoid re-fetch flashes.
*/
export function useLazyModalMounts(): {
mountedLazyModalIds: ReadonlySet<LazyModalId>
shouldMountAddRepoDialog: boolean
} {
const activeModal = useAppStore((s) => s.activeModal)
const [mountedLazyModalIds, setMountedLazyModalIds] = useState<Set<LazyModalId>>(() => new Set())
const [shouldMountAddRepoDialog, setShouldMountAddRepoDialog] = useState(false)
const unmountAddRepoDialogTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (activeModal === 'add-repo') {
if (unmountAddRepoDialogTimerRef.current) {
clearTimeout(unmountAddRepoDialogTimerRef.current)
unmountAddRepoDialogTimerRef.current = null
}
setShouldMountAddRepoDialog(true)
return
}
if (shouldMountAddRepoDialog && !unmountAddRepoDialogTimerRef.current) {
// Why: AddRepoDialog's close effect aborts in-flight clone work; keep one closed render before unmounting hidden SSH/remote subscriptions.
unmountAddRepoDialogTimerRef.current = setTimeout(() => {
setShouldMountAddRepoDialog(false)
unmountAddRepoDialogTimerRef.current = null
}, 0)
}
return () => {
if (unmountAddRepoDialogTimerRef.current) {
clearTimeout(unmountAddRepoDialogTimerRef.current)
unmountAddRepoDialogTimerRef.current = null
}
}
}, [activeModal, shouldMountAddRepoDialog])
const resolvedMountedLazyModalIds = resolveMountedLazyModalIds(activeModal, mountedLazyModalIds)
if (resolvedMountedLazyModalIds !== mountedLazyModalIds) {
setMountedLazyModalIds(new Set(resolvedMountedLazyModalIds))
}
return { mountedLazyModalIds: resolvedMountedLazyModalIds, shouldMountAddRepoDialog }
}
@@ -0,0 +1,141 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { onOnboardingReopened } from '../components/onboarding/show-onboarding-event'
import { shouldShowOnboarding } from '../components/onboarding/should-show-onboarding'
import {
getFeatureTipsAppOpenDecision,
isCliFeatureTipCompleted
} from '../components/feature-tips/feature-tip-startup-gate'
import {
trackCmdJPaletteFeatureTipShown,
trackOrcaCliFeatureTipShown
} from '../components/feature-tips/feature-tip-telemetry'
import { useAppStore } from '../store'
import type { OnboardingState } from '../../../shared/onboarding-state-types'
export type OnboardingGate = ReturnType<typeof useOnboardingAndFeatureTips>
/**
* Owns first-run education: the onboarding flow's visibility plus the one-per-session
* feature-tip prompt, which stays suppressed until onboarding's state is known.
*/
export function useOnboardingAndFeatureTips() {
const [onboarding, setOnboarding] = useState<OnboardingState | null>(null)
const [onboardingLoaded, setOnboardingLoaded] = useState(false)
const [featureTipCliInstalled, setFeatureTipCliInstalled] = useState<boolean | null>(null)
const promptedThisSessionRef = useRef(false)
const suppressedByOnboardingThisSessionRef = useRef(false)
const activeModal = useAppStore((s) => s.activeModal)
const settings = useAppStore((s) => s.settings)
const persistedUIReady = useAppStore((s) => s.persistedUIReady)
const featureTipsSeenIds = useAppStore((s) => s.featureTipsSeenIds)
const featureInteractions = useAppStore((s) => s.featureInteractions)
const contextualToursAutoEligible = useAppStore((s) => s.contextualToursAutoEligible)
const actions = useAppStore(
useShallow((s) => ({
openModal: s.openModal,
markFeatureTipsSeen: s.markFeatureTipsSeen,
setContextualToursAutoEligible: s.setContextualToursAutoEligible,
setContextualToursOnboardingVisible: s.setContextualToursOnboardingVisible
}))
)
const applyStartupOnboardingState = useCallback((state: OnboardingState): void => {
setOnboarding(state)
setOnboardingLoaded(true)
}, [])
useEffect(() => {
return onOnboardingReopened(setOnboarding)
}, [])
useEffect(() => {
// Why: suppress tours until onboarding state is known (null = loading) so a first-run user can't mark a tour seen before onboarding appears.
const suppressTours = !onboardingLoaded || shouldShowOnboarding(onboarding)
actions.setContextualToursOnboardingVisible(suppressTours)
}, [actions, onboarding, onboardingLoaded])
useEffect(() => {
if (!persistedUIReady || !onboardingLoaded || contextualToursAutoEligible !== null) {
return
}
// Why: rollout targets first-run onboarding users; existing profiles are classified once and never auto-toured.
actions.setContextualToursAutoEligible(shouldShowOnboarding(onboarding))
}, [actions, contextualToursAutoEligible, onboarding, onboardingLoaded, persistedUIReady])
useEffect(() => {
if (!persistedUIReady) {
return
}
let cancelled = false
void window.api.cli
.getInstallStatus()
.then((status) => {
if (cancelled) {
return
}
setFeatureTipCliInstalled(isCliFeatureTipCompleted(status))
})
.catch(() => {
if (!cancelled) {
setFeatureTipCliInstalled(true)
}
})
return () => {
cancelled = true
}
}, [persistedUIReady])
useEffect(() => {
const featureTipsDecision = getFeatureTipsAppOpenDecision({
activeModal,
cliInstalled: featureTipCliInstalled,
featureTipsSeenIds,
featureInteractions,
onboarding,
persistedUIReady,
promptedThisSession: promptedThisSessionRef.current,
settings,
suppressedByOnboardingThisSession: suppressedByOnboardingThisSessionRef.current
})
if (featureTipsDecision.kind === 'suppress-for-onboarding') {
// Why: first-run users should finish onboarding without a second education modal in the same session.
suppressedByOnboardingThisSessionRef.current = true
return
}
if (featureTipsDecision.kind !== 'open') {
return
}
promptedThisSessionRef.current = true
if (featureTipsDecision.tipId === 'orca-cli') {
trackOrcaCliFeatureTipShown('app_open')
} else if (featureTipsDecision.tipId === 'cmd-j-palette') {
trackCmdJPaletteFeatureTipShown('app_open')
}
// Why: mark seen on show so a quit/crash before dismiss doesn't reappear it next launch.
actions.markFeatureTipsSeen([featureTipsDecision.tipId])
actions.openModal('feature-tips', { source: 'app_open', tipId: featureTipsDecision.tipId })
}, [
activeModal,
actions,
featureTipCliInstalled,
featureInteractions,
featureTipsSeenIds,
onboarding,
persistedUIReady,
settings
])
return {
applyStartupOnboardingState,
onboarding,
setOnboarding,
shouldRender: onboarding !== null && shouldShowOnboarding(onboarding)
}
}
@@ -0,0 +1,70 @@
import { useEffect } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '../store'
/**
* Mirrors the sidebar/right-sidebar/filter preferences into the durable UI file.
*
* Why (#9002): activeView is deliberately kept off this debounced writer. It used to ride the
* same 150ms save (#8265), so every top-level view switch scheduled a full durable-state write.
*/
export function usePersistedUIWriter(): void {
const persistedUIReady = useAppStore((s) => s.persistedUIReady)
const activeView = useAppStore((s) => s.activeView)
const ui = useAppStore(
useShallow((s) => ({
sidebarWidth: s.sidebarWidth,
rightSidebarOpen: s.rightSidebarOpen,
rightSidebarTab: s.rightSidebarTab,
rightSidebarExplorerView: s.rightSidebarExplorerView,
rightSidebarWidth: s.rightSidebarWidth,
markdownTocPanelWidth: s.markdownTocPanelWidth,
combinedDiffFileTreeWidth: s.combinedDiffFileTreeWidth,
groupBy: s.groupBy,
sortBy: s.sortBy,
projectOrderBy: s.projectOrderBy,
showSleepingWorkspaces: s.showSleepingWorkspaces,
hideDefaultBranchWorkspace: s.hideDefaultBranchWorkspace,
hideAutomationGeneratedWorkspaces: s.hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces: s.hideCliCreatedWorkspaces,
hideDetachedHeadWorkspaces: s.hideDetachedHeadWorkspaces,
hideWorkspacesFromOtherDevices: s.hideWorkspacesFromOtherDevices,
alwaysShowDefaultBranchWorkspace: s.alwaysShowDefaultBranchWorkspace,
showDotfilesByWorktree: s.showDotfilesByWorktree,
filterRepoIds: s.filterRepoIds,
// Why: rides the same debounced save so dashboard auto-acks (which fire on focus/
// visibility) and the in-memory ack cleanup paths in agent-status.ts (close/dismiss)
// both flow to disk through map identity changes. Without persisting, agent rows that
// survive restart come back bold even when the user had already visited them.
acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey
}))
)
useEffect(() => {
if (!persistedUIReady) {
return
}
const timer = window.setTimeout(() => {
void window.api.ui.set({
...ui,
showActiveOnly: false,
hideSleepingWorkspaces: !ui.showSleepingWorkspaces,
// Why: the store keeps this readonly for identity stability, but PersistedUI crosses to
// main, which owns a mutable array — copy at the boundary rather than widening the wire type.
filterRepoIds: [...ui.filterRepoIds]
})
}, 150)
return () => window.clearTimeout(timer)
}, [persistedUIReady, ui])
// Why (#9002): activeView has its own tiny profile preference, so it can track
// every switch without scheduling the multi-MB durable-state writer.
useEffect(() => {
if (!persistedUIReady) {
return
}
void window.api.ui.set({ activeView })
}, [activeView, persistedUIReady])
}
@@ -0,0 +1,60 @@
import { useEffect } from 'react'
import { getSystemPrefersDarkSnapshot } from '../components/terminal-pane/use-system-prefers-dark'
import {
canSkipRuntimeMobileSessionSyncKeyBuild,
getRuntimeMobileSessionSyncKey,
runtimeMobileSessionSyncKeysEqual,
scheduleRuntimeGraphSync,
setRuntimeGraphStoreStateGetter,
setRuntimeGraphSyncEnabled
} from '../runtime/sync-runtime-graph'
import { useAppStore } from '../store'
/** Keeps the mobile runtime graph republished as the store's session-visible state changes. */
export function useRuntimeGraphSync(): void {
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
useEffect(() => {
setRuntimeGraphStoreStateGetter(useAppStore.getState)
return () => {
setRuntimeGraphStoreStateGetter(null)
}
}, [])
useEffect(() => {
let previousKey = getRuntimeMobileSessionSyncKey(useAppStore.getState())
return useAppStore.subscribe((state, previousState) => {
// Why: this fires on every store mutation; read the cached prefers-dark snapshot instead of allocating a throwaway MediaQueryList via matchMedia each tick.
const systemPrefersDark = getSystemPrefersDarkSnapshot()
// Why: skip the key build when every input is reference-unchanged; the gate mirrors every field getRuntimeMobileSessionSyncKey uses.
if (
canSkipRuntimeMobileSessionSyncKeyBuild(
state,
previousState,
systemPrefersDark,
previousKey.systemPrefersDark
)
) {
return
}
const nextKey = getRuntimeMobileSessionSyncKey(
state,
previousState,
previousKey,
systemPrefersDark
)
if (runtimeMobileSessionSyncKeysEqual(nextKey, previousKey)) {
return
}
previousKey = nextKey
scheduleRuntimeGraphSync()
})
}, [])
useEffect(() => {
setRuntimeGraphSyncEnabled(workspaceSessionReady)
return () => {
setRuntimeGraphSyncEnabled(false)
}
}, [workspaceSessionReady])
}
@@ -0,0 +1,48 @@
import { useEffect } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome'
import { useAppStore } from '../store'
import { isMac } from './app-window-chrome'
/** Window-visibility reactions that must run app-wide, not per-surface. */
export function useWindowVisibilityEffects(): void {
const actions = useAppStore(
useShallow((s) => ({
refreshAllGitHub: s.refreshAllGitHub,
reportVisibleGitHubPRRefreshCandidates: s.reportVisibleGitHubPRRefreshCandidates,
bumpGitHubPRVisibleRefreshGeneration: s.bumpGitHubPRVisibleRefreshGeneration
}))
)
// Refresh GitHub data (PR/issue status) when window regains focus
useEffect(() => {
const handler = (): void => {
if (document.visibilityState === 'visible') {
actions.refreshAllGitHub()
actions.bumpGitHubPRVisibleRefreshGeneration()
} else {
actions.reportVisibleGitHubPRRefreshCandidates([], Date.now())
}
}
document.addEventListener('visibilitychange', handler)
return () => document.removeEventListener('visibilitychange', handler)
}, [actions])
// Why (STA-2383): macOS throttles the backgrounded window; on occlusion-uncover only `focus`
// fires (invalidate-only), so the app-shell's dvh height stays stale and the bottom status bar
// is clipped off-screen until a manual resize. Relay the genuine hidden→visible reveal so main
// runs the same full repaint (size jiggle) that show/restore/resume get, recomputing the layout.
useEffect(() => {
if (!isMac || isPairedWebClientWindow()) {
return
}
const handler = (): void => {
if (document.visibilityState !== 'visible') {
return
}
window.api?.ui?.notifyWindowRevealed?.()
}
document.addEventListener('visibilitychange', handler)
return () => document.removeEventListener('visibilitychange', handler)
}, [])
}
+125 -115
View File
@@ -2,9 +2,25 @@ import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
function readSource(relativePath: string): string {
return readFileSync(join(process.cwd(), relativePath), 'utf8')
}
const APP_PATH = 'src/renderer/src/App.tsx'
const STARTUP_HYDRATION_PATH = 'src/renderer/src/app-shell/use-app-startup-hydration.ts'
const DEGRADED_RECOVERY_PATH = 'src/renderer/src/startup/startup-degraded-recovery.ts'
const CHROME_LAYOUT_PATH = 'src/renderer/src/app-shell/use-app-chrome-layout.ts'
const SHELL_SERVICES_PATH = 'src/renderer/src/app-shell/use-app-shell-services.ts'
const BACKGROUND_SERVICES_PATH = 'src/renderer/src/app-shell/AppBackgroundServices.tsx'
const WORKSPACE_SHELL_PATH = 'src/renderer/src/app-shell/AppWorkspaceShell.tsx'
const ROOT_SURFACES_PATH = 'src/renderer/src/app-shell/AppRootSurfaces.tsx'
const LAZY_MODAL_MOUNTS_PATH = 'src/renderer/src/app-shell/use-lazy-modal-mounts.ts'
const SESSION_PERSISTENCE_PATH = 'src/renderer/src/app-shell/use-app-session-persistence.ts'
const PERSISTED_UI_WRITER_PATH = 'src/renderer/src/app-shell/use-persisted-ui-writer.ts'
describe('renderer startup runtime routing', () => {
it('hydrates persisted UI before local catalog and worktree hydration', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const source = readSource(STARTUP_HYDRATION_PATH)
const startupBlockStart = source.indexOf('void (async () => {')
// Why: concurrent startup branches all settle before hydrate-session-stores.
const startupBlockEnd = source.indexOf("timeRendererStartupSyncStep('hydrate-session-stores'")
@@ -99,7 +115,7 @@ describe('renderer startup runtime routing', () => {
})
it('refreshes remote catalogs after startup hydration succeeds', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const source = readSource(STARTUP_HYDRATION_PATH)
const hydrationDoneIndex = source.indexOf(
"logRendererStartupDiagnostic('startup-hydration-done'"
)
@@ -122,12 +138,13 @@ describe('renderer startup runtime routing', () => {
'actions.fetchReposForAllHosts()'
)
const startupFailureIndex = source.indexOf(
const degradedSource = readSource(DEGRADED_RECOVERY_PATH)
const startupFailureIndex = degradedSource.indexOf(
'[startup] Workspace session hydration failed; leaving disk state untouched:'
)
expect(startupFailureIndex).toBeGreaterThanOrEqual(0)
expect(
source.indexOf('startupWorktreeRefreshCompleted: true', startupFailureIndex)
degradedSource.indexOf('startupWorktreeRefreshCompleted: true', startupFailureIndex)
).toBeGreaterThan(startupFailureIndex)
expect(source.slice(remoteCatalogIndex, remoteWorktreeIndex)).toContain(
'actions.fetchProjectGroupsForAllHosts()'
@@ -138,23 +155,17 @@ describe('renderer startup runtime routing', () => {
})
it('waits for first-window startup services before terminal reconnect', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const servicesIndex = source.indexOf('await window.api.app.awaitFirstWindowStartupServices()')
const source = readSource(STARTUP_HYDRATION_PATH)
const servicesIndex = source.indexOf("timeRendererStartupStep('first-window-services-await'")
const preReconnectRecoveryIndex = source.indexOf(
'window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()',
servicesIndex
"timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect'"
)
const capabilityRefreshIndex = source.indexOf(
'refreshTerminalProviderSnapshotCapabilities(',
preReconnectRecoveryIndex
)
const reconnectIndex = source.indexOf(
'actions.reconnectPersistedTerminals(abortController.signal)',
preReconnectRecoveryIndex
"timeRendererStartupStep('terminal-provider-snapshot-capabilities'"
)
const reconnectIndex = source.indexOf("timeRendererStartupStep('reconnect-terminals'")
const postReconnectRecoveryIndex = source.indexOf(
'window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()',
reconnectIndex
"timeRendererStartupStep('recover-legacy-worker-terminals-post-reconnect'"
)
expect(servicesIndex).toBeGreaterThanOrEqual(0)
@@ -165,7 +176,7 @@ describe('renderer startup runtime routing', () => {
})
it('refreshes terminal snapshot capability before degraded reconnect', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const source = readSource(DEGRADED_RECOVERY_PATH)
const degradedStart = source.indexOf(
'[startup] Workspace session hydration failed; leaving disk state untouched:'
)
@@ -181,10 +192,7 @@ describe('renderer startup runtime routing', () => {
'refreshTerminalProviderSnapshotCapabilities(',
recoveryIndex
)
const reconnectIndex = source.indexOf(
'actions.reconnectPersistedTerminals(abortController.signal)',
recoveryIndex
)
const reconnectIndex = source.indexOf('reconnectPersistedTerminals(abortSignal)', recoveryIndex)
expect(degradedStart).toBeGreaterThanOrEqual(0)
expect(servicesIndex).toBeGreaterThan(degradedStart)
@@ -194,10 +202,7 @@ describe('renderer startup runtime routing', () => {
})
it('keeps the persisted Automations view from starting its own bootstrap worktree scan', () => {
const source = readFileSync(
join(process.cwd(), 'src/renderer/src/components/automations/AutomationsPage.tsx'),
'utf8'
)
const source = readSource('src/renderer/src/components/automations/AutomationsPage.tsx')
const fullRefreshStart = source.indexOf('const mountedBeforeStartupWorktreeRefreshRef')
const fullRefreshEffect = source.slice(
fullRefreshStart,
@@ -210,24 +215,29 @@ describe('renderer startup runtime routing', () => {
})
it('does not eagerly import the floating terminal panel on startup', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const shellSource = readSource(WORKSPACE_SHELL_PATH)
const surfacesSource = readSource(ROOT_SURFACES_PATH)
expect(source).toContain(
"import { FloatingTerminalToggleButton } from './components/floating-terminal/FloatingTerminalToggleButton'"
expect(shellSource).toContain(
"import { FloatingTerminalToggleButton } from '../components/floating-terminal/FloatingTerminalToggleButton'"
)
expect(source).toContain("import('./components/floating-terminal/FloatingTerminalPanel').then")
expect(source).not.toContain("from './components/floating-terminal/FloatingTerminalPanel'")
expect(surfacesSource).toContain(
"import('../components/floating-terminal/FloatingTerminalPanel').then"
)
for (const source of [shellSource, surfacesSource]) {
expect(source).not.toContain("from '../components/floating-terminal/FloatingTerminalPanel'")
}
})
it('does not eagerly import idle optional overlay surfaces on startup', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const source = readSource(ROOT_SURFACES_PATH)
expect(source).toContain("import('./components/UpdateCard').then")
expect(source).toContain("import('./components/contextual-tours/ContextualTourOverlay').then")
expect(source).toContain("import('./components/setup-guide/SetupGuideTelemetryObserver').then")
expect(source).not.toContain("from './components/UpdateCard'")
expect(source).not.toContain("from './components/contextual-tours/ContextualTourOverlay'")
expect(source).not.toContain("from './components/setup-guide/SetupGuideTelemetryObserver'")
expect(source).toContain("import('../components/UpdateCard').then")
expect(source).toContain("import('../components/contextual-tours/ContextualTourOverlay').then")
expect(source).toContain("import('../components/setup-guide/SetupGuideTelemetryObserver').then")
expect(source).not.toContain("from '../components/UpdateCard'")
expect(source).not.toContain("from '../components/contextual-tours/ContextualTourOverlay'")
expect(source).not.toContain("from '../components/setup-guide/SetupGuideTelemetryObserver'")
expect(source).toContain('const shouldMountSetupGuideTelemetryObserver = persistedUIReady')
expect(source).not.toContain(
"const shouldMountSetupGuideTelemetryObserver = persistedUIReady && activeModal === 'setup-guide'"
@@ -235,16 +245,15 @@ describe('renderer startup runtime routing', () => {
})
it('keeps crash-report listeners eager while lazy-loading the dialog surface', () => {
const appSource = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const hostSource = readFileSync(
join(process.cwd(), 'src/renderer/src/components/crash-report/CrashReportDialog.tsx'),
'utf8'
)
const surfacesSource = readSource(ROOT_SURFACES_PATH)
const hostSource = readSource('src/renderer/src/components/crash-report/CrashReportDialog.tsx')
expect(appSource).toContain(
"import { CrashReportDialog } from './components/crash-report/CrashReportDialog'"
expect(surfacesSource).toContain(
"import { CrashReportDialog } from '../components/crash-report/CrashReportDialog'"
)
expect(surfacesSource).not.toContain(
"from '../components/crash-report/CrashReportDialogSurface'"
)
expect(appSource).not.toContain("from './components/crash-report/CrashReportDialogSurface'")
expect(hostSource).toContain("import('./CrashReportDialogSurface').then")
expect(hostSource).toContain('window.api.crashReports.getLatestPending()')
expect(hostSource).toContain('window.api.ui.onOpenCrashReport')
@@ -254,10 +263,7 @@ describe('renderer startup runtime routing', () => {
})
it('clears stale crash-report state before opening the lazy manual report surface', () => {
const hostSource = readFileSync(
join(process.cwd(), 'src/renderer/src/components/crash-report/CrashReportDialog.tsx'),
'utf8'
)
const hostSource = readSource('src/renderer/src/components/crash-report/CrashReportDialog.tsx')
const manualOpenStart = hostSource.indexOf('return window.api.ui.onOpenCrashReport(() => {')
const manualOpenEnd = hostSource.indexOf(' }, [loadCrashReport])', manualOpenStart)
const manualOpenBlock = hostSource.slice(manualOpenStart, manualOpenEnd)
@@ -272,32 +278,34 @@ describe('renderer startup runtime routing', () => {
})
it('loads dictation only when voice is enabled or a session is active', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const source = readSource(ROOT_SURFACES_PATH)
expect(source).toContain("import('./components/dictation/DictationController').then")
expect(source).not.toContain("from './components/dictation/DictationController'")
expect(source).toContain("import('../components/dictation/DictationController').then")
expect(source).not.toContain("from '../components/dictation/DictationController'")
expect(source).toContain("settings?.voice?.enabled === true || dictationState !== 'idle'")
expect(source).toContain('shouldMountDictationController ?')
})
it('loads the SSH passphrase dialog only when a credential request is queued', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const source = readSource(ROOT_SURFACES_PATH)
expect(source).toContain("import('./components/settings/SshPassphraseDialog').then")
expect(source).not.toContain("from './components/settings/SshPassphraseDialog'")
expect(source).toContain("import('../components/settings/SshPassphraseDialog').then")
expect(source).not.toContain("from '../components/settings/SshPassphraseDialog'")
expect(source).toContain('s.sshCredentialQueue.length > 0')
expect(source).toContain('hasSshCredentialRequest ?')
})
it('defers background polling until the workspace session is ready', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
expect(source).toContain('useGitStatusPolling({ enabled: workspaceSessionReady })')
expect(source).toContain('<WorkspacePortScanner enabled={workspaceSessionReady} />')
expect(readSource(SHELL_SERVICES_PATH)).toContain(
'useGitStatusPolling({ enabled: workspaceSessionReady })'
)
expect(readSource(BACKGROUND_SERVICES_PATH)).toContain(
'<WorkspacePortScanner enabled={workspaceSessionReady} />'
)
})
it('prefetches terminal snapshot capabilities before reconnect unlocks cold activation', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const source = readSource(STARTUP_HYDRATION_PATH)
const capabilityIndex = source.indexOf(
"timeRendererStartupStep('terminal-provider-snapshot-capabilities'"
)
@@ -308,54 +316,61 @@ describe('renderer startup runtime routing', () => {
})
it('does not load the terminal workbench on the no-workspace landing path', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const shellSource = readSource(WORKSPACE_SHELL_PATH)
const layoutSource = readSource(CHROME_LAYOUT_PATH)
expect(source).toContain("const Terminal = lazy(() => import('./components/Terminal'))")
expect(source).not.toContain("from './components/Terminal'")
expect(source).toContain('const hasMountedTerminalWorkbenchRef = useRef(false)')
expect(source).toContain('hasMountedTerminalWorkbenchRef.current = true')
expect(source).toContain('activeWorktreeId !== null || backgroundTerminalMountRequested')
expect(source).toContain('backgroundTerminalMountRequested ||')
expect(source).toContain('hasMountedTerminalWorkbenchRef.current')
expect(source).toContain('shouldMountTerminalWorkbench ?')
expect(shellSource).toContain("const Terminal = lazy(() => import('../components/Terminal'))")
expect(shellSource).not.toContain("from '../components/Terminal'")
expect(layoutSource).toContain(
'const canMountTerminalWorkbenchNow = activeWorktreeId !== null || backgroundTerminalMountRequested'
)
// Why pin the latch: once the workbench has mounted it must stay mounted, so hidden
// terminal/browser/editor panes survive activeWorktreeId briefly going null.
expect(layoutSource).toContain(
'const [hasMountedTerminalWorkbench, setHasMountedTerminalWorkbench] = useState(false)'
)
expect(layoutSource).toContain('setHasMountedTerminalWorkbench(true)')
expect(layoutSource).toContain(
'const shouldMountTerminalWorkbench = canMountTerminalWorkbenchNow || hasMountedTerminalWorkbench'
)
expect(shellSource).toContain('shouldMountTerminalWorkbench ?')
})
it('keeps the new-workspace composer eager because it is a critical create surface', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const lazyModalSource = readFileSync(
join(process.cwd(), 'src/renderer/src/lazy-modal-mount-state.ts'),
'utf8'
)
const source = readSource(ROOT_SURFACES_PATH)
const lazyModalSource = readSource('src/renderer/src/lazy-modal-mount-state.ts')
expect(source).toContain(
"import NewWorkspaceComposerModal from './components/NewWorkspaceComposerModal'"
"import NewWorkspaceComposerModal from '../components/NewWorkspaceComposerModal'"
)
expect(source).not.toContain("import('./components/NewWorkspaceComposerModal')")
expect(source).not.toContain("import('../components/NewWorkspaceComposerModal')")
expect(source).toContain("activeModal === 'new-workspace-composer'")
expect(lazyModalSource).not.toContain("'new-workspace-composer'")
})
it('does not eagerly import inactive sidebar dialog flows on startup', () => {
const appSource = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const sidebarSource = readFileSync(
join(process.cwd(), 'src/renderer/src/components/sidebar/index.tsx'),
'utf8'
)
const surfacesSource = readSource(ROOT_SURFACES_PATH)
const mountSource = readSource(LAZY_MODAL_MOUNTS_PATH)
const sidebarSource = readSource('src/renderer/src/components/sidebar/index.tsx')
expect(appSource).toContain("lazy(() => import('./components/sidebar/AddRepoDialog'))")
expect(appSource).toContain("lazy(() => import('./components/sidebar/NonGitFolderDialog'))")
expect(appSource).toContain("import('./components/sidebar/AddProjectFromFolderDialog')")
expect(appSource).toContain("lazy(() => import('./components/sidebar/ProjectAddedDialog'))")
expect(appSource).toContain("activeModal === 'add-repo'")
expect(appSource).toContain("activeModal === 'confirm-non-git-folder'")
expect(appSource).toContain("activeModal === 'confirm-add-project-from-folder'")
expect(appSource).toContain("activeModal === 'project-added'")
expect(appSource).toContain('shouldMountAddRepoDialog ? (')
expect(appSource).toContain('boundaryId="modal.add-repo"')
expect(appSource).toContain('boundaryId="modal.confirm-non-git-folder"')
expect(appSource).toContain('boundaryId="modal.confirm-add-project-from-folder"')
expect(appSource).toContain('boundaryId="modal.project-added"')
expect(appSource).toContain('setTimeout(() =>')
expect(surfacesSource).toContain("lazy(() => import('../components/sidebar/AddRepoDialog'))")
expect(surfacesSource).toContain(
"lazy(() => import('../components/sidebar/NonGitFolderDialog'))"
)
expect(surfacesSource).toContain("import('../components/sidebar/AddProjectFromFolderDialog')")
expect(surfacesSource).toContain(
"lazy(() => import('../components/sidebar/ProjectAddedDialog'))"
)
expect(surfacesSource).toContain("activeModal === 'add-repo'")
expect(surfacesSource).toContain("activeModal === 'confirm-non-git-folder'")
expect(surfacesSource).toContain("activeModal === 'confirm-add-project-from-folder'")
expect(surfacesSource).toContain("activeModal === 'project-added'")
expect(surfacesSource).toContain('shouldMountAddRepoDialog ? (')
expect(surfacesSource).toContain('boundaryId="modal.add-repo"')
expect(surfacesSource).toContain('boundaryId="modal.confirm-non-git-folder"')
expect(surfacesSource).toContain('boundaryId="modal.confirm-add-project-from-folder"')
expect(surfacesSource).toContain('boundaryId="modal.project-added"')
expect(mountSource).toContain('setTimeout(() =>')
expect(sidebarSource).toContain("lazyWithRetry(() => import('./WorktreeMetaDialog'))")
expect(sidebarSource).not.toContain("from './AddRepoDialog'")
expect(sidebarSource).not.toContain("React.lazy(() => import('./AddRepoDialog'))")
@@ -379,10 +394,7 @@ describe('renderer startup runtime routing', () => {
})
it('loads Linear agent setup implementation only after the prompt opens it', () => {
const source = readFileSync(
join(process.cwd(), 'src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx'),
'utf8'
)
const source = readSource('src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx')
expect(source).toContain("() => import('./LinearAgentSkillSetupDialog')")
expect(source).not.toContain("from './LinearAgentSkillSetupDialog'")
@@ -391,10 +403,7 @@ describe('renderer startup runtime routing', () => {
})
it('does not eagerly import optional status-bar segments on startup', () => {
const source = readFileSync(
join(process.cwd(), 'src/renderer/src/components/status-bar/StatusBar.tsx'),
'utf8'
)
const source = readSource('src/renderer/src/components/status-bar/StatusBar.tsx')
expect(source).toContain("import('./ResourceUsageStatusSegment').then")
expect(source).toContain("import('./PortsStatusSegment').then")
@@ -407,16 +416,16 @@ describe('renderer startup runtime routing', () => {
})
it('does not eagerly import the status bar shell on startup', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const source = readSource(ROOT_SURFACES_PATH)
expect(source).toContain("import('./components/status-bar/StatusBar').then")
expect(source).not.toContain("from './components/status-bar/StatusBar'")
expect(source).toContain("import('../components/status-bar/StatusBar').then")
expect(source).not.toContain("from '../components/status-bar/StatusBar'")
expect(source).toContain('statusBarVisible ? (')
expect(source).toContain('h-6 min-h-[24px] shrink-0 border-t border-border')
})
it('keeps activeView off the 150ms debounced UI writer hot path (#9002)', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const source = readSource(PERSISTED_UI_WRITER_PATH)
const writerStart = source.indexOf('const timer = window.setTimeout(() => {')
const writerEnd = source.indexOf('}, 150)', writerStart)
const writerBlock = source.slice(writerStart, writerEnd)
@@ -437,7 +446,7 @@ describe('renderer startup runtime routing', () => {
})
it('persists activeView through its narrow preference on every switch (#9002)', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const source = readSource(PERSISTED_UI_WRITER_PATH)
const preferenceEffect = [
'// Why (#9002): activeView has its own tiny profile preference',
@@ -452,21 +461,22 @@ describe('renderer startup runtime routing', () => {
})
it('arms the OSC 52 default-on notice behind a statically mounted Toaster (#10567)', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
// Why pin the call site: the hook is the only caller, so deleting this line silences
// the migration notice on desktop with every unit suite still green.
expect(source).toContain('useOsc52ClipboardDefaultOnNotice(persistedUIReady)')
expect(readSource(SHELL_SERVICES_PATH)).toContain(
'useOsc52ClipboardDefaultOnNotice(persistedUIReady)'
)
// Why pin the static import and the unconditional mount: sonner drops a toast enqueued
// before any Toaster subscribes, and never replays it — a lazy Toaster would burn the
// profile's one notice with its callbacks never firing, so it could never re-arm.
expect(source).toContain("import { Toaster } from '@/components/ui/sonner'")
expect(source).not.toContain("import('@/components/ui/sonner')")
expect(source).toContain('<Toaster closeButton')
const appSource = readSource(APP_PATH)
expect(appSource).toContain("import { Toaster } from '@/components/ui/sonner'")
expect(appSource).not.toContain("import('@/components/ui/sonner')")
expect(appSource).toContain('<Toaster closeButton')
})
it('checkpoints activeView and all session snapshots through one beforeunload handler (#9002)', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const source = readSource(SESSION_PERSISTENCE_PATH)
const checkpointStart = source.indexOf(
'const shutdownCheckpoint = createShutdownCheckpointGuard('
)
@@ -420,6 +420,37 @@ describe('AgentTerminalPreview', () => {
expect(input).toHaveBeenCalledWith('pty-1', 'clip-text')
})
it('encodes a leading newline for a remote Windows Codex preview without submitting', async () => {
readClipboardText.mockResolvedValueOnce('\nsecond line')
const view = render(
<AgentTerminalPreview
ptyId="remote:windows-box@@pty-1"
terminalInput={{
hostPlatform: 'win32',
localWindowsConpty: false,
windowsShiftEnterEncoding: 'alt-enter',
windowsInputRecordPasteNewline: 'alt-enter',
ctrlEnterCsiU: false,
kittyKeyboardAdvertised: false
}}
/>
)
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
const terminal = terminalHarness.instances[0]!
const host = view.container.querySelector<HTMLElement>('.origin-bottom-left')!
const focusTarget = document.createElement('input')
host.appendChild(focusTarget)
focusTarget.focus()
act(() => emitAppMenuPaste!())
await waitFor(() =>
expect(input).toHaveBeenCalledWith('remote:windows-box@@pty-1', '\x1b\rsecond line')
)
expect(terminal.input).toHaveBeenCalledWith('\x1b\rsecond line')
expect(terminal.paste).not.toHaveBeenCalled()
})
it('handles app-menu selection actions while the preview owns focus', async () => {
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
@@ -201,6 +201,7 @@ export function AgentTerminalPreview({
ptyId,
container,
getTerminal: () => terminal,
getTerminalInput: () => terminalInputRef.current,
isDisposed: () => disposed
})
@@ -6,6 +6,8 @@ import {
} from '@/components/terminal-pane/terminal-paste-coordinator'
import { resolveTerminalPasteRuntime } from '@/components/terminal-pane/terminal-paste-runtime'
import { TERMINAL_PASTE_MAX_BYTES } from '@/components/terminal-pane/terminal-paste-limits'
import { pasteTerminalText } from '@/components/terminal-pane/terminal-bracketed-paste'
import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot'
/**
* Clipboard paste for the preview terminal, on the pane's coordinator: large
@@ -16,6 +18,7 @@ export function createPreviewClipboardPaster(deps: {
ptyId: string
container: HTMLElement
getTerminal: () => Terminal | null
getTerminalInput: () => DashboardCardTerminalInput | null
isDisposed: () => boolean
}): (activeElementAtDispatch: Element | null, source: 'keyboard' | 'app-menu') => Promise<void> {
return async (activeElementAtDispatch, source) => {
@@ -38,7 +41,8 @@ export function createPreviewClipboardPaster(deps: {
if (!targetIsCurrent()) {
return
}
const platform = getShortcutPlatform()
const terminalInput = deps.getTerminalInput()
const platform = terminalInput?.hostPlatform ?? getShortcutPlatform()
const plan = await planTerminalPasteWithYield({
text,
source,
@@ -49,11 +53,13 @@ export function createPreviewClipboardPaster(deps: {
ptyId: deps.ptyId,
runtime: resolveTerminalPasteRuntime({ platform, ptyId: deps.ptyId })
},
forceBracketedPasteForMultiline: terminalInput?.forceBracketedMultilineTextPaste,
windowsInputRecordNewline: terminalInput?.windowsInputRecordPasteNewline,
terminalBracketedPasteMode: pasteTerminal.modes.bracketedPasteMode
})
await executeTerminalPastePlan(plan, {
// Why: stream large pastes so the renderer never emits one huge IPC payload.
pasteText: (pasteText) => pasteTerminal.paste(pasteText),
pasteText: (text, options) => pasteTerminalText(pasteTerminal, text, options),
writePty: (data) => window.api.terminalPreview.input(deps.ptyId, data),
isTargetCurrent: targetIsCurrent,
// Why: if focus changes mid-bracketed paste, the closing marker must still reach the live PTY.
@@ -10,12 +10,12 @@ function source(relativePath: string): string {
describe('agent dashboard performance isolation', () => {
it('keeps all dashboard feature modules out of the disabled app and sidebar path', () => {
const app = source('App.tsx')
const backgroundServices = source('app-shell/AppBackgroundServices.tsx')
const sidebar = source('components/sidebar/index.tsx')
const nav = source('components/sidebar/SidebarNav.tsx')
expect(app).not.toMatch(/from ['"].*DashboardPopoutBridge['"]/)
expect(app).toContain("import('./components/dashboard/DashboardPopoutBridge')")
expect(backgroundServices).not.toMatch(/from ['"].*DashboardPopoutBridge['"]/)
expect(backgroundServices).toContain("import('../components/dashboard/DashboardPopoutBridge')")
expect(sidebar).not.toMatch(/from ['"].*AgentDashboard(?:Drawer|SidebarHost)['"]/)
expect(sidebar).toContain("import('./AgentDashboardSidebarHost')")
expect(nav).not.toContain('useAgentBucketCounts')
@@ -1,6 +1,7 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import {
resolveDashboardCardTerminalInput,
type DashboardCardTerminalInputState
@@ -36,6 +37,19 @@ function stateWith(
} as Partial<DashboardCardTerminalInputState>
}
function codexEntry(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry {
return {
state: 'waiting',
prompt: '',
updatedAt: 1,
stateStartedAt: 1,
stateHistory: [],
agentType: 'codex',
paneKey: MAC_ARGS.paneKey,
...overrides
}
}
describe('resolveDashboardCardTerminalInput', () => {
it('resolves a local macOS pane to client-host routing with kitty advertised', () => {
expect(resolveDashboardCardTerminalInput(stateWith(), MAC_ARGS)).toEqual({
@@ -127,6 +141,50 @@ describe('resolveDashboardCardTerminalInput', () => {
expect(resolveDashboardCardTerminalInput(state, MAC_ARGS).hostPlatform).toBe('win32')
})
it('relays Windows input-record paste encoding for a confirmed remote Codex pane', () => {
const profile = resolveDashboardCardTerminalInput(
stateWith({
runtimeStatusByEnvironmentId: new Map([
['windows-box', { status: { hostPlatform: 'win32' } }]
]),
agentStatusByPaneKey: { [MAC_ARGS.paneKey]: codexEntry() }
} as unknown as Partial<DashboardCardTerminalInputState>),
{ ...MAC_ARGS, ptyId: 'remote:windows-box@@pty-1' }
)
expect(profile.windowsInputRecordPasteNewline).toBe('alt-enter')
expect(profile.forceBracketedMultilineTextPaste).toBeUndefined()
})
it('relays bracketed multiline paste for a confirmed non-Windows Codex pane', () => {
const profile = resolveDashboardCardTerminalInput(
stateWith({
agentStatusByPaneKey: { [MAC_ARGS.paneKey]: codexEntry() }
}),
MAC_ARGS
)
expect(profile.forceBracketedMultilineTextPaste).toBe(true)
expect(profile.windowsInputRecordPasteNewline).toBeUndefined()
})
it('does not relay stale restored agent paste authority', () => {
const profile = resolveDashboardCardTerminalInput(
stateWith({
runtimeStatusByEnvironmentId: new Map([
['windows-box', { status: { hostPlatform: 'win32' } }]
]),
agentStatusByPaneKey: {
[MAC_ARGS.paneKey]: codexEntry({ restoredUnconfirmed: true })
}
} as unknown as Partial<DashboardCardTerminalInputState>),
{ ...MAC_ARGS, ptyId: 'remote:windows-box@@pty-1' }
)
expect(profile.forceBracketedMultilineTextPaste).toBeUndefined()
expect(profile.windowsInputRecordPasteNewline).toBeUndefined()
})
it("keeps the live SSH PTY's host after the worktree owner changes", () => {
const state = stateWith({
sshConnectionStates: new Map([['conn-live', { remotePlatform: 'win32' }]])
@@ -11,6 +11,7 @@ import { resolveTerminalInputHostPlatform } from '@/components/terminal-pane/ter
import { resolveWindowsShiftEnterEncodingForPane } from '@/components/terminal-pane/terminal-windows-shift-enter'
import { hasCtrlEnterCsiUAuthorityForPane } from '@/components/terminal-pane/terminal-ctrl-enter'
import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
import { resolveProtectedMultilinePasteOptionsForAgentEvidence } from '@/components/terminal-pane/terminal-agent-paste-bracketing'
/** Store slices the pane's own input helpers read. */
export type DashboardCardTerminalInputState = Pick<
@@ -29,6 +30,7 @@ export type DashboardCardTerminalInputState = Pick<
| 'runtimeEnvironmentCatalogHydrated'
| 'removedRuntimeEnvironmentIds'
| 'paneForegroundAgentByPaneKey'
| 'agentStatusByPaneKey'
| 'agentLaunchConfigByPaneKey'
>
@@ -55,6 +57,7 @@ function withHydratedSlices(
runtimeEnvironmentCatalogHydrated: state.runtimeEnvironmentCatalogHydrated ?? false,
removedRuntimeEnvironmentIds: state.removedRuntimeEnvironmentIds ?? new Set(),
paneForegroundAgentByPaneKey: state.paneForegroundAgentByPaneKey ?? EMPTY_RECORD,
agentStatusByPaneKey: state.agentStatusByPaneKey ?? EMPTY_RECORD,
agentLaunchConfigByPaneKey: state.agentLaunchConfigByPaneKey ?? EMPTY_RECORD
}
}
@@ -99,30 +102,43 @@ export function resolveDashboardCardTerminalInput(
shellOverride: args.shellOverride,
executionHostId
}
const hostPlatform = resolveTerminalInputHostPlatform({
clientPlatform: args.clientPlatform,
state,
worktreeId: args.worktreeId,
transport: {
getConnectionId: () => connectionId,
getPtyId: () => args.ptyId,
getExecutionHostId: () => executionHostId,
// Why: gated exactly like the pane transport's own accessor — without
// it a WSL pty on a Windows client resolves to win32 and Shift+Enter
// would encode CSI-u where the pane sends alt-enter.
getLocalSessionMetadata: () =>
connectionId
? null
: {
...(args.cwd ? { cwd: args.cwd } : {}),
...(args.shellOverride ? { shellOverride: args.shellOverride } : {})
}
}
})
const protectedPaste = resolveProtectedMultilinePasteOptionsForAgentEvidence({
isWindowsClient: args.clientPlatform === 'win32',
hostPlatform,
foregroundAgent: state.paneForegroundAgentByPaneKey[args.paneKey]?.agent,
entry: state.agentStatusByPaneKey[args.paneKey]
})
return {
hostPlatform: resolveTerminalInputHostPlatform({
clientPlatform: args.clientPlatform,
state,
worktreeId: args.worktreeId,
transport: {
getConnectionId: () => connectionId,
getPtyId: () => args.ptyId,
getExecutionHostId: () => executionHostId,
// Why: gated exactly like the pane transport's own accessor — without
// it a WSL pty on a Windows client resolves to win32 and Shift+Enter
// would encode CSI-u where the pane sends alt-enter.
getLocalSessionMetadata: () =>
connectionId
? null
: {
...(args.cwd ? { cwd: args.cwd } : {}),
...(args.shellOverride ? { shellOverride: args.shellOverride } : {})
}
}
}),
hostPlatform,
localWindowsConpty: isLocalNativeWindowsConpty(windowsPtyContext),
...(args.osRelease === undefined ? {} : { osRelease: args.osRelease }),
windowsShiftEnterEncoding: resolveWindowsShiftEnterEncodingForPane(state, args.paneKey),
...(protectedPaste?.forceBracketedPasteForMultiline
? { forceBracketedMultilineTextPaste: true as const }
: {}),
...(protectedPaste?.windowsInputRecordNewline
? { windowsInputRecordPasteNewline: protectedPaste.windowsInputRecordNewline }
: {}),
ctrlEnterCsiU: hasCtrlEnterCsiUAuthorityForPane(state, args.paneKey),
kittyKeyboardAdvertised: !shouldDisableKittyKeyboardForTerminal({
...windowsPtyContext,
@@ -719,7 +719,7 @@ describe('native chat PTY session options', () => {
expect(surface.getSnapshot()[0]).toMatchObject({
id: 'model',
valueSource: 'default',
kind: { currentValue: 'grok-4.5' }
kind: { currentValue: 'grok-4.6' }
})
// Regression: the effort row hangs off that default model, so resolving the apply
@@ -735,9 +735,9 @@ describe('native chat PTY session options', () => {
it('does not adopt grok’s unprobed seed default as a persisted launch model', async () => {
// Regression: setting an option under the CLI default wrote `model` into settings,
// so every later grok launch app-wide emitted `-m grok-4.5` — on an account without
// so every later grok launch app-wide emitted `-m grok-4.6` — on an account without
// that model, a fatal launch the user never opted into. No discovery has run here,
// so `grok-4.5` is still only the seed's guess.
// so `grok-4.6` is still only the seed's guess.
let persisted: PersistedNativeChatSessionOptions = {}
const surface = createNativeChatPtySessionOptions({
agent: 'grok',
@@ -760,7 +760,7 @@ describe('native chat PTY session options', () => {
expect(persisted.grok?.model).toBeUndefined()
// The scoped value is still remembered for a later explicit pick of that model.
expect(persisted.grok?.valuesByModel?.['grok-4.5']?.effort).toBe('low')
expect(persisted.grok?.valuesByModel?.['grok-4.6']?.effort).toBe('low')
expect(resolveNativeChatSessionOptionDefaults(persisted, 'grok')).toBeUndefined()
// A model the user actually picks still becomes the launch default.
@@ -0,0 +1,79 @@
import { Loader2 } from 'lucide-react'
import type { EphemeralVmRuntimeRecord } from '../../../../shared/ephemeral-vm-runtimes'
import { translate } from '@/i18n/i18n'
import { Button } from '../ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '../ui/dialog'
type EphemeralVmCleanupStopDialogProps = {
runtime: EphemeralVmRuntimeRecord | null
isStopping: boolean
onCancel: () => void
onConfirm: () => void
}
export function EphemeralVmCleanupStopDialog({
runtime,
isStopping,
onCancel,
onConfirm
}: EphemeralVmCleanupStopDialogProps): React.JSX.Element {
return (
<Dialog
open={runtime !== null}
onOpenChange={(open) => {
if (!open && !isStopping) {
onCancel()
}
}}
>
<DialogContent className="max-w-md" showCloseButton={!isStopping}>
<DialogHeader>
<DialogTitle className="text-sm">
{translate(
'auto.components.settings.EphemeralVmCleanupStopDialog.title',
'Stop cleanup?'
)}
</DialogTitle>
<DialogDescription className="text-xs">
{translate(
'auto.components.settings.EphemeralVmCleanupStopDialog.description',
'The VM may remain running and incur charges. You can retry cleanup later.'
)}
</DialogDescription>
</DialogHeader>
{runtime ? (
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs text-muted-foreground">
<div className="truncate">{runtime.workspaceName || runtime.recipeId}</div>
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={isStopping}>
{translate(
'auto.components.settings.EphemeralVmCleanupStopDialog.cancel',
'Keep cleaning'
)}
</Button>
<Button onClick={onConfirm} disabled={isStopping}>
{isStopping ? <Loader2 className="animate-spin" /> : null}
{isStopping
? translate(
'auto.components.settings.EphemeralVmCleanupStopDialog.stopping',
'Stopping…'
)
: translate(
'auto.components.settings.EphemeralVmCleanupStopDialog.confirm',
'Stop cleanup'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,117 @@
import { AlertTriangle, Copy, Loader2, Square, Trash2 } from 'lucide-react'
import type { EphemeralVmRuntimeRecord } from '../../../../shared/ephemeral-vm-runtimes'
import { getEphemeralVmRecipeResultProjectRoot } from '../../../../shared/ephemeral-vm-recipes'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { Button } from '../ui/button'
type EphemeralVmRuntimeRowProps = {
runtime: EphemeralVmRuntimeRecord
statusLabel: string
cleanupFailed: boolean
cleanupRunning: boolean
isStopping: boolean
disabled: boolean
onCleanup: () => void
onStopCleanup: () => void
onCopyCleanupCommand: () => void
}
export function EphemeralVmRuntimeRow({
runtime,
statusLabel,
cleanupFailed,
cleanupRunning,
isStopping,
disabled,
onCleanup,
onStopCleanup,
onCopyCleanupCommand
}: EphemeralVmRuntimeRowProps): React.JSX.Element {
const hasError = cleanupFailed || runtime.status === 'failed'
return (
<div className="flex items-center gap-3 px-4 py-3">
<div
className={cn(
'size-2 shrink-0 rounded-full',
hasError ? 'bg-destructive' : 'bg-muted-foreground/40'
)}
/>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate text-sm font-medium">
{runtime.workspaceName || runtime.recipeId}
</div>
<span className="shrink-0 text-[11px] text-muted-foreground">{statusLabel}</span>
{hasError ? <AlertTriangle className="size-3.5 shrink-0 text-destructive" /> : null}
</div>
<p className="truncate text-xs text-muted-foreground">
{runtime.recipeId} · {getEphemeralVmRecipeResultProjectRoot(runtime.recipeResult)}
</p>
{runtime.cleanupLastError ? (
<p className="mt-0.5 truncate text-xs text-destructive">{runtime.cleanupLastError}</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
{runtime.cleanupStatus === 'failed' ? (
<Button
type="button"
variant="ghost"
size="xs"
className="gap-1.5 text-muted-foreground hover:text-foreground"
onClick={onCopyCleanupCommand}
disabled={disabled}
>
<Copy className="size-3" />
{translate(
'auto.components.settings.EphemeralVmRuntimesSection.copyCleanup',
'Copy command'
)}
</Button>
) : null}
{cleanupRunning ? (
<Button
type="button"
variant="ghost"
size="xs"
className="gap-1.5 text-muted-foreground hover:text-foreground"
onClick={onStopCleanup}
disabled={disabled || isStopping}
>
{isStopping ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Square className="size-3" />
)}
{isStopping
? translate(
'auto.components.settings.EphemeralVmRuntimesSection.stoppingCleanup',
'Stopping…'
)
: translate(
'auto.components.settings.EphemeralVmRuntimesSection.stopCleanup',
'Stop cleanup'
)}
</Button>
) : (
<Button
type="button"
variant="ghost"
size="xs"
className="gap-1.5 text-muted-foreground hover:text-foreground"
onClick={onCleanup}
disabled={disabled}
>
<Trash2 className="size-3" />
{cleanupFailed
? translate(
'auto.components.settings.EphemeralVmRuntimesSection.retry',
'Retry cleanup'
)
: translate('auto.components.settings.EphemeralVmRuntimesSection.cleanup', 'Cleanup')}
</Button>
)}
</div>
</div>
)
}
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { EphemeralVmRuntimeRecord } from '../../../../shared/ephemeral-vm-runtimes'
@@ -22,6 +22,18 @@ vi.mock('sonner', () => ({
}
}))
vi.mock('../ui/dialog', () => ({
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
open ? <div>{children}</div> : null,
DialogContent: ({ children }: { children: ReactNode }) => (
<div data-slot="dialog-content">{children}</div>
),
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>
}))
const roots: Root[] = []
function makeRuntime(overrides: Partial<EphemeralVmRuntimeRecord> = {}): EphemeralVmRuntimeRecord {
@@ -81,6 +93,14 @@ describe('EphemeralVmRuntimesSection helpers', () => {
expect(getEphemeralVmRuntimeStatusLabel(makeRuntime({ cleanupStatus: 'failed' }))).toBe(
'Cleanup failed'
)
expect(
getEphemeralVmRuntimeStatusLabel(
makeRuntime({
cleanupStatus: 'failed',
cleanupLastError: 'Cleanup stopped by user.'
})
)
).toBe('Cleanup stopped')
expect(getEphemeralVmRuntimeStatusLabel(makeRuntime({ cleanupStatus: 'disabled' }))).toBe(
'Cleanup disabled'
)
@@ -108,6 +128,13 @@ describe('EphemeralVmRuntimesSection', () => {
status: 'cleaned',
cleanupStatus: 'succeeded'
})
),
stopCleanup: vi.fn().mockResolvedValue(
makeRuntime({
status: 'cleanup_failed',
cleanupStatus: 'failed',
cleanupLastError: 'Cleanup stopped by user.'
})
)
},
ui: {
@@ -122,6 +149,7 @@ describe('EphemeralVmRuntimesSection', () => {
act(() => root.unmount())
})
document.body.replaceChildren()
vi.useRealTimers()
})
it('renders active Cloud VM runtimes and cleans one up', async () => {
@@ -178,6 +206,65 @@ describe('EphemeralVmRuntimesSection', () => {
expect(toastMocks.success).not.toHaveBeenCalled()
})
it('stops running cleanup from a persistent runtime row', async () => {
const running = makeRuntime({ status: 'cleanup_pending', cleanupStatus: 'running' })
const stopped = makeRuntime({
status: 'cleanup_failed',
cleanupStatus: 'failed',
cleanupLastError: 'Cleanup stopped by user.'
})
window.api.ephemeralVm.listRuntimes = vi
.fn()
.mockResolvedValueOnce([running])
.mockResolvedValue([stopped])
window.api.ephemeralVm.stopCleanup = vi.fn().mockResolvedValue(stopped)
const container = await renderSection()
await vi.waitFor(() => expect(container.textContent).toContain('Stop cleanup'))
const stopButton = [...container.querySelectorAll('button')].find(
(button) => button.textContent === 'Stop cleanup'
)
await act(async () => {
stopButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
await vi.waitFor(() => expect(document.body.textContent).toContain('The VM may remain running'))
const dialog = document.body.querySelector('[data-slot="dialog-content"]')
const confirmButton = [...(dialog?.querySelectorAll('button') ?? [])].find(
(button) => button.textContent === 'Stop cleanup'
)
await act(async () => {
confirmButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
await vi.waitFor(() =>
expect(window.api.ephemeralVm.stopCleanup).toHaveBeenCalledWith({ runtimeId: 'runtime-1' })
)
await vi.waitFor(() => expect(container.textContent).toContain('Retry cleanup'))
expect(container.textContent).toContain('Cleanup stopped by user.')
})
it('does not repeat load-error toasts while polling a running cleanup', async () => {
vi.useFakeTimers()
window.api.ephemeralVm.listRuntimes = vi
.fn()
.mockResolvedValueOnce([makeRuntime({ status: 'cleanup_pending', cleanupStatus: 'running' })])
.mockRejectedValue(new Error('IPC unavailable'))
const container = await renderSection()
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
expect(container.textContent).toContain('Stop cleanup')
toastMocks.error.mockClear()
await act(async () => {
await vi.advanceTimersByTimeAsync(2_000)
})
expect(toastMocks.error).not.toHaveBeenCalled()
})
it('copies a manual cleanup command for failed runtimes', async () => {
window.api.ephemeralVm.listRuntimes = vi.fn().mockResolvedValue([
makeRuntime({
@@ -1,13 +1,14 @@
import { AlertTriangle, Copy, Loader2, RefreshCw, Trash2 } from 'lucide-react'
import { Loader2, RefreshCw } from 'lucide-react'
import type React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import type { EphemeralVmRuntimeRecord } from '../../../../shared/ephemeral-vm-runtimes'
import { getEphemeralVmRecipeResultProjectRoot } from '../../../../shared/ephemeral-vm-recipes'
import { EPHEMERAL_VM_CLEANUP_STOPPED_ERROR } from '../../../../shared/ephemeral-vm-recipe-destroy-result'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
import { Button } from '../ui/button'
import { cn } from '@/lib/utils'
import { EphemeralVmCleanupStopDialog } from './EphemeralVmCleanupStopDialog'
import { EphemeralVmRuntimeRow } from './EphemeralVmRuntimeRow'
function hasCleanupFailed(runtime: EphemeralVmRuntimeRecord): boolean {
return (
@@ -17,6 +18,17 @@ function hasCleanupFailed(runtime: EphemeralVmRuntimeRecord): boolean {
)
}
function isCleanupRunning(runtime: EphemeralVmRuntimeRecord): boolean {
return runtime.cleanupStatus === 'running' || runtime.status === 'cleanup_pending'
}
function hasCleanupStopped(runtime: EphemeralVmRuntimeRecord): boolean {
return (
runtime.cleanupStatus === 'failed' &&
runtime.cleanupLastError === EPHEMERAL_VM_CLEANUP_STOPPED_ERROR
)
}
export function getVisibleEphemeralVmRuntimes(
runtimes: readonly EphemeralVmRuntimeRecord[]
): EphemeralVmRuntimeRecord[] {
@@ -26,6 +38,12 @@ export function getVisibleEphemeralVmRuntimes(
}
export function getEphemeralVmRuntimeStatusLabel(runtime: EphemeralVmRuntimeRecord): string {
if (hasCleanupStopped(runtime)) {
return translate(
'auto.components.settings.EphemeralVmRuntimesSection.cleanupStopped',
'Cleanup stopped'
)
}
if (hasCleanupFailed(runtime)) {
return translate(
'auto.components.settings.EphemeralVmRuntimesSection.cleanupFailed',
@@ -53,47 +71,84 @@ export function getEphemeralVmRuntimeStatusLabel(runtime: EphemeralVmRuntimeReco
return runtime.status
}
export function EphemeralVmRuntimesSection(): React.JSX.Element {
export function EphemeralVmRuntimesSection({
active = true
}: {
active?: boolean
}): React.JSX.Element {
const [runtimes, setRuntimes] = useState<EphemeralVmRuntimeRecord[]>([])
const [isLoading, setIsLoading] = useState(true)
const [cleaningId, setCleaningId] = useState<string | null>(null)
const [stoppingId, setStoppingId] = useState<string | null>(null)
const [pendingStop, setPendingStop] = useState<EphemeralVmRuntimeRecord | null>(null)
const mountedRef = useMountedRef()
const refresh = useCallback(async (): Promise<void> => {
if (mountedRef.current) {
setIsLoading(true)
}
try {
const nextRuntimes = await window.api.ephemeralVm.listRuntimes()
if (mountedRef.current) {
setRuntimes(getVisibleEphemeralVmRuntimes(nextRuntimes))
const refresh = useCallback(
async (showLoading = true, reportError = true): Promise<void> => {
if (mountedRef.current && showLoading) {
setIsLoading(true)
}
} catch (error) {
if (mountedRef.current) {
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmLoadFailed',
'Couldn’t load Cloud VM runtimes.'
)
)
try {
const nextRuntimes = await window.api.ephemeralVm.listRuntimes()
if (mountedRef.current) {
setRuntimes(getVisibleEphemeralVmRuntimes(nextRuntimes))
}
} catch (error) {
if (mountedRef.current && reportError) {
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmLoadFailed',
'Couldn’t load Cloud VM runtimes.'
)
)
}
} finally {
if (mountedRef.current && showLoading) {
setIsLoading(false)
}
}
} finally {
if (mountedRef.current) {
setIsLoading(false)
}
}
}, [mountedRef])
},
[mountedRef]
)
useEffect(() => {
void refresh()
}, [refresh])
if (active) {
void refresh()
}
}, [active, refresh])
const hasRunningCleanup = runtimes.some(isCleanupRunning) || cleaningId !== null
useEffect(() => {
if (!active || !hasRunningCleanup) {
return
}
let cancelled = false
let timer: ReturnType<typeof setTimeout> | undefined
const poll = async (): Promise<void> => {
await refresh(false, false)
if (!cancelled) {
timer = setTimeout(() => void poll(), 1_000)
}
}
timer = setTimeout(() => void poll(), 1_000)
return () => {
cancelled = true
if (timer) {
clearTimeout(timer)
}
}
}, [active, hasRunningCleanup, refresh])
const cleanupRuntime = async (runtime: EphemeralVmRuntimeRecord): Promise<void> => {
setCleaningId(runtime.id)
try {
const cleaned = await window.api.ephemeralVm.cleanup({ runtimeId: runtime.id })
if (hasCleanupStopped(cleaned)) {
await refresh(false)
return
}
if (hasCleanupFailed(cleaned)) {
throw new Error(
cleaned.cleanupLastError ??
@@ -170,6 +225,33 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element {
}
}
const stopCleanup = async (runtime: EphemeralVmRuntimeRecord): Promise<void> => {
setStoppingId(runtime.id)
try {
await window.api.ephemeralVm.stopCleanup({ runtimeId: runtime.id })
if (mountedRef.current) {
setPendingStop(null)
await refresh(false)
}
} catch (error) {
if (mountedRef.current) {
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.settings.EphemeralVmRuntimesSection.stopCleanupFailed',
'Couldn’t stop Cloud VM cleanup.'
)
)
await refresh(false)
}
} finally {
if (mountedRef.current) {
setStoppingId(null)
}
}
}
const hasRuntimes = runtimes.length > 0
return (
<div className="space-y-3 pt-2" data-settings-section="temporary-vm-runtimes">
@@ -226,92 +308,33 @@ export function EphemeralVmRuntimesSection(): React.JSX.Element {
<EphemeralVmRuntimeRow
key={runtime.id}
runtime={runtime}
isCleaning={cleaningId === runtime.id}
disabled={cleaningId !== null || isLoading}
statusLabel={getEphemeralVmRuntimeStatusLabel(runtime)}
cleanupFailed={hasCleanupFailed(runtime)}
cleanupRunning={isCleanupRunning(runtime) || cleaningId === runtime.id}
isStopping={stoppingId === runtime.id}
disabled={
isLoading ||
(cleaningId !== null && cleaningId !== runtime.id) ||
(stoppingId !== null && stoppingId !== runtime.id)
}
onCleanup={() => void cleanupRuntime(runtime)}
onStopCleanup={() => setPendingStop(runtime)}
onCopyCleanupCommand={() => void copyCleanupCommand(runtime)}
/>
))}
</div>
)}
</div>
</div>
)
}
function EphemeralVmRuntimeRow({
runtime,
isCleaning,
disabled,
onCleanup,
onCopyCleanupCommand
}: {
runtime: EphemeralVmRuntimeRecord
isCleaning: boolean
disabled: boolean
onCleanup: () => void
onCopyCleanupCommand: () => void
}): React.JSX.Element {
const statusLabel = getEphemeralVmRuntimeStatusLabel(runtime)
const cleanupFailed = hasCleanupFailed(runtime)
const hasError = cleanupFailed || runtime.status === 'failed'
return (
<div className="flex items-center gap-3 px-4 py-3">
<div
className={cn(
'size-2 shrink-0 rounded-full',
hasError ? 'bg-destructive' : 'bg-muted-foreground/40'
)}
<EphemeralVmCleanupStopDialog
runtime={pendingStop}
isStopping={stoppingId !== null}
onCancel={() => setPendingStop(null)}
onConfirm={() => {
if (pendingStop) {
void stopCleanup(pendingStop)
}
}}
/>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate text-sm font-medium">
{runtime.workspaceName || runtime.recipeId}
</div>
<span className="shrink-0 text-[11px] text-muted-foreground">{statusLabel}</span>
{hasError ? <AlertTriangle className="size-3.5 shrink-0 text-destructive" /> : null}
</div>
<p className="truncate text-xs text-muted-foreground">
{runtime.recipeId} · {getEphemeralVmRecipeResultProjectRoot(runtime.recipeResult)}
</p>
{runtime.cleanupLastError ? (
<p className="mt-0.5 truncate text-xs text-destructive">{runtime.cleanupLastError}</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
{runtime.cleanupStatus === 'failed' ? (
<Button
type="button"
variant="ghost"
size="xs"
className="gap-1.5 text-muted-foreground hover:text-foreground"
onClick={onCopyCleanupCommand}
disabled={disabled}
>
<Copy className="size-3" />
{translate(
'auto.components.settings.EphemeralVmRuntimesSection.copyCleanup',
'Copy command'
)}
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="xs"
className="gap-1.5 text-muted-foreground hover:text-foreground"
onClick={onCleanup}
disabled={disabled}
>
{isCleaning ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
{cleanupFailed
? translate(
'auto.components.settings.EphemeralVmRuntimesSection.retry',
'Retry cleanup'
)
: translate('auto.components.settings.EphemeralVmRuntimesSection.cleanup', 'Cleanup')}
</Button>
</div>
</div>
)
}
@@ -1123,7 +1123,7 @@ export function RuntimeEnvironmentsPane({
<div className={cn('space-y-5 pt-2', visibleWorkflow !== 'cloud-vm' && 'hidden')}>
<CloudVmSetupGuide />
<EphemeralVmRuntimesSection />
<EphemeralVmRuntimesSection active={visibleWorkflow === 'cloud-vm'} />
</div>
<div
@@ -0,0 +1,94 @@
import { renderToStaticMarkup } from 'react-dom/server'
import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/repo-types'
const mocks = vi.hoisted(() => ({
state: {
activeModal: 'confirm-remove-folder' as string | null,
modalData: {
repoId: 'repo-1',
displayName: 'Example',
hostId: 'ssh:target-1'
} as Record<string, unknown>,
repos: [] as Repo[],
sshTargetLabels: new Map<string, string>(),
removedSshTargetLabels: new Map<string, string>(),
closeModal: vi.fn(),
removeProject: vi.fn()
}
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state)
}))
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
open ? <div>{children}</div> : null,
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children: ReactNode }) => <footer>{children}</footer>,
DialogHeader: ({ children }: { children: ReactNode }) => <header>{children}</header>,
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>
}))
vi.mock('@/components/ui/button', () => ({
Button: ({ children }: { children: ReactNode }) => <button>{children}</button>
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record<string, string>) =>
Object.entries(values ?? {}).reduce(
(text, [key, value]) => text.replace(`{{${key}}}`, value),
fallback
)
}))
import RemoveFolderDialog from './RemoveFolderDialog'
function repo(connectionId: string | null, executionHostId: Repo['executionHostId']): Repo {
return {
id: 'repo-1',
path: '/workspace/example',
displayName: 'Example',
badgeColor: '#000',
addedAt: 1,
kind: 'git',
connectionId,
executionHostId
}
}
describe('RemoveFolderDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.state.modalData = {
repoId: 'repo-1',
displayName: 'Example',
hostId: 'ssh:target-1'
}
mocks.state.sshTargetLabels = new Map([['target-1', 'Persistent host']])
mocks.state.removedSshTargetLabels = new Map()
})
it('warns that VM recipe cleanup controls file deletion', () => {
mocks.state.modalData.hostId = 'ssh:runtime-ssh-runtime-1'
mocks.state.repos = [repo('runtime-ssh-runtime-1', 'ssh:runtime-ssh-runtime-1')]
const html = renderToStaticMarkup(<RemoveFolderDialog />)
expect(html).toContain('Its VM recipe determines whether the environment')
expect(html).toContain('files are permanently deleted')
expect(html).not.toContain('Its files stay on')
})
it('keeps the file-preservation promise for ordinary SSH projects', () => {
mocks.state.repos = [repo('target-1', 'ssh:target-1')]
const html = renderToStaticMarkup(<RemoveFolderDialog />)
expect(html).toContain('Its files stay on Persistent host')
expect(html).not.toContain('VM recipe')
})
})
@@ -10,7 +10,11 @@ import {
import { Button } from '@/components/ui/button'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
import {
getRepoExecutionHostId,
isRuntimeOwnedSshTargetId,
type ExecutionHostId
} from '../../../../shared/execution-host'
// Why: interpolated into the sentence so locales control where the name sits;
// U+0000 cannot appear in a real project name, so the split is unambiguous.
@@ -31,34 +35,43 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() {
// user's — "still on your disk" would be misleading. Name the host (using the
// removed-target label when it's a ghost) so the user knows where it remains
// and that re-adding that host recovers it.
const sshConnectionId = useAppStore(
(s) =>
s.repos
.find((repo) => repo.id === repoId && (!hostId || getRepoExecutionHostId(repo) === hostId))
?.connectionId?.trim() ?? null
)
const sshHostLabel = useAppStore((s) => {
const connectionId = s.repos
.find((repo) => repo.id === repoId && (!hostId || getRepoExecutionHostId(repo) === hostId))
?.connectionId?.trim()
if (!connectionId) {
if (!sshConnectionId) {
return null
}
return (
s.sshTargetLabels.get(connectionId) ??
s.removedSshTargetLabels.get(connectionId) ??
connectionId
s.sshTargetLabels.get(sshConnectionId) ??
s.removedSshTargetLabels.get(sshConnectionId) ??
sshConnectionId
)
})
// Why: fragment concatenation around the styled name cannot be reordered by
// SOV locales (#9294). Translate one full sentence with the name as a
// sentinel token, then split on it to re-apply the inline emphasis.
const description = sshHostLabel
const description = isRuntimeOwnedSshTargetId(sshConnectionId)
? translate(
'auto.components.sidebar.RemoveFolderDialog.removeDescriptionSsh',
'This only removes {{name}} from Orca. Its files stay on {{host}} — re-add that SSH host to recover it.',
{ name: NAME_TOKEN, host: sshHostLabel }
)
: translate(
'auto.components.sidebar.RemoveFolderDialog.removeDescriptionLocal',
'This only removes {{name}} from Orca. It is still on your disk.',
'auto.components.sidebar.RemoveFolderDialog.removeDescriptionVmRecipe',
'This removes {{name}} from Orca. Its VM recipe determines whether the environment and its files are permanently deleted.',
{ name: NAME_TOKEN }
)
: sshHostLabel
? translate(
'auto.components.sidebar.RemoveFolderDialog.removeDescriptionSsh',
'This only removes {{name}} from Orca. Its files stay on {{host}} — re-add that SSH host to recover it.',
{ name: NAME_TOKEN, host: sshHostLabel }
)
: translate(
'auto.components.sidebar.RemoveFolderDialog.removeDescriptionLocal',
'This only removes {{name}} from Orca. It is still on your disk.',
{ name: NAME_TOKEN }
)
const [descriptionBeforeName, descriptionAfterName] = description.split(NAME_TOKEN)
const handleConfirm = useCallback(() => {
@@ -36,6 +36,7 @@ function setState(overrides: Record<string, unknown> = {}): void {
hideWorkspacesFromOtherDevices: false,
setHideWorkspacesFromOtherDevices: vi.fn(),
runtimeEnvironments: [],
runtimeEnvironmentCatalogHydrated: true,
alwaysShowDefaultBranchWorkspace: true,
setAlwaysShowDefaultBranchWorkspace: vi.fn(),
...overrides
@@ -96,8 +97,15 @@ describe('SidebarWorkspaceFilterSection', () => {
expect(rowLabels()).toContain('Hide other-client workspaces')
})
it('keeps the other-client filter visible while the remote catalog loads', () => {
setState({ runtimeEnvironmentCatalogHydrated: false })
render()
expect(rowLabels()).toContain('Hide other-client workspaces')
})
it('hides the other-client filter for local-only clients', () => {
setState()
setState({ runtimeEnvironmentCatalogHydrated: true })
render()
expect(rowLabels()).not.toContain('Hide other-client workspaces')
@@ -27,11 +27,15 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter
const hideWorkspacesFromOtherDevices = useAppStore((s) => s.hideWorkspacesFromOtherDevices)
const setHideWorkspacesFromOtherDevices = useAppStore((s) => s.setHideWorkspacesFromOtherDevices)
const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
const runtimeEnvironmentCatalogHydrated = useAppStore((s) => s.runtimeEnvironmentCatalogHydrated)
const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace)
const setAlwaysShowDefaultBranchWorkspace = useAppStore(
(s) => s.setAlwaysShowDefaultBranchWorkspace
)
const showOtherClientFilter = runtimeEnvironments.length > 0 || hideWorkspacesFromOtherDevices
const showOtherClientFilter =
!runtimeEnvironmentCatalogHydrated ||
runtimeEnvironments.length > 0 ||
hideWorkspacesFromOtherDevices
return (
<>
@@ -195,6 +195,8 @@ import { useVisibleTerminalTabClaim } from './use-visible-terminal-tab-claim'
import { TerminalSshReconnectOverlay } from './TerminalSshReconnectOverlay'
import { TerminalRemoteRuntimeReconnectBanner } from './TerminalRemoteRuntimeReconnectBanner'
import { selectTerminalTabAgentTypesByLeaf } from './terminal-tab-agent-type-index'
import { resolveProtectedMultilinePasteOptionsForPane } from './terminal-agent-paste-bracketing'
import { resolveTerminalInputHostPlatform } from './terminal-input-host-platform'
import { canContinueAgentSessionInNewSession } from './terminal-agent-session-continuation'
import {
updateTerminalRemoteRuntimeRecoveryUiState,
@@ -1936,6 +1938,7 @@ function TerminalPane(
},
forceBracketedPaste: options?.forceBracketedPaste,
forceBracketedPasteForMultiline: options?.forceBracketedPasteForMultiline,
windowsInputRecordNewline: options?.windowsInputRecordNewline,
terminalBracketedPasteMode: pane.terminal.modes.bracketedPasteMode
})
const execution = await executeTerminalPastePlan(plan, {
@@ -1966,6 +1969,28 @@ function TerminalPane(
}
}
// Why: resolved per pane and PTY host; split siblings and remote hosts can need
// different multiline paste protocols.
const resolvePaneProtectedMultilinePasteOptions = (
pane: ManagedPane
): TerminalPasteTextOptions | undefined => {
const state = useAppStore.getState()
const transport = paneTransportsRef.current.get(pane.id) ?? null
return resolveProtectedMultilinePasteOptionsForPane({
isWindowsClient: forceBracketedMultilineTextPaste,
hostPlatform: resolveTerminalInputHostPlatform({
clientPlatform: shortcutPlatform,
state,
worktreeId,
transport
}),
agentStatusByPaneKey: state.agentStatusByPaneKey,
paneForegroundAgentByPaneKey: state.paneForegroundAgentByPaneKey,
tabId,
leafId: pane.leafId
})
}
const pasteFromClipboard = (
pane: ManagedPane,
source: Extract<TerminalPasteSource, 'keyboard' | 'paste-event'>,
@@ -1983,7 +2008,7 @@ function TerminalPane(
saveClipboardImageAsTempFile: window.api.ui.saveClipboardImageAsTempFile,
connectionId,
runtimeEnvironmentId,
forceBracketedMultilineTextPaste,
protectedMultilineTextPasteOptions: resolvePaneProtectedMultilinePasteOptions(pane),
pasteText: (text, options) =>
executePanePasteText(pane, source, activeElementAtDispatch, text, options),
onTextPasteError: () =>
@@ -2130,7 +2155,7 @@ function TerminalPane(
saveClipboardImageAsTempFile: window.api.ui.saveClipboardImageAsTempFile,
connectionId,
runtimeEnvironmentId,
forceBracketedMultilineTextPaste,
protectedMultilineTextPasteOptions: resolvePaneProtectedMultilinePasteOptions(pane),
pasteText: (text, options) =>
executePanePasteText(pane, 'app-menu', activeElementAtDispatch, text, options),
onTextPasteError: () =>
@@ -2707,6 +2732,7 @@ function TerminalPane(
? 'win32'
: 'linux'
const connectionId = getConnectionId(worktreeId) ?? null
const pasteState = useAppStore.getState()
const targetStillMounted = (): boolean => {
const manager = managerRef.current
return Boolean(
@@ -2738,6 +2764,19 @@ function TerminalPane(
transport
})
},
...resolveProtectedMultilinePasteOptionsForPane({
isWindowsClient: forceBracketedMultilineTextPaste,
hostPlatform: resolveTerminalInputHostPlatform({
clientPlatform: shortcutPlatform,
state: pasteState,
worktreeId,
transport: transport ?? null
}),
agentStatusByPaneKey: pasteState.agentStatusByPaneKey,
paneForegroundAgentByPaneKey: pasteState.paneForegroundAgentByPaneKey,
tabId,
leafId: clickedPane.leafId
}),
terminalBracketedPasteMode: clickedPane.terminal.modes.bracketedPasteMode
})
const execution = await executeTerminalPastePlan(plan, {
@@ -2754,7 +2793,7 @@ function TerminalPane(
recordTerminalUserInputForLeaf(tabId, clickedPane.leafId)
})
},
[getPrimarySelectionMiddleClickPane, tabId, worktreeId]
[getPrimarySelectionMiddleClickPane, forceBracketedMultilineTextPaste, tabId, worktreeId]
)
const handlePrimarySelectionAuxClick = useCallback(
@@ -0,0 +1,217 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { PaneForegroundAgentEntry } from '../../store/slices/pane-foreground-agent'
import { resolveProtectedMultilinePasteOptionsForPane } from './terminal-agent-paste-bracketing'
import { pasteTerminalText } from './terminal-bracketed-paste'
import {
executeTerminalPastePlan,
planTerminalPaste,
type TerminalPasteTarget,
type TerminalPasteTextOptions
} from './terminal-paste-coordinator'
import { resolveTerminalPasteRuntime } from './terminal-paste-runtime'
const TAB_ID = 'tab-1'
const AGENT_LEAF = '11111111-1111-4111-8111-111111111111'
const SHELL_LEAF = '22222222-2222-4222-8222-222222222222'
const AGENT_PANE_KEY = `${TAB_ID}:${AGENT_LEAF}`
function agentEntry(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry {
return {
state: 'waiting',
prompt: '',
updatedAt: 0,
stateStartedAt: 0,
stateHistory: [],
agentType: 'codex',
paneKey: AGENT_PANE_KEY,
...overrides
}
}
function foregroundEntry(
overrides: Partial<PaneForegroundAgentEntry> = {}
): PaneForegroundAgentEntry {
return { agent: null, shellForeground: false, ...overrides }
}
const CODEX_ON_AGENT_LEAF = { [AGENT_PANE_KEY]: agentEntry() }
function decide(
args: Partial<Parameters<typeof resolveProtectedMultilinePasteOptionsForPane>[0]> = {}
): TerminalPasteTextOptions | undefined {
return resolveProtectedMultilinePasteOptionsForPane({
isWindowsClient: false,
hostPlatform: 'linux',
agentStatusByPaneKey: CODEX_ON_AGENT_LEAF,
paneForegroundAgentByPaneKey: {},
tabId: TAB_ID,
leafId: AGENT_LEAF,
...args
})
}
describe('resolveProtectedMultilinePasteOptionsForPane', () => {
it('brackets an agent pane on a non-Windows host', () => {
expect(decide()).toEqual({ forceBracketedPasteForMultiline: true })
})
it('leaves a plain shell pane alone so ESC[200~ never reaches a non-TUI program', () => {
expect(decide({ leafId: SHELL_LEAF })).toBeUndefined()
})
it('gates per leaf, not per tab', () => {
expect(decide({ tabId: 'other-tab' })).toBeUndefined()
})
it('keeps the existing Windows-client behaviour for non-agent panes', () => {
expect(decide({ isWindowsClient: true, agentStatusByPaneKey: {}, leafId: SHELL_LEAF })).toEqual(
{ forceBracketedPasteForMultiline: true }
)
})
it('ignores a pane whose agentType is not a TUI agent', () => {
expect(
decide({
agentStatusByPaneKey: {
[AGENT_PANE_KEY]: agentEntry({
agentType: 'not-an-agent' as AgentStatusEntry['agentType']
})
}
})
).toBeUndefined()
})
it('brackets on a process-confirmed agent even with no status row', () => {
expect(
decide({
agentStatusByPaneKey: {},
paneForegroundAgentByPaneKey: {
[AGENT_PANE_KEY]: foregroundEntry({ agent: 'codex', shellForeground: false })
}
})
).toEqual({ forceBracketedPasteForMultiline: true })
})
it('vetoes a row rehydrated from disk across an app restart', () => {
expect(
decide({
agentStatusByPaneKey: {
[AGENT_PANE_KEY]: agentEntry({ restoredUnconfirmed: true })
}
})
).toBeUndefined()
})
it('still brackets a long-idle agent parked at done', () => {
// Why: an idle-but-live agent sits at `done` with an old updatedAt. Gating on
// state or the 30-minute freshness TTL would strip bracketing from exactly the
// pane that needs it most.
expect(
decide({
agentStatusByPaneKey: {
[AGENT_PANE_KEY]: agentEntry({ state: 'done', updatedAt: 0, stateStartedAt: 0 })
}
})
).toEqual({ forceBracketedPasteForMultiline: true })
})
it('degrades to the pre-fix path instead of throwing on a malformed pane key', () => {
// Why: makePaneKey throws on a non-UUID leaf or a tabId containing ':'. The throw
// would escape before the paste helper's catch is attached, making the paste a
// silent no-op with no error surface.
expect(() => decide({ leafId: 'not-a-uuid' })).not.toThrow()
expect(decide({ leafId: 'not-a-uuid' })).toBeUndefined()
expect(() => decide({ tabId: 'tab:with:colons' })).not.toThrow()
expect(decide({ tabId: 'tab:with:colons' })).toBeUndefined()
})
it('still brackets when shellForeground is latched true while an agent owns the pane', () => {
// Why: shellForeground is republished only at OSC 133 boundaries, so a shell with
// no 133 integration leaves it true while an agent runs. Measured live: vetoing on
// it reinstated the submit bug.
expect(
decide({
paneForegroundAgentByPaneKey: {
[AGENT_PANE_KEY]: foregroundEntry({ shellForeground: true })
}
})
).toEqual({ forceBracketedPasteForMultiline: true })
})
it('uses modified Enter for an agent that reads Windows input records', () => {
expect(decide({ hostPlatform: 'win32' })).toEqual({
windowsInputRecordNewline: 'alt-enter'
})
})
it('does not change unverified Windows agent paste protocols', () => {
expect(
decide({
hostPlatform: 'win32',
agentStatusByPaneKey: {
[AGENT_PANE_KEY]: agentEntry({ agentType: 'pi' })
}
})
).toEqual({ forceBracketedPasteForMultiline: true })
})
})
describe('leading-newline paste into a remote agent pane', () => {
// Regression: a mac client on a remote Windows host pasted a block starting with "\n".
// ConPTY never forwarded DECSET 2004, so xterm rewrote the newline to CR and codex
// submitted the draft parked in its composer.
const PASTED = '\nRemember: At the end of the day, we want the best possible code.'
function remoteWindowsTarget(): TerminalPasteTarget {
return {
kind: 'terminal',
paneId: 1,
leafId: AGENT_LEAF,
ptyId: 'remote:host-abc/pty-1',
runtime: resolveTerminalPasteRuntime({
platform: 'win32',
ptyId: 'remote:host-abc/pty-1',
isWindowsConpty: false
})
}
}
it('encodes the leading newline as modified Enter instead of submit', async () => {
const terminal = {
modes: { bracketedPasteMode: false },
options: { ignoreBracketedPasteMode: false },
input: vi.fn(),
paste: vi.fn()
}
const plan = planTerminalPaste({
text: PASTED,
source: 'keyboard',
target: remoteWindowsTarget(),
terminalBracketedPasteMode: false,
...decide({ hostPlatform: 'win32' })
})
await executeTerminalPastePlan(plan, {
pasteText: (text, options) => pasteTerminalText(terminal, text, options)
})
expect(plan.payload.lineCount).toBe(2)
expect(plan.mode).toBe('windows-input-record')
expect(plan.bracketed).toBe(false)
expect(terminal.input).toHaveBeenCalledWith(`\x1b\r${PASTED.slice(1)}`)
expect(terminal.paste).not.toHaveBeenCalled()
})
it('a single-line paste stays on the direct path', () => {
const plan = planTerminalPaste({
text: 'no newline here',
source: 'keyboard',
target: remoteWindowsTarget(),
terminalBracketedPasteMode: false,
...decide({ hostPlatform: 'win32' })
})
expect(plan.mode).toBe('direct')
})
})
@@ -0,0 +1,93 @@
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { isTuiAgent, TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import type { PaneForegroundAgentEntry } from '../../store/slices/pane-foreground-agent'
import type { TerminalPasteTextOptions } from './terminal-paste-model'
/**
* Why: xterm brackets a paste only after seeing DECSET 2004, which can be lost by
* remote replay or ConPTY. Unprotected CR submits an agent's parked draft.
*
* Why keyed on the pane's agent and not on the mode bit itself: "we never observed
* DECSET 2004" is not usable evidence. Measured in real ptys — zsh 5.9, fish 4.8.1 and
* bash >= 5.1 emit `?2004h` at the prompt and `?2004l` just before exec, but macOS
* /bin/bash 3.2, /bin/sh, bash 4.4, and any shell with bracketed paste turned off in
* .inputrc/zle emit *nothing at all*. A deliberate opt-out is byte-identical to a bare
* `cat`. So silence cannot be read as "nobody has an opinion".
*
* Agent identity disambiguates silence because a TUI agent enables bracketed paste.
* A verified Windows input-record agent cannot receive paste frames; for that explicit
* capability, use the same modified-Enter newline contract as Shift+Enter.
*
* Why this diverges from terminal-ctrl-enter / terminal-windows-shift-enter, which veto
* on shellForeground/routingRevoked and require routingTrusted: those resolvers decide
* where to ROUTE input bytes, so a forged identity misdelivers keystrokes. This one only
* decides how to encode a user-requested paste, whose ESC bytes are sanitized downstream.
*/
export function resolveProtectedMultilinePasteOptionsForPane({
isWindowsClient,
hostPlatform,
agentStatusByPaneKey,
paneForegroundAgentByPaneKey,
tabId,
leafId
}: {
isWindowsClient: boolean
hostPlatform: NodeJS.Platform
agentStatusByPaneKey: Record<string, AgentStatusEntry>
paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry>
tabId: string
leafId: string
}): TerminalPasteTextOptions | undefined {
let paneKey: string
try {
paneKey = makePaneKey(tabId, leafId)
} catch {
// Why: unreachable today (pane.leafId is a minted UUID), but a legacy/malformed
// layout must degrade to the pre-fix path, not throw out of the paste handler --
// the throw would escape before pasteTerminalClipboard's catch is attached and
// the paste would be a silent no-op with no error surface.
return isWindowsClient ? { forceBracketedPasteForMultiline: true } : undefined
}
return resolveProtectedMultilinePasteOptionsForAgentEvidence({
isWindowsClient,
hostPlatform,
foregroundAgent: paneForegroundAgentByPaneKey[paneKey]?.agent,
entry: agentStatusByPaneKey[paneKey]
})
}
export function resolveProtectedMultilinePasteOptionsForAgentEvidence({
isWindowsClient,
hostPlatform,
foregroundAgent,
entry
}: {
isWindowsClient: boolean
hostPlatform: NodeJS.Platform
foregroundAgent: unknown
entry: AgentStatusEntry | undefined
}): TerminalPasteTextOptions | undefined {
// Why: a row rehydrated from disk across a restart describes the previous process,
// not the shell now attached to this pane.
const agent = isTuiAgent(foregroundAgent)
? foregroundAgent
: entry?.restoredUnconfirmed !== true && isTuiAgent(entry?.agentType)
? entry.agentType
: null
// Why NOT vetoed on shellForeground: that flag is republished only at OSC 133
// boundaries, so a shell without 133 integration leaves it latched true while an
// agent owns the foreground. Vetoing on it silently reinstates the submit bug —
// measured live. An idle agent also sits at `done` past the 30-minute freshness
// TTL, so neither state nor TTL can gate this either. A false negative sends the
// user's parked draft; a false positive only changes encoding within this paste.
const windowsInputRecordPasteNewline = agent
? TUI_AGENT_CONFIG[agent].windowsInputRecordPasteNewline
: undefined
if (hostPlatform === 'win32' && windowsInputRecordPasteNewline) {
return {
windowsInputRecordNewline: windowsInputRecordPasteNewline
}
}
return isWindowsClient || agent ? { forceBracketedPasteForMultiline: true } : undefined
}
@@ -111,6 +111,17 @@ describe('terminal bracketed paste policy', () => {
expect(terminal.paste).not.toHaveBeenCalled()
})
it('encodes Windows input-record newlines atomically and sanitizes escape bytes', () => {
const terminal = createTerminal(false)
pasteTerminalText(terminal, '\none\r\ntwo\x1b[201~', {
windowsInputRecordNewline: 'alt-enter'
})
expect(terminal.input).toHaveBeenCalledWith('\x1b\rone\x1b\rtwo␛[201~')
expect(terminal.paste).not.toHaveBeenCalled()
})
it('does not change paste behavior when Ctrl+C happened outside bracketed paste mode', () => {
const terminal = createTerminal(false)
const observedIgnoreValues: (boolean | undefined)[] = []
@@ -1,4 +1,5 @@
import type { Terminal } from '@xterm/xterm'
import type { WindowsInputRecordNewline } from './terminal-paste-model'
type BracketedPasteTerminal = {
modes: {
@@ -14,6 +15,7 @@ type PasteTerminal = BracketedPasteTerminal & {
type PasteTerminalTextOptions = {
forceBracketedPaste?: boolean
windowsInputRecordNewline?: WindowsInputRecordNewline
}
const interruptedBracketedPasteTerminals = new WeakSet<object>()
@@ -77,6 +79,28 @@ export function wrapTerminalBracketedPasteText(text: string): string {
return `${BRACKETED_PASTE_START}${sanitizeBracketedPasteText(normalizedText)}${BRACKETED_PASTE_END}`
}
export function encodeWindowsInputRecordPasteText(
text: string,
newline: WindowsInputRecordNewline
): string {
const newlineSequence = newline === 'csi-u' ? '\x1b[13;2u' : '\x1b\r'
let encoded = ''
for (let index = 0; index < text.length; index += 1) {
const char = text[index]
if (char === '\r') {
encoded += newlineSequence
if (text[index + 1] === '\n') {
index += 1
}
} else if (char === '\n') {
encoded += newlineSequence
} else {
encoded += char === ESCAPE ? '\u241b' : char
}
}
return encoded
}
function forceBracketedPaste(terminal: PasteTerminal, text: string): void {
// Why: forced callers already built the exact paste protocol bytes. Send
// them as PTY input so xterm's DOM/native paste machinery cannot defer them.
@@ -110,6 +134,12 @@ export function pasteTerminalText(
text: string,
options?: PasteTerminalTextOptions
): void {
if (options?.windowsInputRecordNewline) {
// Why: input-record TUIs see bracket markers as keys; modified Enter preserves
// pasted newlines without turning the first one into submit.
terminal.input(encodeWindowsInputRecordPasteText(text, options.windowsInputRecordNewline))
return
}
if (options?.forceBracketedPaste) {
// Why: generated image paths are paste payloads, even when they are a
// single line, so they must bypass stale Ctrl+C plain-text suppression.
@@ -308,6 +308,21 @@ describe('terminal clipboard paste', () => {
})
})
it('delegates Windows input-record newline protection without scanning text', async () => {
const pasteText = vi.fn()
await pasteTerminalClipboard({
readClipboardText: vi.fn().mockResolvedValue('\nline two'),
saveClipboardImageAsTempFile: vi.fn(),
pasteText,
protectedMultilineTextPasteOptions: { windowsInputRecordNewline: 'alt-enter' }
})
expect(pasteText).toHaveBeenCalledWith('\nline two', {
windowsInputRecordNewline: 'alt-enter'
})
})
it('keeps single-line text on the ordinary paste path when Windows multi-line protection is on', async () => {
const saveClipboardImageAsTempFile = vi.fn()
const pasteText = vi.fn()
@@ -22,6 +22,7 @@ type PasteTerminalClipboardDeps = {
connectionId?: string | null
runtimeEnvironmentId?: string | null
forceBracketedMultilineTextPaste?: boolean
protectedMultilineTextPasteOptions?: TerminalPasteTextOptions
onTextPasteError?: (error: unknown) => void
onImagePasteError?: (error: unknown) => void
}
@@ -46,6 +47,7 @@ export async function pasteTerminalClipboard({
connectionId,
runtimeEnvironmentId,
forceBracketedMultilineTextPaste = false,
protectedMultilineTextPasteOptions,
onTextPasteError,
onImagePasteError
}: PasteTerminalClipboardDeps): Promise<TerminalClipboardPasteResult> {
@@ -62,9 +64,10 @@ export async function pasteTerminalClipboard({
}
if (text) {
try {
const result = await (forceBracketedMultilineTextPaste
? pasteText(text, { forceBracketedPasteForMultiline: true })
: pasteText(text))
const textOptions =
protectedMultilineTextPasteOptions ??
(forceBracketedMultilineTextPaste ? { forceBracketedPasteForMultiline: true } : undefined)
const result = await (textOptions ? pasteText(text, textOptions) : pasteText(text))
if (result === false) {
return { status: 'skipped', reason: 'text-paste-rejected' }
}
@@ -17,15 +17,19 @@ export function chunkTerminalPastePlan(plan: TerminalPastePlan): string[] {
}
export function* iterateTerminalPastePlanChunks(plan: TerminalPastePlan): Generator<string> {
const maxChunkBytes = Math.max(4, plan.maxChunkBytes ?? TERMINAL_PASTE_CHUNK_MAX_BYTES)
const maxChunkBytes = Math.max(
plan.windowsInputRecordNewline === 'csi-u' ? 8 : 4,
plan.maxChunkBytes ?? TERMINAL_PASTE_CHUNK_MAX_BYTES
)
if (plan.bracketed) {
yield BRACKETED_PASTE_START
}
yield* iterateTextByUtf8Bytes(
plan.payload.plainText,
maxChunkBytes,
plan.bracketed,
plan.newlinePolicy === 'terminal-cr'
plan.bracketed || plan.newlinePolicy === 'windows-input-record',
plan.newlinePolicy,
plan.windowsInputRecordNewline
)
if (plan.bracketed) {
yield BRACKETED_PASTE_END
@@ -36,7 +40,8 @@ function* iterateTextByUtf8Bytes(
text: string,
maxBytes: number,
sanitizeEscapes: boolean,
normalizeLineEndings: boolean
newlinePolicy: TerminalPastePlan['newlinePolicy'],
windowsInputRecordNewline?: TerminalPastePlan['windowsInputRecordNewline']
): Generator<string> {
let chunk = ''
let chunkBytes = 0
@@ -45,26 +50,34 @@ function* iterateTextByUtf8Bytes(
const codeUnitLength = codePoint > 0xffff ? 2 : 1
// Why: iterator normalization avoids a full-size copy and keeps CRLF atomic across chunks.
if (
normalizeLineEndings &&
newlinePolicy !== 'preserve' &&
codePoint === LINE_FEED_CODE_POINT &&
index > 0 &&
text.charCodeAt(index - 1) === CARRIAGE_RETURN_CODE_POINT
) {
continue
}
const normalizedCodePoint =
normalizeLineEndings && codePoint === LINE_FEED_CODE_POINT
? CARRIAGE_RETURN_CODE_POINT
: codePoint
const isLineEnding =
newlinePolicy !== 'preserve' &&
(codePoint === LINE_FEED_CODE_POINT || codePoint === CARRIAGE_RETURN_CODE_POINT)
const normalizedCodePoint = isLineEnding ? CARRIAGE_RETURN_CODE_POINT : codePoint
const sanitizedEscape = sanitizeEscapes && codePoint === TERMINAL_PASTE_ESCAPE_CODE_POINT
const next = sanitizedEscape
? TERMINAL_PASTE_INERT_ESCAPE
: normalizedCodePoint === codePoint
? text.slice(index, index + codeUnitLength)
: '\r'
const nextBytes = getUtf8ByteLengthForCodePoint(
sanitizedEscape ? TERMINAL_PASTE_INERT_ESCAPE_CODE_POINT : normalizedCodePoint
)
const next =
isLineEnding && newlinePolicy === 'windows-input-record'
? windowsInputRecordNewline === 'csi-u'
? '\x1b[13;2u'
: '\x1b\r'
: sanitizedEscape
? TERMINAL_PASTE_INERT_ESCAPE
: normalizedCodePoint === codePoint
? text.slice(index, index + codeUnitLength)
: '\r'
const nextBytes =
isLineEnding && newlinePolicy === 'windows-input-record'
? next.length
: getUtf8ByteLengthForCodePoint(
sanitizedEscape ? TERMINAL_PASTE_INERT_ESCAPE_CODE_POINT : normalizedCodePoint
)
if (chunk && chunkBytes + nextBytes > maxBytes) {
yield chunk
chunk = next
@@ -242,6 +242,64 @@ describe('terminal paste coordinator', () => {
expect(chunks.join('')).not.toContain('\n')
})
it('streams large Windows input-record paste with atomic modified Enter newlines', () => {
const plan = planTerminalPaste({
text: '\nabc\r\ndef\x1b[201~ghi',
source: 'keyboard',
target: terminalTarget(),
windowsInputRecordNewline: 'alt-enter',
terminalBracketedPasteMode: true,
maxDirectBytes: 4,
maxChunkBytes: 4
})
const chunks = chunkTerminalPastePlan(plan)
expect(plan.mode).toBe('chunked')
expect(plan.bracketed).toBe(false)
expect(plan.newlinePolicy).toBe('windows-input-record')
expect(chunks.join('')).toBe('\x1b\rabc\x1b\rdef␛[201~ghi')
expect(chunks).not.toContain('\x1b[200~')
expect(chunks).not.toContain('\x1b[201~')
})
it('accounts for modified-Enter expansion before choosing bounded chunks', () => {
const plan = planTerminalPaste({
text: '\n\n\n\n',
source: 'keyboard',
target: terminalTarget(),
windowsInputRecordNewline: 'alt-enter',
maxDirectBytes: 6,
maxBytes: 16
})
expect(plan.mode).toBe('chunked')
expect(chunkTerminalPastePlan(plan).join('')).toBe('\x1b\r\x1b\r\x1b\r\x1b\r')
})
it('counts CRLF by its exact encoded size at paste limits', () => {
const altEnter = planTerminalPaste({
text: 'a\r\n',
source: 'keyboard',
target: terminalTarget(),
windowsInputRecordNewline: 'alt-enter',
maxDirectBytes: 3,
maxBytes: 3
})
const csiU = planTerminalPaste({
text: 'a\r\n',
source: 'keyboard',
target: terminalTarget(),
windowsInputRecordNewline: 'csi-u',
maxDirectBytes: 8,
maxBytes: 8
})
expect(altEnter.mode).toBe('windows-input-record')
expect(chunkTerminalPastePlan(altEnter).join('')).toBe('a\x1b\r')
expect(csiU.mode).toBe('windows-input-record')
expect(chunkTerminalPastePlan(csiU).join('')).toBe('a\x1b[13;2u')
})
it('chunks escape-heavy bracketed paste without per-character string sanitizer scans', () => {
const text = Array.from({ length: 64 }, (_value, index) => `part-${index}\x1b[201~`).join('')
const plan = planTerminalPaste({
@@ -12,7 +12,8 @@ import type {
TerminalPastePayload,
TerminalPastePlan,
TerminalPasteSource,
TerminalPasteTarget
TerminalPasteTarget,
WindowsInputRecordNewline
} from './terminal-paste-model'
export {
@@ -42,6 +43,7 @@ type PlanTerminalPasteArgs = {
target: TerminalPasteTarget
forceBracketedPaste?: boolean
forceBracketedPasteForMultiline?: boolean
windowsInputRecordNewline?: WindowsInputRecordNewline
terminalBracketedPasteMode?: boolean
hasRichText?: boolean
maxDirectBytes?: number
@@ -72,7 +74,8 @@ export function createTerminalPastePayload({
byteLength: metadata.byteLength,
lineCount: metadata.lineCount,
hasRichText,
hasControlSequences: metadata.hasControlSequences
hasControlSequences: metadata.hasControlSequences,
lineEndingByteLength: metadata.lineEndingByteLength
}
}
@@ -82,6 +85,7 @@ export function planTerminalPaste({
target,
forceBracketedPaste = false,
forceBracketedPasteForMultiline = false,
windowsInputRecordNewline,
terminalBracketedPasteMode = false,
hasRichText = false,
maxDirectBytes = TERMINAL_PASTE_DIRECT_MAX_BYTES,
@@ -92,6 +96,7 @@ export function planTerminalPaste({
return buildTerminalPastePlan({
forceBracketedPaste,
forceBracketedPasteForMultiline,
windowsInputRecordNewline,
maxBytes,
maxChunkBytes,
maxDirectBytes,
@@ -107,6 +112,7 @@ export async function planTerminalPasteWithYield({
target,
forceBracketedPaste = false,
forceBracketedPasteForMultiline = false,
windowsInputRecordNewline,
terminalBracketedPasteMode = false,
hasRichText = false,
maxDirectBytes = TERMINAL_PASTE_DIRECT_MAX_BYTES,
@@ -126,11 +132,13 @@ export async function planTerminalPasteWithYield({
byteLength: metadata.byteLength,
lineCount: metadata.lineCount,
hasRichText,
hasControlSequences: metadata.hasControlSequences
hasControlSequences: metadata.hasControlSequences,
lineEndingByteLength: metadata.lineEndingByteLength
}
return buildTerminalPastePlan({
forceBracketedPaste,
forceBracketedPasteForMultiline,
windowsInputRecordNewline,
maxBytes,
maxChunkBytes,
maxDirectBytes,
@@ -145,6 +153,7 @@ function buildTerminalPastePlan({
target,
forceBracketedPaste,
forceBracketedPasteForMultiline,
windowsInputRecordNewline,
terminalBracketedPasteMode,
maxDirectBytes,
maxChunkBytes,
@@ -154,18 +163,32 @@ function buildTerminalPastePlan({
target: TerminalPasteTarget
forceBracketedPaste: boolean
forceBracketedPasteForMultiline: boolean
windowsInputRecordNewline?: WindowsInputRecordNewline
terminalBracketedPasteMode: boolean
maxDirectBytes: number
maxChunkBytes: number
maxBytes: number
}): TerminalPastePlan {
const shouldChunk = payload.byteLength > maxDirectBytes
const effectiveWindowsInputRecordNewline =
!forceBracketedPaste && payload.lineCount > 1 ? windowsInputRecordNewline : undefined
const plannedByteLength = effectiveWindowsInputRecordNewline
? payload.byteLength -
payload.lineEndingByteLength +
(payload.lineCount - 1) * (effectiveWindowsInputRecordNewline === 'csi-u' ? 7 : 2)
: payload.byteLength
const shouldChunk = plannedByteLength > maxDirectBytes
const effectiveForceBracketedPaste =
forceBracketedPaste || (forceBracketedPasteForMultiline && payload.lineCount > 1)
const shouldBracketChunk = effectiveForceBracketedPaste || terminalBracketedPasteMode
forceBracketedPaste ||
(effectiveWindowsInputRecordNewline === undefined &&
forceBracketedPasteForMultiline &&
payload.lineCount > 1)
const shouldBracketChunk =
effectiveWindowsInputRecordNewline === undefined &&
(effectiveForceBracketedPaste || terminalBracketedPasteMode)
const mode = choosePasteMode({
byteLength: payload.byteLength,
byteLength: plannedByteLength,
forceBracketedPaste: effectiveForceBracketedPaste,
windowsInputRecordNewline: effectiveWindowsInputRecordNewline,
shouldChunk,
maxBytes
})
@@ -173,7 +196,15 @@ function buildTerminalPastePlan({
target,
payload,
mode,
newlinePolicy: mode === 'chunked' || mode === 'bracketed-terminal' ? 'terminal-cr' : 'preserve',
newlinePolicy:
effectiveWindowsInputRecordNewline !== undefined
? 'windows-input-record'
: mode === 'chunked' || mode === 'bracketed-terminal'
? 'terminal-cr'
: 'preserve',
...(effectiveWindowsInputRecordNewline
? { windowsInputRecordNewline: effectiveWindowsInputRecordNewline }
: {}),
runtimeKey: target.runtime.runtimeKey,
...(shouldChunk ? { maxChunkBytes } : {}),
bracketed: mode === 'bracketed-terminal' || (mode === 'chunked' && shouldBracketChunk),
@@ -189,11 +220,13 @@ function buildTerminalPastePlan({
function choosePasteMode({
byteLength,
forceBracketedPaste,
windowsInputRecordNewline,
shouldChunk,
maxBytes
}: {
byteLength: number
forceBracketedPaste: boolean
windowsInputRecordNewline?: WindowsInputRecordNewline
shouldChunk: boolean
maxBytes: number
}): TerminalPastePlan['mode'] {
@@ -203,5 +236,8 @@ function choosePasteMode({
if (shouldChunk) {
return 'chunked'
}
if (windowsInputRecordNewline !== undefined) {
return 'windows-input-record'
}
return forceBracketedPaste ? 'bracketed-terminal' : 'direct'
}
@@ -62,7 +62,10 @@ async function executeTerminalPastePlanNow(
if (plan.mode !== 'chunked') {
const pasteResult = await runTerminalPasteOperationWithTimeout(() => {
return pasteText(plan.payload.plainText, {
forceBracketedPaste: plan.mode === 'bracketed-terminal'
forceBracketedPaste: plan.mode === 'bracketed-terminal',
...(plan.windowsInputRecordNewline
? { windowsInputRecordNewline: plan.windowsInputRecordNewline }
: {})
})
}, operationTimeoutMs)
if (pasteResult.timedOut) {
@@ -29,15 +29,17 @@ export type TerminalPastePayload = {
lineCount: number
hasRichText: boolean
hasControlSequences: boolean
lineEndingByteLength: number
}
export type TerminalPastePlan = {
target: TerminalPasteTarget
payload: TerminalPastePayload
mode: 'direct' | 'chunked' | 'bracketed-terminal' | 'reject'
mode: 'direct' | 'chunked' | 'bracketed-terminal' | 'windows-input-record' | 'reject'
// Why: 'terminal-cr' marks plans whose paste bytes Orca constructs itself, so
// every write path applies xterm's native \r?\n -> \r before ConPTY sees LF.
newlinePolicy: 'preserve' | 'terminal-cr'
newlinePolicy: 'preserve' | 'terminal-cr' | 'windows-input-record'
windowsInputRecordNewline?: WindowsInputRecordNewline
runtimeKey: string
maxChunkBytes?: number
bracketed: boolean
@@ -48,9 +50,12 @@ export type TerminalPastePlan = {
export type TerminalPasteTextOptions = {
forceBracketedPaste?: boolean
forceBracketedPasteForMultiline?: boolean
windowsInputRecordNewline?: WindowsInputRecordNewline
recoverImagePasteWebglAtlas?: boolean
}
export type WindowsInputRecordNewline = 'alt-enter' | 'csi-u'
export type TerminalPasteExecutionReason =
| 'operation-timeout'
| 'paste-rejected'
@@ -18,6 +18,7 @@ describe('terminal paste payload metadata', () => {
byteLength: textEncoder.encode(text).byteLength,
exceededLimit: false,
hasControlSequences: expected.hasControlSequences,
lineEndingByteLength: text.match(/[\r\n]/g)?.length ?? 0,
lineCount: expected.lineCount
})
}
@@ -30,6 +31,7 @@ describe('terminal paste payload metadata', () => {
byteLength: 26,
exceededLimit: false,
hasControlSequences: true,
lineEndingByteLength: 3,
lineCount: 3
})
expect(utf8ByteLength(text)).toBe(26)
@@ -45,6 +47,7 @@ describe('terminal paste payload metadata', () => {
byteLength: 8,
exceededLimit: true,
hasControlSequences: false,
lineEndingByteLength: 0,
lineCount: 1
})
expect(metadata.byteLength).toBeLessThan(utf8ByteLength(text))
@@ -64,6 +67,7 @@ describe('terminal paste payload metadata', () => {
byteLength: 43,
exceededLimit: false,
hasControlSequences: true,
lineEndingByteLength: 2,
lineCount: 2
})
expect(yieldToEventLoop).toHaveBeenCalled()
@@ -40,6 +40,8 @@ import type { AgentSessionContinuationRequest } from '@/lib/agent-session-contin
import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion'
import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd'
import { useAppStore } from '@/store'
import { resolveProtectedMultilinePasteOptionsForPane } from './terminal-agent-paste-bracketing'
import { resolveTerminalInputHostPlatform } from './terminal-input-host-platform'
import { translate } from '@/i18n/i18n'
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
import { copyTerminalHandleForPane } from './terminal-handle-copy'
@@ -262,6 +264,7 @@ export function useTerminalPaneContextMenu({
},
forceBracketedPaste: options?.forceBracketedPaste,
forceBracketedPasteForMultiline: options?.forceBracketedPasteForMultiline,
windowsInputRecordNewline: options?.windowsInputRecordNewline,
terminalBracketedPasteMode: pane.terminal.modes.bracketedPasteMode
})
const execution = await executeTerminalPastePlan(plan, {
@@ -322,16 +325,27 @@ export function useTerminalPaneContextMenu({
return
}
const connectionId = getConnectionId(worktreeId) ?? null
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(
useAppStore.getState(),
worktreeId
)
const state = useAppStore.getState()
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId)
const transport = paneTransportsRef.current.get(pane.id) ?? null
const result = await pasteTerminalClipboard({
readClipboardText: window.api.ui.readClipboardText,
saveClipboardImageAsTempFile: window.api.ui.saveClipboardImageAsTempFile,
connectionId,
runtimeEnvironmentId,
forceBracketedMultilineTextPaste,
protectedMultilineTextPasteOptions: resolveProtectedMultilinePasteOptionsForPane({
isWindowsClient: forceBracketedMultilineTextPaste,
hostPlatform: resolveTerminalInputHostPlatform({
clientPlatform: getShortcutPlatform(),
state,
worktreeId,
transport
}),
agentStatusByPaneKey: state.agentStatusByPaneKey,
paneForegroundAgentByPaneKey: state.paneForegroundAgentByPaneKey,
tabId,
leafId: pane.leafId
}),
pasteText: (text, options) => executeMenuPasteText(pane, source, text, options),
onTextPasteError: () =>
onPasteError('Paste failed: clipboard text is too large for a safe terminal paste.'),
@@ -6,39 +6,54 @@ import { WorkspaceCleanupFilterBar } from './workspace-cleanup-filter-bar'
afterEach(cleanup)
function renderFilterBar(facetPanelOpen = false): void {
render(
<WorkspaceCleanupFilterBar
facetProps={{
filters: createDefaultWorkspaceCleanupFilterState(),
counts: {
activity: 0,
size: 0,
status: 0,
agent: 0,
git: 0,
review: 0,
ticket: 0,
context: 0,
location: 0,
safety: 0
},
totalCount: 100,
options: { workspaceStatuses: [], hostIds: [], repos: [], reviewProviders: [] },
onPatch: vi.fn()
}}
facetPanelOpen={facetPanelOpen}
onFacetPanelOpenChange={vi.fn()}
activeFacetGroupCount={0}
matchedCount={100}
hasActiveFilters={false}
gitEvidence={{ pendingCount: 0, totalCount: 0 }}
onQueryChange={vi.fn()}
onClearFilters={vi.fn()}
/>
)
}
describe('WorkspaceCleanupFilterBar', () => {
it('keeps size measurement out of the browse controls', () => {
render(
<WorkspaceCleanupFilterBar
facetProps={{
filters: createDefaultWorkspaceCleanupFilterState(),
counts: {
activity: 0,
size: 0,
status: 0,
agent: 0,
git: 0,
review: 0,
ticket: 0,
context: 0,
location: 0,
safety: 0
},
totalCount: 100,
options: { workspaceStatuses: [], hostIds: [], repos: [], reviewProviders: [] },
onPatch: vi.fn()
}}
facetPanelOpen={false}
onFacetPanelOpenChange={vi.fn()}
activeFacetGroupCount={0}
matchedCount={100}
hasActiveFilters={false}
gitEvidence={{ pendingCount: 0, totalCount: 0 }}
onQueryChange={vi.fn()}
onClearFilters={vi.fn()}
/>
)
renderFilterBar()
expect(screen.queryByRole('button', { name: 'Scan' })).toBeNull()
})
it('keeps the footer visible while the facet panel scrolls', () => {
renderFilterBar(true)
const content = document.querySelector('[data-slot="popover-content"]')
const root = document.querySelector('[data-slot="scroll-area"]')
expect(content?.className).toContain(
'h-[min(471px,var(--radix-popover-content-available-height))]'
)
expect(root?.className).toContain('min-h-0 flex-1')
})
})
@@ -67,8 +67,13 @@ export function WorkspaceCleanupFilterBar({
) : null}
</Button>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={6} className="w-[320px] p-0">
<ScrollArea className="max-h-[420px]">
<PopoverContent
align="end"
sideOffset={6}
className="flex h-[min(471px,var(--radix-popover-content-available-height))] w-[320px] flex-col p-0"
>
{/* 471px preserves the 420px facet viewport plus the fixed footer at full height. */}
<ScrollArea className="min-h-0 flex-1">
<WorkspaceCleanupLifecycleFacets {...facetProps} />
<WorkspaceCleanupGitReviewFacets {...facetProps} />
</ScrollArea>
+14 -2
View File
@@ -4410,7 +4410,8 @@
"d36883e046": "Cancel",
"b79b39d865": "Remove Project",
"removeDescriptionSsh": "This only removes {{name}} from Orca. Its files stay on {{host}} — re-add that SSH host to recover it.",
"removeDescriptionLocal": "This only removes {{name}} from Orca. It is still on your disk."
"removeDescriptionLocal": "This only removes {{name}} from Orca. It is still on your disk.",
"removeDescriptionVmRecipe": "This removes {{name}} from Orca. Its VM recipe determines whether the environment and its files are permanently deleted."
},
"ScrollToCurrentWorkspaceToolbarButton": {
"23989bb663": "Reveal active workspace"
@@ -9967,7 +9968,11 @@
"cloudVmTitle": "Cloud VM runtimes",
"cloudVmRefresh": "Refresh Cloud VM runtimes",
"cloudVmLoading": "Checking Cloud VM runtimes…",
"cloudVmEmptyWithSetup": "No Cloud VM runtimes yet. Create one from a workspace using an environment recipe."
"cloudVmEmptyWithSetup": "No Cloud VM runtimes yet. Create one from a workspace using an environment recipe.",
"stoppingCleanup": "Stopping…",
"stopCleanup": "Stop cleanup",
"cleanupStopped": "Cleanup stopped",
"stopCleanupFailed": "Couldn’t stop Cloud VM cleanup."
},
"ephemeralVms": {
"search": {
@@ -10719,6 +10724,13 @@
"saveFailed": "Could not save visibility defaults.",
"updateServer": "Update this server to configure source defaults.",
"updateServerDefaults": "Update this server to configure visibility defaults."
},
"EphemeralVmCleanupStopDialog": {
"title": "Stop cleanup?",
"description": "The VM may remain running and incur charges. You can retry cleanup later.",
"cancel": "Keep cleaning",
"stopping": "Stopping…",
"confirm": "Stop cleanup"
}
},
"right": {
@@ -32,6 +32,7 @@ describe('cleanupFailedEphemeralVmWorkspace', () => {
await cleanupFailedEphemeralVmWorkspace(request(), {
deleteProjectHostSetup: vi.fn(async () => {
order.push('setup')
return { id: 'setup-1' }
}),
cleanupRuntime: vi.fn(async () => {
order.push('runtime')
@@ -43,7 +44,7 @@ describe('cleanupFailedEphemeralVmWorkspace', () => {
expect(order).toEqual(['setup', 'runtime'])
})
it('still destroys the VM when setup deletion fails', async () => {
it('retains the VM when setup deletion throws', async () => {
const cleanupRuntime = vi.fn().mockResolvedValue(undefined)
const reportSetupError = vi.fn()
await cleanupFailedEphemeralVmWorkspace(request(), {
@@ -54,6 +55,20 @@ describe('cleanupFailedEphemeralVmWorkspace', () => {
})
expect(reportSetupError).toHaveBeenCalledOnce()
expect(cleanupRuntime).toHaveBeenCalledWith('runtime-1')
expect(cleanupRuntime).not.toHaveBeenCalled()
})
it('retains the VM when setup deletion reports no result', async () => {
const cleanupRuntime = vi.fn().mockResolvedValue(undefined)
const reportSetupError = vi.fn()
await cleanupFailedEphemeralVmWorkspace(request(), {
deleteProjectHostSetup: vi.fn().mockResolvedValue(null),
cleanupRuntime,
reportSetupError,
reportRuntimeError: vi.fn()
})
expect(reportSetupError).toHaveBeenCalledOnce()
expect(cleanupRuntime).not.toHaveBeenCalled()
})
})
@@ -19,9 +19,16 @@ export async function cleanupFailedEphemeralVmWorkspace(
request.workspaceRunContext?.projectHostSetupId
) {
try {
await actions.deleteProjectHostSetup(request.workspaceRunContext.projectHostSetupId)
const deleted = await actions.deleteProjectHostSetup(
request.workspaceRunContext.projectHostSetupId
)
if (deleted == null) {
actions.reportSetupError(new Error('Could not confirm project host rollback.'))
return
}
} catch (error) {
actions.reportSetupError(error)
return
}
}
try {
@@ -28,7 +28,10 @@ describe('cleanupEphemeralVmRuntimesForDeleted', () => {
expect(cleanup).toHaveBeenCalledTimes(1)
expect(cleanup).toHaveBeenCalledWith({ runtimeId: 'rt-1' })
expect(destroyed).toEqual(['runtime-ssh-a'])
expect(destroyed).toEqual({
destroyedSshTargetIds: ['runtime-ssh-a'],
retainedSshTargetIds: []
})
})
it('cleans a runtime matched only by its runtime-owned SSH target id', async () => {
@@ -43,7 +46,10 @@ describe('cleanupEphemeralVmRuntimesForDeleted', () => {
})
expect(cleanup).toHaveBeenCalledWith({ runtimeId: 'rt-1' })
expect(destroyed).toEqual(['runtime-ssh-orca-1'])
expect(destroyed).toEqual({
destroyedSshTargetIds: ['runtime-ssh-orca-1'],
retainedSshTargetIds: []
})
})
it('ignores non-runtime-owned target ids and already-cleaned runtimes', async () => {
@@ -58,7 +64,7 @@ describe('cleanupEphemeralVmRuntimesForDeleted', () => {
})
expect(cleanup).not.toHaveBeenCalled()
expect(destroyed).toEqual([])
expect(destroyed).toEqual({ destroyedSshTargetIds: [], retainedSshTargetIds: [] })
})
it('retries a completed provider cleanup while its SSH target remains', async () => {
@@ -74,7 +80,7 @@ describe('cleanupEphemeralVmRuntimesForDeleted', () => {
cleanup.mockResolvedValue({ status: 'cleaned', cleanupStatus: 'succeeded' })
await expect(cleanupEphemeralVmRuntimesForDeleted({ workspaceIds: ['wt-1'] })).resolves.toEqual(
['runtime-ssh-a']
{ destroyedSshTargetIds: ['runtime-ssh-a'], retainedSshTargetIds: [] }
)
expect(cleanup).toHaveBeenCalledWith({ runtimeId: 'rt-1' })
})
@@ -90,14 +96,27 @@ describe('cleanupEphemeralVmRuntimesForDeleted', () => {
})
await expect(cleanupEphemeralVmRuntimesForDeleted({ workspaceIds: ['wt-1'] })).resolves.toEqual(
[]
{ destroyedSshTargetIds: [], retainedSshTargetIds: ['runtime-ssh-a'] }
)
})
it('swallows listRuntimes failures', async () => {
listRuntimes.mockRejectedValue(new Error('boom'))
await expect(cleanupEphemeralVmRuntimesForDeleted({ workspaceIds: ['wt-1'] })).resolves.toEqual(
[]
{ destroyedSshTargetIds: [], retainedSshTargetIds: [] }
)
})
it('retains an explicit runtime target when runtime listing fails', async () => {
listRuntimes.mockRejectedValue(new Error('boom'))
await expect(
cleanupEphemeralVmRuntimesForDeleted({
runtimeOwnedSshTargetIds: ['runtime-ssh-a']
})
).resolves.toEqual({
destroyedSshTargetIds: [],
retainedSshTargetIds: ['runtime-ssh-a']
})
})
})
@@ -10,16 +10,21 @@ import { isRuntimeOwnedSshTargetId } from '../../../shared/execution-host'
* routes through project removal, which must not leak the live Docker/VM and its
* hidden SSH target.
*
* Returns the runtime-owned SSH target ids that were destroyed so the caller can
* purge the now-orphaned project that pointed at them.
* Classifies runtime-owned SSH targets so callers purge only confirmed-destroyed projects.
*/
export type EphemeralVmCleanupSummary = {
destroyedSshTargetIds: string[]
retainedSshTargetIds: string[]
}
export async function cleanupEphemeralVmRuntimesForDeleted(args: {
workspaceIds?: readonly string[]
// Raw runtime-owned SSH target ids (e.g. a removed repo's connectionId) whose
// backing runtime should also be torn down, even if no workspace id matched.
runtimeOwnedSshTargetIds?: readonly string[]
}): Promise<string[]> {
const destroyedSshTargetIds: string[] = []
}): Promise<EphemeralVmCleanupSummary> {
const destroyedSshTargetIds = new Set<string>()
const retainedSshTargetIds = new Set<string>()
try {
const workspaceIdSet = new Set(args.workspaceIds ?? [])
const sshTargetIdSet = new Set(
@@ -33,13 +38,29 @@ export async function cleanupEphemeralVmRuntimesForDeleted(args: {
(runtime.sshTargetId !== undefined && sshTargetIdSet.has(runtime.sshTargetId)))
)
for (const runtime of matchingRuntimes) {
const cleaned = await window.api.ephemeralVm.cleanup({ runtimeId: runtime.id })
if (runtime.sshTargetId && !cleaned.sshTargetId) {
destroyedSshTargetIds.push(runtime.sshTargetId)
try {
const cleaned = await window.api.ephemeralVm.cleanup({ runtimeId: runtime.id })
if (runtime.sshTargetId) {
const targetIds = cleaned.sshTargetId ? retainedSshTargetIds : destroyedSshTargetIds
targetIds.add(runtime.sshTargetId)
}
} catch (error) {
console.error('Failed to clean up ephemeral VM runtime for deleted workspace:', error)
if (runtime.sshTargetId) {
retainedSshTargetIds.add(runtime.sshTargetId)
}
}
}
} catch (error) {
console.error('Failed to clean up ephemeral VM runtime for deleted workspace:', error)
for (const targetId of args.runtimeOwnedSshTargetIds ?? []) {
if (isRuntimeOwnedSshTargetId(targetId)) {
retainedSshTargetIds.add(targetId)
}
}
}
return {
destroyedSshTargetIds: [...destroyedSshTargetIds],
retainedSshTargetIds: [...retainedSshTargetIds]
}
return destroyedSshTargetIds
}
@@ -101,13 +101,14 @@ describe('prepareEphemeralVmWorkspaceTarget', () => {
expect(window.api.ephemeralVm.cleanup).not.toHaveBeenCalled()
})
it('imports an ssh recipe result through the runtime-owned ssh host', async () => {
it('carries a provisioned-root source commit through runtime-owned SSH import', async () => {
vi.mocked(window.api.ephemeralVm.provision).mockResolvedValue({
ok: true,
connectionType: 'ssh',
stderr: 'creating sandbox',
warnings: [],
sshTargetId: 'runtime-ssh-runtime-1',
expectedRefHead: 'abc123',
runtime: {
id: 'runtime-1',
repoId: 'repo-1',
@@ -119,7 +120,8 @@ describe('prepareEphemeralVmWorkspaceTarget', () => {
createdAt: 1,
updatedAt: 1,
recipeResult: {
schemaVersion: 1,
schemaVersion: 2,
checkoutMode: 'provisioned-root',
connection: {
type: 'ssh',
projectRoot: '/workspace/repo',
@@ -164,7 +166,8 @@ describe('prepareEphemeralVmWorkspaceTarget', () => {
setup: { ...setupResult.setup, hostId: 'ssh:runtime-ssh-runtime-1' }
},
runtimeId: 'runtime-1',
checkoutMode: 'orca-worktree',
checkoutMode: 'provisioned-root',
expectedRefHead: 'abc123',
stderr: 'creating sandbox',
warnings: []
})
@@ -32,6 +32,7 @@ export type PrepareEphemeralVmWorkspaceTargetResult =
runtimeId: string
checkoutMode: 'orca-worktree' | 'provisioned-root'
environmentId?: string
expectedRefHead?: string
stderr: string
warnings: EphemeralVmRecipeResultWarning[]
}
@@ -134,6 +135,11 @@ export async function prepareEphemeralVmWorkspaceTarget(
setup,
runtimeId: provisioned.runtime.id,
checkoutMode,
...(checkoutMode === 'provisioned-root' &&
provisioned.connectionType === 'ssh' &&
provisioned.expectedRefHead
? { expectedRefHead: provisioned.expectedRefHead }
: {}),
stderr: provisioned.stderr,
warnings: provisioned.warnings
} satisfies PrepareEphemeralVmWorkspaceTargetResult
@@ -0,0 +1,87 @@
import { beforeEach, expect, it, vi } from 'vitest'
import type { PendingWorktreeCreation } from './pending-worktree-creation'
const { prepareTargetMock } = vi.hoisted(() => ({ prepareTargetMock: vi.fn() }))
const store = {
repos: [{ id: 'repo-1' }],
pendingWorktreeCreations: {} as Record<string, PendingWorktreeCreation>,
activePendingCreationId: 'creation-1',
updatePendingWorktreeCreation: vi.fn(),
setupProjectExistingFolder: vi.fn()
}
vi.mock('@/store', () => ({ useAppStore: { getState: () => store } }))
vi.mock('@/lib/ephemeral-vm-workspace-target', () => ({
prepareEphemeralVmWorkspaceTarget: prepareTargetMock
}))
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
import { prepareRequestForCreate } from './ephemeral-vm-worktree-creation'
beforeEach(() => {
vi.clearAllMocks()
store.pendingWorktreeCreations = {
'creation-1': {
creationId: 'creation-1',
phase: 'provisioning-vm',
status: 'creating',
startedAt: 1,
indeterminate: true,
loaderVisible: true,
request: {} as never
}
}
globalThis.window = {
api: { ephemeralVm: { onProvisionEvent: vi.fn(() => vi.fn()) } }
} as never
})
it('carries the captured provisioned-root ref identity into adoption', async () => {
prepareTargetMock.mockResolvedValue({
ok: true,
runtimeId: 'runtime-1',
checkoutMode: 'provisioned-root',
expectedRefHead: 'abc123',
stderr: '',
warnings: [],
setup: {
project: { id: 'project-1' },
setup: {
id: 'setup-runtime',
projectId: 'project-1',
hostId: 'ssh:runtime-ssh-runtime-1'
},
repo: { id: 'repo-runtime', path: '/workspace/repo' }
}
})
const prepared = await prepareRequestForCreate('creation-1', {
repoId: 'repo-1',
name: 'feature',
baseBranch: 'origin/main',
branchNameOverride: 'feature/ref-check',
setupDecision: 'inherit',
agent: null,
pendingFirstAgentMessageRename: false,
note: '',
startupPlan: null,
quickPrompt: '',
quickTelemetry: null,
ephemeralVmRecipe: {
sourceRepoId: 'repo-1',
recipeId: 'cloud-sandbox',
projectId: 'github:stablyai/orca',
checkoutMode: 'provisioned-root'
}
})
expect(prepareTargetMock).toHaveBeenCalledWith(
expect.objectContaining({ branch: 'feature/ref-check', ref: 'origin/main' })
)
expect(prepared).toMatchObject({
ephemeralVmRuntimeId: 'runtime-1',
ephemeralVmCheckoutMode: 'provisioned-root',
ephemeralVmExpectedRefHead: 'abc123'
})
})
@@ -79,6 +79,9 @@ export async function prepareRequestForCreate(
: getEphemeralVmPortableBaseSelection(request)),
ephemeralVmRuntimeId: preparedTarget.runtimeId,
ephemeralVmCheckoutMode: preparedTarget.checkoutMode,
...(preparedTarget.expectedRefHead
? { ephemeralVmExpectedRefHead: preparedTarget.expectedRefHead }
: {}),
...(preparedTarget.environmentId
? { ephemeralVmRuntimeEnvironmentId: preparedTarget.environmentId }
: {}),
@@ -18,6 +18,7 @@ describe('paste payload metadata', () => {
byteLength: textEncoder.encode(text).byteLength,
exceededLimit: false,
hasControlSequences: expected.hasControlSequences,
lineEndingByteLength: text.match(/[\r\n]/g)?.length ?? 0,
lineCount: expected.lineCount
})
}
@@ -30,6 +31,7 @@ describe('paste payload metadata', () => {
byteLength: 26,
exceededLimit: false,
hasControlSequences: true,
lineEndingByteLength: 3,
lineCount: 3
})
expect(getPastePayloadUtf8ByteLength(text)).toBe(26)
@@ -45,6 +47,7 @@ describe('paste payload metadata', () => {
byteLength: 8,
exceededLimit: true,
hasControlSequences: false,
lineEndingByteLength: 0,
lineCount: 1
})
expect(metadata.byteLength).toBeLessThan(getPastePayloadUtf8ByteLength(text))
@@ -64,6 +67,7 @@ describe('paste payload metadata', () => {
byteLength: 43,
exceededLimit: false,
hasControlSequences: true,
lineEndingByteLength: 2,
lineCount: 2
})
expect(yieldToEventLoop).toHaveBeenCalled()
+32 -8
View File
@@ -8,6 +8,7 @@ export type PastePayloadMetadata = {
byteLength: number
exceededLimit: boolean
hasControlSequences: boolean
lineEndingByteLength: number
lineCount: number
}
@@ -24,6 +25,7 @@ export function measurePastePayloadMetadata(
const stopAfterBytes = options.stopAfterBytes
let byteLength = 0
let hasControlSequences = false
let lineEndingByteLength = 0
let lineCount = 1
let previousWasCarriageReturn = false
@@ -33,22 +35,32 @@ export function measurePastePayloadMetadata(
hasControlSequences ||= isPasteControlSequenceCodePoint(codePoint)
if (codePoint === 0x0d) {
lineCount += 1
lineEndingByteLength += 1
previousWasCarriageReturn = true
} else {
if (codePoint === 0x0a && !previousWasCarriageReturn) {
lineCount += 1
if (codePoint === 0x0a) {
lineEndingByteLength += 1
if (!previousWasCarriageReturn) {
lineCount += 1
}
}
previousWasCarriageReturn = false
}
if (Number.isFinite(stopAfterBytes) && byteLength > (stopAfterBytes ?? 0)) {
return { byteLength, exceededLimit: true, hasControlSequences, lineCount }
return {
byteLength,
exceededLimit: true,
hasControlSequences,
lineEndingByteLength,
lineCount
}
}
if (codePoint > 0xffff) {
index += 1
}
}
return { byteLength, exceededLimit: false, hasControlSequences, lineCount }
return { byteLength, exceededLimit: false, hasControlSequences, lineEndingByteLength, lineCount }
}
export async function measurePastePayloadMetadataWithYield(
@@ -72,6 +84,7 @@ export async function measurePastePayloadMetadataWithYield(
let nextYieldAt = yieldAfterCodeUnits
let byteLength = 0
let hasControlSequences = false
let lineEndingByteLength = 0
let lineCount = 1
let previousWasCarriageReturn = false
@@ -81,15 +94,25 @@ export async function measurePastePayloadMetadataWithYield(
hasControlSequences ||= isPasteControlSequenceCodePoint(codePoint)
if (codePoint === 0x0d) {
lineCount += 1
lineEndingByteLength += 1
previousWasCarriageReturn = true
} else {
if (codePoint === 0x0a && !previousWasCarriageReturn) {
lineCount += 1
if (codePoint === 0x0a) {
lineEndingByteLength += 1
if (!previousWasCarriageReturn) {
lineCount += 1
}
}
previousWasCarriageReturn = false
}
if (Number.isFinite(stopAfterBytes) && byteLength > (stopAfterBytes ?? 0)) {
return { byteLength, exceededLimit: true, hasControlSequences, lineCount }
return {
byteLength,
exceededLimit: true,
hasControlSequences,
lineEndingByteLength,
lineCount
}
}
if (codePoint > 0xffff) {
index += 1
@@ -100,7 +123,7 @@ export async function measurePastePayloadMetadataWithYield(
}
}
return { byteLength, exceededLimit: false, hasControlSequences, lineCount }
return { byteLength, exceededLimit: false, hasControlSequences, lineEndingByteLength, lineCount }
}
export function getPastePayloadUtf8ByteLength(text: string): number {
@@ -129,6 +152,7 @@ function createEmptyPastePayloadMetadata(): PastePayloadMetadata {
byteLength: 0,
exceededLimit: false,
hasControlSequences: false,
lineEndingByteLength: 0,
lineCount: 0
}
}
@@ -49,6 +49,8 @@ export type WorktreeCreationRequest = {
ephemeralVmRuntimeEnvironmentId?: string
/** Checkout ownership selected by the provisioned recipe. */
ephemeralVmCheckoutMode?: 'orca-worktree' | 'provisioned-root'
/** Source-host commit captured before a provisioned-root recipe starts. */
ephemeralVmExpectedRefHead?: string
/** Recipe to provision before creating the worktree. Kept serializable so
* retry can rerun the recipe after a failed create. */
ephemeralVmRecipe?: {
@@ -28,6 +28,7 @@ describe('getProvisionedRootCreateOptions', () => {
request({
ephemeralVmCheckoutMode: 'provisioned-root',
ephemeralVmRuntimeId: 'runtime-1',
ephemeralVmExpectedRefHead: 'abc123',
workspaceRunContext: {
kind: 'workspace-run',
projectId: 'project-1',
@@ -41,7 +42,8 @@ describe('getProvisionedRootCreateOptions', () => {
).toEqual({
runtimeId: 'runtime-1',
executionHostId: 'ssh:runtime-ssh-one',
expectedPath: '/workspace/repo'
expectedPath: '/workspace/repo',
expectedRefHead: 'abc123'
})
})
@@ -51,5 +53,23 @@ describe('getProvisionedRootCreateOptions', () => {
request({ ephemeralVmCheckoutMode: 'provisioned-root', ephemeralVmRuntimeId: 'runtime-1' })
)
).toThrow('identity is incomplete')
expect(() =>
getProvisionedRootCreateOptions(
request({
ephemeralVmCheckoutMode: 'provisioned-root',
ephemeralVmRuntimeId: 'runtime-1',
baseBranch: 'origin/main',
workspaceRunContext: {
kind: 'workspace-run',
projectId: 'project-1',
hostId: 'ssh:runtime-ssh-one',
projectHostSetupId: 'setup-1',
repoId: 'repo-runtime',
path: '/workspace/repo'
}
})
)
).toThrow('identity is incomplete')
})
})
@@ -4,6 +4,7 @@ export type ProvisionedRootCreateOptions = {
runtimeId: string
executionHostId: NonNullable<WorktreeCreationRequest['workspaceRunContext']>['hostId']
expectedPath: string
expectedRefHead?: string
}
export function getProvisionedRootCreateOptions(
@@ -12,12 +13,19 @@ export function getProvisionedRootCreateOptions(
if (request.ephemeralVmCheckoutMode !== 'provisioned-root') {
return null
}
if (!request.ephemeralVmRuntimeId || !request.workspaceRunContext) {
if (
!request.ephemeralVmRuntimeId ||
!request.workspaceRunContext ||
(request.baseBranch && !request.ephemeralVmExpectedRefHead)
) {
throw new Error('Provisioned-root workspace identity is incomplete.')
}
return {
runtimeId: request.ephemeralVmRuntimeId,
executionHostId: request.workspaceRunContext.hostId,
expectedPath: request.workspaceRunContext.path
expectedPath: request.workspaceRunContext.path,
...(request.ephemeralVmExpectedRefHead
? { expectedRefHead: request.ephemeralVmExpectedRefHead }
: {})
}
}
@@ -0,0 +1,101 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '../store'
import { getStartupErrorFallbackUI } from '../lib/startup-ui-hydration'
import {
collectTerminalProviderSnapshotPtyIds,
refreshTerminalProviderSnapshotCapabilities
} from '../components/terminal/terminal-provider-snapshot-capability'
type DegradedStartupRecoveryArgs = {
error: unknown
/** Whether ui.get() hydrated before the failure; false means defaults are safe to apply. */
uiHydrated: boolean
/** Whether the success path already began terminal reconnect over now-partial state. */
reconnectStarted: boolean
isCancelled: () => boolean
hydratePersistedUI: ReturnType<typeof useAppStore.getState>['hydratePersistedUI']
reconnectPersistedTerminals: (signal: AbortSignal) => Promise<void>
abortSignal: AbortSignal
}
// Why (issue #1158): reconnect flips workspaceSessionReady so the UI mounts, but pending* maps
// are normally drained by reconnect — clear them so a failure can't leave phantom dead PTYs.
function forceWorkspaceSessionReady(): void {
useAppStore.setState({
workspaceSessionReady: true,
pendingReconnectWorktreeIds: [],
pendingReconnectTabByWorktree: {},
pendingReconnectPtyIdByTabId: {}
})
}
/**
* Boots the app in degraded "no-save" mode after the startup hydration chain threw.
*
* Why (issue #1158): in-memory state is left untouched and hydrationSucceeded stays false
* (default-hydrating here once erased saved tabs); only the ready flags flip so the UI mounts.
*/
export async function recoverFromDegradedStartup(args: DegradedStartupRecoveryArgs): Promise<void> {
const {
error,
uiHydrated,
reconnectStarted,
isCancelled,
hydratePersistedUI,
reconnectPersistedTerminals,
abortSignal
} = args
const stepLabel = error instanceof Error && error.message ? error.message : String(error)
console.error(
'[startup] Workspace session hydration failed; leaving disk state untouched:',
stepLabel,
error
)
if (isCancelled()) {
return
}
// Why: degraded mode stays interactive; later repo/runtime changes must not remain gated forever.
useAppStore.setState({ startupWorktreeRefreshCompleted: true })
// Why (issue #1158): only apply default UI if ui.get() never hydrated; otherwise defaults would clobber ui.json via the debounced writer.
const fallbackUI = getStartupErrorFallbackUI(uiHydrated)
if (fallbackUI) {
hydratePersistedUI(fallbackUI, 'startup')
}
// Why (issue #1158): sticky toast so the user knows they're in degraded "no-save" mode (hydrationSucceeded stays false); "Restart now" calls app.relaunch to recover.
toast.error(translate('auto.App.12e77cf12b', 'Session restore failed'), {
description: translate(
'auto.App.0a9e810705',
"Changes won't be saved until restart. Your previous tabs are safe on disk."
),
duration: Infinity,
dismissible: true,
action: {
label: translate('auto.App.caea5b51b9', 'Restart now'),
onClick: () => {
void window.api.app.relaunch()
}
}
})
if (reconnectStarted) {
// Why (issue #1158): re-running reconnect over its partially-mutated state would double-set ptyIds and drain pending* twice — force the flag, clear pending*.
forceWorkspaceSessionReady()
return
}
try {
await window.api.app.awaitFirstWindowStartupServices()
await window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()
await refreshTerminalProviderSnapshotCapabilities(
collectTerminalProviderSnapshotPtyIds(useAppStore.getState())
)
await reconnectPersistedTerminals(abortSignal)
await window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()
} catch (reconnectErr) {
console.error('[startup] reconnectPersistedTerminals failed in error path:', reconnectErr)
// Why (issue #1158): the await may have run during StrictMode teardown; re-check cancellation so a cancelled pass 1 doesn't stomp pass 2's hydration.
if (!isCancelled()) {
// Why (issue #1158): recovery threw too; force the flag so the shell still mounts.
forceWorkspaceSessionReady()
}
}
}
@@ -0,0 +1,76 @@
import type { SshConnectionState } from '../../../shared/ssh-types'
import { timeRendererStartupStep } from './startup-diagnostics'
import { reconnectSshTargetForRendererStartup } from './ssh-startup-reconnect'
const SSH_RECONNECT_TIMEOUT_MS = 15_000
/**
* Re-establishes the SSH targets that were live at shutdown before terminal reconnect, so
* SSH-backed tabs route through pty.attach. Passphrase-protected and timed-out targets are
* handed back as deferred so their PTYs reattach on tab focus instead of stacking dialogs.
*/
export async function restoreSshConnectionsForStartup(args: {
connectionIds: string[]
setDeferredSshReconnectTargets: (targetIds: string[]) => void
publishSshConnectionState: (targetId: string, state: SshConnectionState) => void
}): Promise<void> {
const { connectionIds, setDeferredSshReconnectTargets, publishSshConnectionState } = args
const allTargets = await timeRendererStartupStep('ssh-list-targets', () =>
window.api.ssh.listTargets()
)
const targetMap = new Map(allTargets.map((t) => [t.id, t]))
const targets = connectionIds.map((targetId) => ({
targetId,
needsPassphrase: targetMap.get(targetId)?.lastRequiredPassphrase ?? false
}))
const eagerTargets = targets.filter((t) => !t.needsPassphrase)
const deferredTargets = targets.filter((t) => t.needsPassphrase)
if (deferredTargets.length > 0) {
setDeferredSshReconnectTargets(deferredTargets.map((t) => t.targetId))
}
// Why: treat timed-out eager targets as deferred so their PTYs reattach on tab focus (ssh.connect keeps running in main and likely finishes by then).
const timedOutTargets: string[] = []
await timeRendererStartupStep(
'ssh-reconnect',
() =>
Promise.all(
eagerTargets.map(async ({ targetId }) => {
const result = await reconnectSshTargetForRendererStartup({
targetId,
timeoutMs: SSH_RECONNECT_TIMEOUT_MS,
connect: (id) => window.api.ssh.connect({ targetId: id }),
publishState: publishSshConnectionState,
onFailure: (id, error) => {
console.warn(`SSH auto-reconnect failed for ${id}:`, error)
}
})
if (result.timedOut) {
timedOutTargets.push(targetId)
}
})
),
{ eagerTargets: eagerTargets.length, deferredTargets: deferredTargets.length }
)
if (timedOutTargets.length > 0) {
setDeferredSshReconnectTargets([...deferredTargets.map((t) => t.targetId), ...timedOutTargets])
}
// Why: older/wrapped providers may return no state from connect; poll main once as a compatibility fallback before terminal restoration.
for (const { targetId } of eagerTargets) {
if (timedOutTargets.includes(targetId)) {
continue
}
try {
const state = await window.api.ssh.getState({ targetId })
console.warn(`[ssh-restore] Polled state for ${targetId}: status=${state?.status}`)
if (state?.status === 'connected') {
publishSshConnectionState(targetId, state)
}
} catch {
/* best-effort */
}
}
}

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