fix(runtime): recover orphan terminals without changing Active Server (#10011)

Adds guarded host-authoritative orphan PTY adoption and keeps Active Server durable preference mutations exclusive to its explicit settings control.
This commit is contained in:
OrcaWin 2026-07-22 22:58:09 -07:00 committed by GitHub
parent c3620f0954
commit 88b7e69ba1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
62 changed files with 4941 additions and 711 deletions

View File

@ -1412,20 +1412,22 @@
"local",
"daemon",
"ssh",
"wsl",
"remote-runtime"
],
"coverageNotes": "Deterministic macOS tests cover controller claims, daemon and SSH/relay operation replay, mixed-version selection, runtime ownership, exact provisional handoff, durable terminal retirement, and two independent viewer mirrors. The secondary parity repro runs independent clients against one headless remote Orca runtime over encrypted pairing and a real daemon-backed PTY. A headed Orca desktop server paired to a separate client is the primary live user topology and remains uncollected for this issue. SSH coverage is provider/relay contract and fault-injection coverage only; it does not substitute for paired-server coverage. WSL and live SSH hosts remain gaps.",
"coverageNotes": "Deterministic macOS tests cover controller claims, daemon and SSH/relay operation replay, mixed-version selection, runtime ownership, exact provisional handoff, durable terminal retirement, two independent viewer mirrors, and guarded adoption of legacy live PTYs. The adoption harness models v1.4.150 agent/setup/shell tabs, current-generation restart and reconnect, exact handle/incarnation/worktree/host checks, topology CAS, competing clients, split-pane/group restoration, WSL ownership, and SSH owner rejection. The secondary parity repro runs independent clients against one headless remote Orca runtime over encrypted pairing and a real daemon-backed PTY. A headed Orca desktop server paired to a separate client is the primary live user topology and remains uncollected for this issue. SSH coverage is provider/relay contract and fault-injection coverage only; it does not substitute for paired-server coverage. Live Windows, WSL, and SSH hosts remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/8878",
"https://github.com/stablyai/orca/issues/9352",
"https://github.com/stablyai/orca/pull/9687"
],
"invariant": "For every claim-capable execution route, one provider-session identity has at most one live PTY owner and one canonical host surface across concurrent clients, retries, reconnects, and stale publications. A physical exit retires that exact incarnation durably so stale client state and host restart cannot recreate it. Mixed-version routes select the unchanged legacy request before any authority side effect or execution-owner-local filesystem access.",
"oracle": "Race independent clients and repeated operation IDs, then assert one physical spawn and one canonical PTY/surface; inject exit-before-reply, provider disconnect, conflicting claim scope, and old daemon/relay capabilities; assert safe adoption or explicit failure without a second spawn. After exact exit, assert terminal and tab listings omit the surface, a stale publication cannot restore it, restart cannot resurrect it, and an exact provisional handoff is consumed even when exit wins before the next snapshot.",
"invariant": "For every claim-capable execution route, one provider-session identity has at most one live PTY owner and one canonical host surface across concurrent clients, retries, reconnects, and stale publications. A live orphan may be adopted only when the controller proves its exact handle and incarnation, its worktree and host owner match, no competing visual owner exists, and a host topology CAS wins. A physical exit retires that exact incarnation durably so stale client state and host restart cannot recreate it. Mixed-version routes select the unchanged legacy request before any authority side effect or execution-owner-local filesystem access.",
"oracle": "Race independent clients and repeated operation IDs, then assert one physical spawn and one canonical PTY/surface; inject exit-before-reply, provider disconnect, conflicting claim scope, old daemon/relay capabilities, reused handles, stale incarnations, owner mismatch, and topology revision conflict; assert safe adoption or explicit failure without a second spawn or wrong-process attachment. Restore legacy split panes and groups beside a newer host-owned tab, preserving output, input, resize, titles, tab/leaf identity, active group, and multi-client convergence. After exact exit, assert terminal and tab listings omit the surface, a stale publication cannot restore it, restart cannot resurrect it, and an exact provisional handoff is consumed even when exit wins before the next snapshot.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/shared/claimed-agent-pty-owner.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/runtime/orca-runtime-agent-session-operation.test.ts src/main/runtime/remote-agent-session-host-authority.integration.test.ts src/main/runtime/orca-runtime-terminal-retirement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-terminal-tab-retirement.unit.test.ts",
"pnpm test:repro:remote-agent-session"
"pnpm test:repro:remote-agent-session",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-orphan-owner.test.ts src/main/runtime/terminal-orphan-topology.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --maxWorkers=1"
],
"testFiles": [
"src/shared/claimed-agent-pty-owner.test.ts",
@ -1438,7 +1440,12 @@
"src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts",
"src/renderer/src/runtime/web-runtime-session.test.ts",
"src/renderer/src/runtime/web-session-tabs-sync.test.ts",
"tests/e2e/remote-terminal-tab-retirement.unit.test.ts"
"tests/e2e/remote-terminal-tab-retirement.unit.test.ts",
"src/main/runtime/orca-runtime.test.ts",
"src/main/runtime/terminal-orphan-owner.test.ts",
"src/main/runtime/terminal-orphan-topology.test.ts",
"src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts",
"src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts"
],
"assertionRefs": [
{
@ -1497,6 +1504,31 @@
"one exact exit produces one same-epoch higher-version host publication and one durable persistence flush",
"same-epoch stale publications cannot resurrect the retired surface after reconnect"
]
},
{
"file": "src/main/runtime/orca-runtime.test.ts",
"assertions": [
"v1.4.150-shaped agent, setup, and shell PTYs adopt as one CAS transaction while stale incarnation and competing clients fail safely",
"current-generation restart and disconnect/reconnect preserve output, input, resize, title, tab, leaf, handle, and incarnation identity",
"split-pane and multi-group legacy topology merges beside a newer host-owned terminal without replacing it",
"equivalent Windows and separator-normalized persisted worktree keys canonicalize without duplicate terminal topology",
"connection mismatch, reused handles, SSH ownership mismatch, and stale topology revisions cannot claim a live PTY while WSL ownership succeeds"
]
},
{
"file": "src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts",
"assertions": [
"absence stays pending until an exact live orphan adoption settles",
"client pane and group topology is pruned to exact orphan claims and translated to host tab identities",
"a missing split leaf remains recoverable when another leaf in the same tab is already host-owned"
]
},
{
"file": "src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts",
"assertions": [
"mixed-version inventory without incarnation evidence remains visible but cannot adopt",
"a truncated legacy unfiltered inventory cannot hide a candidate whose liveness is unresolved"
]
}
],
"evidenceRuns": [
@ -1577,6 +1609,144 @@
],
"demotionRule": "Keep experimental or demote if the focused gate flakes without a product or harness bug, if a retry can physically spawn twice, if a stale exit/publication can replace or resurrect a terminal, or if mixed-version fallback occurs after an authority side effect."
},
{
"id": "runtime-routing.active-server-preference",
"title": "Active Server changes only through its explicit Advanced control",
"maturity": "experimental",
"protection": "partial",
"owner": "runtime-routing",
"layer": "main-preload-renderer-persistence-contract",
"surfaces": [
"Advanced Active Server setting",
"saved server Connect and Disconnect",
"remote workspace navigation",
"terminal reveal and create",
"browser and mobile handoff",
"app restart"
],
"platforms": [
"macos",
"linux",
"windows",
"mobile"
],
"providers": [
"local",
"remote-runtime",
"ssh",
"wsl"
],
"coveredPlatforms": [
"macos"
],
"coveredProviders": [
"local",
"remote-runtime",
"ssh",
"wsl"
],
"coverageNotes": "Platform-neutral deterministic tests separate the durable Active Server preference from per-client connection, selected-workspace, browser-session, and execution-host routing. The composed regression models Local desktop -> connect/navigate Windows 2 -> reveal a local terminal -> restart. Multi-client browser host overrides, multi-server profile caches, generic settings IPC rejection, local and remote workspace ownership, and restart reset of transient routing are covered. Live desktop UI runs remain uncollected.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/9687"
],
"invariant": "Only an explicit user change in Settings > Remote Orca Servers > Advanced > Active Server may mutate activeRuntimeEnvironmentId. Connecting, pairing, disconnecting, selecting or revealing a workspace or terminal, browser/mobile handoff, remote navigation, and reconnect must use transient or target-owner routing and must never rewrite the durable preference. Generic settings mutation cannot bypass the dedicated preference IPC.",
"oracle": "Start with Active Server=Local desktop, connect and navigate Windows 2, then reveal a local terminal and assert it succeeds while the persisted preference remains local before and after restart. Repeat with multiple clients and servers, browser host switches, remote-owned and local-owned workspaces, pairing/connect/disconnect, and generic settings writes. Assert only the dedicated validated preference method changes activeRuntimeEnvironmentId and stale host-operation completions cannot overwrite the newly selected transient host.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/settings.test.ts src/main/ipc/runtime-environments.test.ts src/renderer/src/store/slices/settings.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/components/settings/browser-session-host-selection.test.ts src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts src/renderer/src/components/status-bar/SshStatusSegment.test.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/hooks/useIpcEvents.test.ts src/renderer/src/web/web-preload-api.test.ts --maxWorkers=1"
],
"testFiles": [
"src/main/ipc/settings.test.ts",
"src/main/ipc/runtime-environments.test.ts",
"src/renderer/src/store/slices/settings.test.ts",
"src/renderer/src/store/slices/browser.test.ts",
"src/renderer/src/components/settings/browser-session-host-selection.test.ts",
"src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts",
"src/renderer/src/components/status-bar/SshStatusSegment.test.ts",
"src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts",
"src/renderer/src/hooks/useIpcEvents.test.ts",
"src/renderer/src/web/web-preload-api.test.ts"
],
"assertionRefs": [
{
"file": "src/main/ipc/settings.test.ts",
"assertions": [
"generic settings IPC strips activeRuntimeEnvironmentId while the dedicated validated IPC persists it",
"invalid preference types and unknown server identities cannot mutate the durable preference"
]
},
{
"file": "src/renderer/src/hooks/useIpcEvents.test.ts",
"assertions": [
"Local desktop remains the durable default after transient Windows 2 navigation and a focused local terminal reveal succeeds",
"local and remote terminal create route by target workspace ownership instead of the durable preference"
]
},
{
"file": "src/renderer/src/store/slices/browser.test.ts",
"assertions": [
"multiple clients select different transient browser hosts without changing Active Server",
"restart clears transient browser host override while retaining the durable local preference",
"late profile and import results update only their captured host and cannot overwrite a newer selection"
]
},
{
"file": "src/renderer/src/components/settings/browser-session-host-selection.test.ts",
"assertions": [
"a removed transient server override falls back to an available host instead of leaving browser settings on an invalid option"
]
},
{
"file": "src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts",
"assertions": [
"connection status and the Advanced default-host selection are distinct concepts"
]
},
{
"file": "src/renderer/src/web/web-preload-api.test.ts",
"assertions": [
"generic web settings writes cannot mutate Active Server",
"the dedicated web preference setter rejects unknown server identities without corrupting the saved choice"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-22",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/settings.test.ts src/main/ipc/runtime-environments.test.ts src/renderer/src/store/slices/settings.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/components/settings/browser-session-host-selection.test.ts src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts src/renderer/src/components/status-bar/SshStatusSegment.test.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/hooks/useIpcEvents.test.ts src/renderer/src/web/web-preload-api.test.ts --maxWorkers=1",
"result": "passed",
"durationSeconds": 6.55,
"summary": "Ten files and 282 tests passed, including the composed Local -> Windows 2 navigation -> local reveal -> restart regression, dedicated-only preference persistence, removed transient-host fallback, multi-client browser routing, late host-operation suppression, and web pairing/preference separation."
}
],
"runtimeBudget": {
"p95Seconds": 20,
"scope": "focused persistence and routing contract tests"
},
"flakeHistory": {
"status": "unknown",
"evidence": "New deterministic gate with no soak history."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "The user-observed local terminal reveal failure and unintended Active Server switch are encoded by deterministic routing and persistence tests; a saved intentional-break artifact is not yet attached."
},
"performanceBudget": {
"required": false,
"evidence": "Preference writes are explicit user actions; transient routing adds no polling and uses existing host/worktree indexes."
},
"promotionCriteria": [
"Run the focused gate in soak across macOS, Linux, and Windows.",
"Attach a live Windows Local -> Windows 2 -> local reveal -> restart artifact.",
"Attach saved red/green evidence for generic settings mutation and transient connection routing."
],
"knownGaps": [
"The exact journey is deterministic contract coverage, not a packaged Windows UI automation run.",
"Browser/mobile handoff is covered through transient routing state and preload contracts, not a live phone browser session."
],
"demotionRule": "Demote or block release if any non-Advanced path mutates Active Server, if local reveal depends on the durable default instead of workspace ownership, or if transient host state survives restart."
},
{
"id": "terminal-geometry.visible-convergence",
"title": "Visible desktop terminals converge across xterm, fit, PTY, shell, and runtime mirror size",

View File

@ -885,6 +885,26 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(procs[0].cwd).toBe('/repo/owned-before-osc7')
expect(procs[0].worktreeId).toBe('repo::/repo/owned-before-osc7')
})
it('reports the daemon session WSL owner', async () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
const spawned = await adapter.spawn({
cols: 80,
rows: 24,
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
})
const procs = await adapter.listProcesses()
expect(procs.find((process) => process.id === spawned.id)?.wslDistro).toBe('Ubuntu')
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
})
})
describe('hasChildProcesses / getForegroundProcess', () => {

View File

@ -921,6 +921,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
title: 'shell',
...(worktreeId ? { worktreeId } : {}),
...(s.terminalHandle ? { terminalHandle: s.terminalHandle } : {}),
...(s.wslDistro !== undefined ? { wslDistro: s.wslDistro } : {}),
...this.validatedAgentSessionOwners(s.agentSessionOwners)
}
})

View File

@ -19,6 +19,7 @@ export function listLiveTerminalHostSessions(
shellState: session.shellState,
isAlive: true,
...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}),
wslDistro: session.wslDistro,
pid: session.pid,
cwd: session.getCwd(),
cols: size?.cols ?? 0,

View File

@ -355,6 +355,7 @@ export type SessionInfo = {
shellState: ShellReadyState
isAlive: boolean
terminalHandle?: string
wslDistro?: string | null
pid: number | null
cwd: string | null
cols: number

View File

@ -242,7 +242,6 @@ import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservatio
import { CliInstaller } from './cli/cli-installer'
import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher'
import { reconcileManagedWslCliRegistrations } from './cli/wsl-cli-registration-reconciliation'
import { selfHealRuntimeEnvironmentFocus } from './runtime-environment-focus-self-heal'
let mainWindow: BrowserWindow | null = null
/** Whether a manual app.quit() (Cmd+Q) is in progress; lets the close handler skip the running-process confirmation and go straight to close. */
@ -1838,7 +1837,6 @@ app.whenReady().then(async () => {
`[claude-live-pty] Seeded ${persistedClaudePtyIds.length} persisted Claude session id(s) into the refresh gate`
)
}
selfHealRuntimeEnvironmentFocus({ store, userDataPath: app.getPath('userData') })
applyAppIcon(store.getSettings().appIcon)
if (shouldSuppressDevEducation({ isDev: is.dev })) {
suppressDevEducationForStore(store)

View File

@ -14,7 +14,6 @@ import {
removeEnvironment,
updateEnvironmentFromPairingCode
} from '../../shared/runtime-environment-store'
import { clearActiveRuntimeEnvironmentFocusIfMatches } from '../runtime-environment-focus-self-heal'
import {
cleanupEphemeralVmRuntime,
resumeEphemeralVmRuntime,
@ -104,7 +103,6 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
if (result.ok && runtime.runtimeEnvironmentId) {
try {
removeEnvironment(userDataPath, runtime.runtimeEnvironmentId)
clearActiveRuntimeEnvironmentFocusIfMatches(store, runtime.runtimeEnvironmentId)
} catch {
// Cleanup of provider resources matters more than hiding a stale local
// environment row; users can still remove that manually.

View File

@ -275,10 +275,8 @@ describe('registerEphemeralVmHandlers', () => {
} as never)
expect(cleaned).toEqual(expect.objectContaining({ status: 'cleaned' }))
expect(listEnvironments(userDataPath)).toEqual([])
expect(store.updateSettings).toHaveBeenLastCalledWith(
{ activeRuntimeEnvironmentId: null },
{ notifyListeners: true }
)
expect(store.getSettings().activeRuntimeEnvironmentId).toBe(result.environment!.id)
expect(store.updateSettings).toHaveBeenCalledTimes(1)
})
it('provisions an ssh recipe without creating a runtime environment', async () => {

View File

@ -12210,7 +12210,6 @@ describe('registerPtyHandlers', () => {
expect(result).toEqual({
id: expect.any(String),
pid: 12345,
wslDistro: null,
incarnationId: expect.any(String)
})
expect(spawnMock).toHaveBeenCalledTimes(1)

View File

@ -168,8 +168,6 @@ describe('registerRuntimeEnvironmentHandlers', () => {
const added = await add(null, { name: 'desk', pairingCode: pairingCode() })
expect(JSON.stringify(added)).not.toContain('device-token')
expect(JSON.stringify(added)).not.toContain('publicKeyB64')
activeRuntimeEnvironmentId = added.environment.id
const list = handler<undefined, { id: string; name: string }[]>('runtimeEnvironments:list')
expect(await list(null, undefined)).toMatchObject([{ id: added.environment.id, name: 'desk' }])
expect(JSON.stringify(await list(null, undefined))).not.toContain('device-token')
@ -190,16 +188,31 @@ describe('registerRuntimeEnvironmentHandlers', () => {
expect(removed).toMatchObject({
removed: { id: added.environment.id, name: 'desk' }
})
expect(store.updateSettings).toHaveBeenCalledWith(
{ activeRuntimeEnvironmentId: null },
{ notifyListeners: true }
)
expect(activeRuntimeEnvironmentId).toBeNull()
expect(closeRemoteRuntimeRequestConnectionMock).toHaveBeenCalledWith(added.environment.id)
expect(JSON.stringify(removed)).not.toContain('device-token')
expect(await list(null, undefined)).toEqual([])
})
it('requires an explicit Advanced selection before removing the Active Server', async () => {
registerRuntimeEnvironmentHandlers(store as never)
const add = handler<
{ name: string; pairingCode: string },
{ environment: { id: string; name: string } }
>('runtimeEnvironments:addFromPairingCode')
const added = await add(null, { name: 'desk', pairingCode: pairingCode() })
activeRuntimeEnvironmentId = added.environment.id
const remove = handler<{ selector: string }, { removed: { id: string } }>(
'runtimeEnvironments:remove'
)
expect(() => remove(null, { selector: added.environment.id })).toThrow(
'Choose another Active Server in Advanced'
)
expect(activeRuntimeEnvironmentId).toBe(added.environment.id)
expect(store.updateSettings).not.toHaveBeenCalled()
})
it('disconnects a saved runtime without removing it', async () => {
registerRuntimeEnvironmentHandlers(store as never)

View File

@ -14,7 +14,6 @@ import type { RuntimeStatus } from '../../shared/runtime-types'
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client'
import type { Store } from '../persistence'
import { clearActiveRuntimeEnvironmentFocusIfMatches } from '../runtime-environment-focus-self-heal'
import { closeRemoteRuntimeRequestConnection } from './runtime-environment-request-connections'
import {
advanceRuntimeEnvironmentTransportGeneration,
@ -80,9 +79,7 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
}
ipcMain.removeAllListeners('runtimeEnvironments:subscriptionBinary')
ipcMain.handle('runtimeEnvironments:list', (): PublicKnownRuntimeEnvironment[] =>
listPublicRuntimeEnvironments()
)
ipcMain.handle('runtimeEnvironments:list', listPublicRuntimeEnvironments)
ipcMain.handle(
'runtimeEnvironments:addFromPairingCode',
(
@ -92,21 +89,22 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
environment: redactRuntimeEnvironment(addEnvironmentFromPairingCode(getUserDataPath(), args))
})
)
ipcMain.handle(
'runtimeEnvironments:resolve',
(_event, args: { selector: string }): PublicKnownRuntimeEnvironment =>
redactRuntimeEnvironment(resolveEnvironment(getUserDataPath(), args.selector))
ipcMain.handle('runtimeEnvironments:resolve', (_event, args: { selector: string }) =>
redactRuntimeEnvironment(resolveEnvironment(getUserDataPath(), args.selector))
)
ipcMain.handle(
'runtimeEnvironments:remove',
(_event, args: { selector: string }): { removed: PublicKnownRuntimeEnvironment } => {
const environment = resolveEnvironment(getUserDataPath(), args.selector)
if (store.getSettings().activeRuntimeEnvironmentId === environment.id) {
throw new Error('Choose another Active Server in Advanced before removing this server.')
}
const removed = removeEnvironment(getUserDataPath(), args.selector)
invalidateRuntimeEnvironmentTransport(removed.id)
if (args.selector !== removed.id) {
closeRemoteRuntimeRequestConnection(args.selector)
clearSharedControlSupport(args.selector)
}
clearActiveRuntimeEnvironmentFocusIfMatches(store, removed.id)
return { removed: redactRuntimeEnvironment(removed) }
}
)

View File

@ -9,6 +9,7 @@ const {
previewGhosttyImportMock,
previewWarpThemeImportMock,
prepareLocalWorktreeRootsForReposMock,
resolveEnvironmentMock,
rebuildAppMenuMock
} = vi.hoisted(() => ({
applyAppIconMock: vi.fn(),
@ -19,10 +20,12 @@ const {
previewGhosttyImportMock: vi.fn(),
previewWarpThemeImportMock: vi.fn(),
prepareLocalWorktreeRootsForReposMock: vi.fn(),
resolveEnvironmentMock: vi.fn(),
rebuildAppMenuMock: vi.fn()
}))
vi.mock('electron', () => ({
app: { getPath: vi.fn(() => '/test/user-data') },
BrowserWindow: { getAllWindows: browserWindowGetAllWindowsMock },
ipcMain: { handle: handleMock, on: onMock },
nativeTheme: { themeSource: 'system' }
@ -52,6 +55,10 @@ vi.mock('../menu/register-app-menu', () => ({
rebuildAppMenu: rebuildAppMenuMock
}))
vi.mock('../../shared/runtime-environment-store', () => ({
resolveEnvironment: resolveEnvironmentMock
}))
import { registerSettingsHandlers } from './settings'
const settingsInvokeEvent = { sender: { id: 1 } }
@ -79,6 +86,12 @@ describe('registerSettingsHandlers', () => {
previewGhosttyImportMock.mockClear()
previewWarpThemeImportMock.mockClear()
prepareLocalWorktreeRootsForReposMock.mockReset().mockResolvedValue(undefined)
resolveEnvironmentMock.mockReset().mockImplementation((_userDataPath, selector) => {
if (selector !== 'windows-2' && selector !== 'Windows 2') {
throw new Error('Runtime environment not found')
}
return { id: 'windows-2' }
})
rebuildAppMenuMock.mockClear()
browserWindowGetAllWindowsMock.mockReset()
store.getSettings.mockReset()
@ -108,6 +121,52 @@ describe('registerSettingsHandlers', () => {
expect(event.returnValue).toEqual({ terminalMainSideEffectAuthority: false })
})
it('rejects durable Active Server writes through generic settings:set', async () => {
store.getSettings.mockReturnValue({ activeRuntimeEnvironmentId: null })
store.updateSettings.mockReturnValue({ activeRuntimeEnvironmentId: null })
registerSettingsHandlers(store as never)
const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as (
event: typeof settingsInvokeEvent,
args: { activeRuntimeEnvironmentId: string }
) => Promise<unknown>
await handler(settingsInvokeEvent, { activeRuntimeEnvironmentId: 'windows-2' })
expect(store.updateSettings).toHaveBeenCalledWith(
{},
expect.objectContaining({ originWebContentsId: 1 })
)
})
it('persists Active Server only through the dedicated preference channel', () => {
store.updateSettings.mockReturnValue({ activeRuntimeEnvironmentId: 'windows-2' })
registerSettingsHandlers(store as never)
const handler = handleMock.mock.calls.find(
(call) => call[0] === 'settings:set-active-runtime-environment-preference'
)?.[1] as (event: typeof settingsInvokeEvent, args: { environmentId: string | null }) => unknown
expect(handler(settingsInvokeEvent, { environmentId: ' windows-2 ' })).toEqual({
activeRuntimeEnvironmentId: 'windows-2'
})
expect(store.updateSettings).toHaveBeenCalledWith(
{ activeRuntimeEnvironmentId: 'windows-2' },
{ notifyListeners: true, originWebContentsId: 1 }
)
handler(settingsInvokeEvent, { environmentId: 'Windows 2' })
expect(store.updateSettings).toHaveBeenLastCalledWith(
{ activeRuntimeEnvironmentId: 'windows-2' },
{ notifyListeners: true, originWebContentsId: 1 }
)
expect(() => handler(settingsInvokeEvent, { environmentId: 42 as never })).toThrow(
'Invalid Active Server preference'
)
expect(() => handler(settingsInvokeEvent, { environmentId: 'does-not-exist' })).toThrow(
'Runtime environment not found'
)
expect(store.updateSettings).toHaveBeenCalledTimes(2)
})
it('applies bot-author deltas against the authoritative settings snapshot', () => {
store.getSettings
.mockReturnValueOnce({ prBotAuthorOverrides: ['alice'] })

View File

@ -1,4 +1,4 @@
import { BrowserWindow, ipcMain, nativeTheme } from 'electron'
import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron'
import type { Store } from '../persistence'
import type { GlobalSettings, PersistedState } from '../../shared/types'
import { listSystemFontFamilies } from '../system-fonts'
@ -22,6 +22,7 @@ import { normalizeTerminalLineHeight } from '../../shared/terminal-line-height-s
import { prepareLocalWorktreeRootsForRepos } from '../worktree-root-preparation'
import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher'
import { applyPRBotAuthorOverride } from '../../shared/pr-bot-author-overrides'
import { resolveEnvironment } from '../../shared/runtime-environment-store'
// Why: the whitelist is the source-of-truth for which keys we emit on. Casting
// to a Set once at module load lets the IPC handler's per-key membership
@ -92,6 +93,9 @@ export function registerSettingsHandlers(
ipcMain.handle('settings:set', async (event, args: Partial<GlobalSettings>) => {
const sanitizedArgs = sanitizeRendererSettingsUpdate(args)
// Why: connection/navigation code receives the generic settings writer; the
// durable server preference has a dedicated Advanced-control boundary.
delete sanitizedArgs.activeRuntimeEnvironmentId
// Why: Floating Workspace grants are trusted only when written by the
// main-process directory picker, never by renderer-provided settings IPC.
delete sanitizedArgs.floatingTerminalTrustedCwds
@ -205,6 +209,23 @@ export function registerSettingsHandlers(
return result
})
ipcMain.handle(
'settings:set-active-runtime-environment-preference',
(event, args: { environmentId?: unknown }): GlobalSettings => {
const requestedEnvironmentId = args?.environmentId
if (requestedEnvironmentId !== null && typeof requestedEnvironmentId !== 'string') {
throw new Error('Invalid Active Server preference')
}
const requestedId = requestedEnvironmentId?.trim() || null
const environmentId =
requestedId === null ? null : resolveEnvironment(app.getPath('userData'), requestedId).id
return store.updateSettings(
{ activeRuntimeEnvironmentId: environmentId },
{ notifyListeners: true, originWebContentsId: event.sender.id }
)
}
)
ipcMain.handle('settings:listFonts', () => {
return listSystemFontFamilies()
})

View File

@ -231,7 +231,6 @@ describe('LocalPtyProvider', () => {
expect(second).toEqual({
id: 'serve-session-1',
pid: 12345,
wslDistro: null,
isReattach: true
})
expect(mockProc.resize).toHaveBeenCalledWith(120, 40)
@ -1788,6 +1787,28 @@ describe('LocalPtyProvider', () => {
expect(newEntries[0]).toHaveProperty('title', 'zsh')
expect(newEntries[0]).toHaveProperty('cwd', '/tmp/owned-cwd')
expect(newEntries[0]).toHaveProperty('worktreeId', 'repo::/tmp/owned-cwd')
expect(newEntries[0]).not.toHaveProperty('wslDistro')
expect(newEntries[1]).not.toHaveProperty('wslDistro')
})
it('reports native and WSL ownership explicitly on Windows', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const native = await provider.spawn({
cols: 80,
rows: 24,
cwd: 'C:\\repo',
shellOverride: 'powershell.exe'
})
const wsl = await provider.spawn({
cols: 80,
rows: 24,
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
})
const processes = await provider.listProcesses()
expect(processes.find((process) => process.id === native.id)?.wslDistro).toBeNull()
expect(processes.find((process) => process.id === wsl.id)?.wslDistro).toBe('Ubuntu')
})
})

View File

@ -847,7 +847,11 @@ export class LocalPtyProvider implements IPtyProvider {
const proc = spawnResult.process
const spawnedShellIsWsl =
process.platform === 'win32' && pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe'
const spawnedWslDistro = spawnedShellIsWsl ? (launchWslDistro ?? undefined) : null
const spawnedWslDistro = spawnedShellIsWsl
? (launchWslDistro ?? undefined)
: process.platform === 'win32'
? null
: undefined
createPtyPhysicalExit(id)
ptyProcesses.set(id, proc)
ptyInitialCwd.set(id, cwd)
@ -1323,7 +1327,8 @@ export class LocalPtyProvider implements IPtyProvider {
cwd: ptyInitialCwd.get(id) ?? '',
title: proc.process || ptyShellName.get(id) || 'shell',
...(ptyWorktreeId.get(id) ? { worktreeId: ptyWorktreeId.get(id) } : {}),
...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {})
...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {}),
...(ptyWslDistroById.has(id) ? { wslDistro: ptyWslDistroById.get(id) ?? null } : {})
}))
}

View File

@ -0,0 +1,16 @@
import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
export type PtyProcessInfo = {
id: string
incarnationId?: PtyIncarnationId
cwd: string
title: string
/** Owning worktree when the provider can report it authoritatively. */
worktreeId?: string
/** Trusted ORCA_TERMINAL_HANDLE exported into this PTY, when known. */
terminalHandle?: string
/** Exact WSL owner reported by the PTY provider; null means native Windows. */
wslDistro?: string | null
agentSessionOwners?: AgentSessionOwnerBinding[]
}

View File

@ -29,9 +29,9 @@ import type { PtySpawnResult } from './pty-spawn-result'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import type {
AgentSessionExecutionClaim,
AgentSessionOwnerBinding,
AgentSessionSurfaceBinding
} from '../../shared/agent-session-host-authority'
import type { PtyProcessInfo } from './pty-process-info'
export type {
PtyBackgroundStreamEvent,
@ -114,19 +114,7 @@ export type PtySpawnOptions = {
signal?: AbortSignal
}
export type { PtySpawnResult }
export type PtyProcessInfo = {
id: string
incarnationId?: PtyIncarnationId
cwd: string
title: string
/** Owning worktree when the provider can report it authoritatively. */
worktreeId?: string
/** Trusted ORCA_TERMINAL_HANDLE exported into this PTY, when known. */
terminalHandle?: string
agentSessionOwners?: AgentSessionOwnerBinding[]
}
export type { PtyProcessInfo, PtySpawnResult }
type PtyProbeOptions = { signal?: AbortSignal }
@ -393,10 +381,7 @@ export type IGitProvider = {
// ─── Provider Registry ──────────────────────────────────────────────
/**
* Routes operations to the correct provider based on connectionId.
* null/undefined connectionId = local provider.
*/
/** Routes operations by connectionId; null/undefined selects the local provider. */
export type IProviderRegistry = {
getPtyProvider(connectionId: string | null | undefined): IPtyProvider
getFilesystemProvider(connectionId: string | null | undefined): IFilesystemProvider

View File

@ -1,156 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../shared/types'
import type { KnownRuntimeEnvironment } from '../shared/runtime-environments'
import {
clearActiveRuntimeEnvironmentFocusIfMatches,
selfHealRuntimeEnvironmentFocus
} from './runtime-environment-focus-self-heal'
function environment(
id: string,
source?: KnownRuntimeEnvironment['source']
): KnownRuntimeEnvironment {
return {
id,
name: id,
createdAt: 0,
updatedAt: 0,
lastUsedAt: null,
runtimeId: null,
...(source ? { source } : {}),
endpoints: [
{
id: `ws-${id}`,
kind: 'websocket',
label: 'WebSocket',
endpoint: 'ws://127.0.0.1:6768',
deviceToken: 'token',
publicKeyB64: 'key'
}
],
preferredEndpointId: `ws-${id}`
}
}
function makeStore(activeRuntimeEnvironmentId: string | null | undefined) {
const settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> = {}
if (activeRuntimeEnvironmentId !== undefined) {
settings.activeRuntimeEnvironmentId = activeRuntimeEnvironmentId
}
const updateSettings = vi.fn((updates: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'>) => {
settings.activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId
return settings
})
return {
store: {
getSettings: () => settings,
updateSettings
},
updateSettings
}
}
describe('runtime environment focus self-heal', () => {
it('keeps a focus id that resolves to a user-managed environment', () => {
const { store, updateSettings } = makeStore('env-1')
selfHealRuntimeEnvironmentFocus({
store,
userDataPath: '/user-data',
listKnownEnvironments: () => [environment('env-1')]
})
expect(updateSettings).not.toHaveBeenCalled()
})
it('clears a dangling focus id and logs one diagnostic line', () => {
const { store, updateSettings } = makeStore('missing-env')
const log = vi.fn()
selfHealRuntimeEnvironmentFocus({
store,
userDataPath: '/user-data',
listKnownEnvironments: () => [environment('env-1')],
log
})
expect(updateSettings).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: null })
expect(log).toHaveBeenCalledTimes(1)
expect(log.mock.calls[0][0]).toContain('missing-env')
})
it('clears an ephemeral-VM focus id after restart', () => {
const { store, updateSettings } = makeStore('vm-env')
const log = vi.fn()
selfHealRuntimeEnvironmentFocus({
store,
userDataPath: '/user-data',
listKnownEnvironments: () => [environment('vm-env', 'ephemeral-vm')],
log
})
expect(updateSettings).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: null })
expect(log).toHaveBeenCalledTimes(1)
})
it('leaves null and absent focus settings untouched', () => {
const nullCase = makeStore(null)
const absentCase = makeStore(undefined)
const listKnownEnvironments = vi.fn(() => [environment('env-1')])
selfHealRuntimeEnvironmentFocus({
store: nullCase.store,
userDataPath: '/user-data',
listKnownEnvironments
})
selfHealRuntimeEnvironmentFocus({
store: absentCase.store,
userDataPath: '/user-data',
listKnownEnvironments
})
expect(nullCase.updateSettings).not.toHaveBeenCalled()
expect(absentCase.updateSettings).not.toHaveBeenCalled()
expect(listKnownEnvironments).not.toHaveBeenCalled()
})
it('normalizes an empty persisted id to null without reading the registry', () => {
const { store, updateSettings } = makeStore('')
const listKnownEnvironments = vi.fn(() => [environment('env-1')])
selfHealRuntimeEnvironmentFocus({
store,
userDataPath: '/user-data',
listKnownEnvironments
})
expect(updateSettings).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: null })
expect(listKnownEnvironments).not.toHaveBeenCalled()
})
it('fails soft when the registry cannot be read', () => {
const { store, updateSettings } = makeStore('env-1')
selfHealRuntimeEnvironmentFocus({
store,
userDataPath: '/user-data',
listKnownEnvironments: () => {
throw new Error('invalid registry')
}
})
expect(updateSettings).not.toHaveBeenCalled()
})
it('clears the active focus on matching in-process removal with listener notification', () => {
const { store, updateSettings } = makeStore('env-1')
clearActiveRuntimeEnvironmentFocusIfMatches(store, 'env-1')
expect(updateSettings).toHaveBeenCalledWith(
{ activeRuntimeEnvironmentId: null },
{ notifyListeners: true }
)
})
})

View File

@ -1,71 +0,0 @@
import type { GlobalSettings } from '../shared/types'
import { listEnvironments } from '../shared/runtime-environment-store'
import {
isUserManagedRuntimeEnvironment,
type KnownRuntimeEnvironment
} from '../shared/runtime-environments'
type RuntimeEnvironmentFocusStore = {
getSettings: () => Pick<GlobalSettings, 'activeRuntimeEnvironmentId'>
updateSettings: (
updates: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'>,
options?: { notifyListeners?: boolean }
) => unknown
}
type SelfHealRuntimeEnvironmentFocusArgs = {
store: RuntimeEnvironmentFocusStore
userDataPath: string
listKnownEnvironments?: (userDataPath: string) => KnownRuntimeEnvironment[]
log?: (message: string) => void
}
function logClearedFocus(log: ((message: string) => void) | undefined, reason: string): void {
const writeLog = log ?? console.info
writeLog(`[runtime-environment-focus] cleared active runtime environment: ${reason}`)
}
export function clearActiveRuntimeEnvironmentFocusIfMatches(
store: RuntimeEnvironmentFocusStore,
environmentId: string
): void {
if (store.getSettings().activeRuntimeEnvironmentId !== environmentId) {
return
}
store.updateSettings({ activeRuntimeEnvironmentId: null }, { notifyListeners: true })
}
export function selfHealRuntimeEnvironmentFocus({
store,
userDataPath,
listKnownEnvironments = listEnvironments,
log
}: SelfHealRuntimeEnvironmentFocusArgs): void {
const activeRuntimeEnvironmentId = store.getSettings().activeRuntimeEnvironmentId
if (activeRuntimeEnvironmentId === undefined || activeRuntimeEnvironmentId === null) {
return
}
if (activeRuntimeEnvironmentId.trim() === '') {
store.updateSettings({ activeRuntimeEnvironmentId: null })
logClearedFocus(log, 'empty persisted id')
return
}
let environments: KnownRuntimeEnvironment[]
try {
environments = listKnownEnvironments(userDataPath)
} catch {
// Why: an unreadable registry must not clear a possibly-valid focus; keep
// it and let a later launch heal once the registry reads again.
return
}
const focusedEnvironment = environments.find((entry) => entry.id === activeRuntimeEnvironmentId)
if (focusedEnvironment && isUserManagedRuntimeEnvironment(focusedEnvironment)) {
return
}
store.updateSettings({ activeRuntimeEnvironmentId: null })
logClearedFocus(log, `dangling id ${activeRuntimeEnvironmentId}`)
}

File diff suppressed because it is too large Load Diff

View File

@ -368,6 +368,8 @@ import type {
RuntimeTerminalFocus,
RuntimeTerminalClose,
RuntimeTerminalListResult,
RuntimeTerminalOrphanAdoptionRequest,
RuntimeTerminalOrphanAdoptionResult,
RuntimeWorktreeTerminalSleepResult,
RuntimeTerminalResolvePane,
RuntimeTerminalState,
@ -426,6 +428,11 @@ import {
buildHeadlessTabGroupMove,
buildHeadlessTabGroupSplit
} from './headless-tab-group-split-layout'
import {
hasExactTerminalOrphanGroupLayout,
mergeTerminalOrphanGroupLayout
} from './terminal-orphan-topology'
import { terminalOrphanExecutionOwnersEqual } from './terminal-orphan-owner'
import {
retireTerminalSurfacesFromSnapshot,
type RetiredTerminalSurface
@ -1134,6 +1141,7 @@ type RuntimePtyWorktreeRecord = {
lastOscTitleAt: number | null
managementTitle: string | null
managementTitleAt: number | null
controllerTitle: string | null
title: string | null
titleUpdatedAt: number | null
lastOutputAt: number | null
@ -2462,6 +2470,10 @@ export class OrcaRuntimeService {
private handles = new Map<string, TerminalHandleRecord>()
private handleByLeafKey = new Map<string, string>()
private handleByPtyId = new Map<string, string>()
private controllerTerminalIdentityByPtyId = new Map<
string,
{ handle: string; incarnationId: string; wslDistro?: string | null }
>()
private detachedPreAllocatedLeaves = new Map<string, RuntimeLeafRecord>()
private graphSyncCallbacks: (() => void)[] = []
private waitersByHandle = new Map<string, Set<TerminalWaiter>>()
@ -7141,11 +7153,29 @@ export class OrcaRuntimeService {
}
}
private adoptControllerTerminalHandle(ptyId: string, handle: string | undefined): void {
private adoptControllerTerminalHandle(
ptyId: string,
handle: string | undefined,
incarnationId?: string
): void {
const trimmed = handle?.trim()
if (!trimmed || !trimmed.startsWith('term_')) {
return
}
const pty = this.ptysById.get(ptyId)
const changedIncarnation = Boolean(
incarnationId && pty?.incarnationId && incarnationId !== pty.incarnationId
)
if (changedIncarnation) {
const priorHandle = this.handleByPtyId.get(ptyId)
this.invalidateAllHandlesForPty(ptyId)
pty!.tabId = null
pty!.paneKey = null
// Reusing an exported handle would make stale client metadata name the replacement process.
if (priorHandle === trimmed) {
return
}
}
if (this.isTerminalHandleAdoptionBlocked(ptyId, trimmed)) {
return
}
@ -7154,6 +7184,23 @@ export class OrcaRuntimeService {
this.registerPreAllocatedHandleForPty(ptyId, trimmed)
}
private invalidateAllHandlesForPty(ptyId: string): void {
this.handleByPtyId.delete(ptyId)
const invalidated = new Set<string>()
for (const [handle, record] of this.handles) {
if (record.ptyId === ptyId) {
invalidated.add(handle)
this.handles.delete(handle)
this.rejectWaitersForHandle(handle, 'terminal_handle_stale')
}
}
for (const [leafKey, handle] of this.handleByLeafKey) {
if (invalidated.has(handle)) {
this.handleByLeafKey.delete(leafKey)
}
}
}
// Why: adoption is best-effort restart recovery and must be first-wins.
// Re-keying a pty that already has a handle this session would strand
// waiters registered under the old handle, and provider-reported values
@ -12417,7 +12464,7 @@ export class OrcaRuntimeService {
async listTerminals(
worktreeSelector?: string,
limit = DEFAULT_TERMINAL_LIST_LIMIT,
opts: { requireFreshPtyLiveness?: boolean } = {}
opts: { handles?: readonly string[]; requireFreshPtyLiveness?: boolean } = {}
): Promise<RuntimeTerminalListResult> {
if (!Number.isInteger(limit) || limit <= 0) {
throw new Error('invalid_limit')
@ -12529,7 +12576,11 @@ export class OrcaRuntimeService {
terminals.push(this.buildPtyTerminalSummary(pty, worktreesById))
}
const listedTerminals = terminals.slice(0, limit)
const requestedHandles = opts.handles ? new Set(opts.handles) : null
const matchingTerminals = requestedHandles
? terminals.filter((terminal) => requestedHandles.has(terminal.handle))
: terminals
const listedTerminals = matchingTerminals.slice(0, limit)
const visualLayouts = this.buildTerminalVisualLayouts(
listedTerminals,
worktreesById,
@ -12539,8 +12590,480 @@ export class OrcaRuntimeService {
return {
terminals: listedTerminals,
...(visualLayouts.length > 0 ? { visualLayouts } : {}),
totalCount: terminals.length,
truncated: terminals.length > limit
topologyRevisions: Object.fromEntries(
[...new Set(matchingTerminals.map((terminal) => terminal.worktreeId))].map((worktreeId) => [
worktreeId,
this.getTerminalTopologyRevision(worktreeId)
])
),
totalCount: matchingTerminals.length,
truncated: matchingTerminals.length > limit
}
}
private getTerminalTopologyRevision(worktreeId: string): number {
const repoId = getRepoIdFromWorktreeId(worktreeId)
return (
this.store?.getWorkspaceSession?.()?.terminalTopologyRevisionByRepoId?.[repoId] ??
this.terminalTopologyRevisionByRepoId.get(repoId) ??
0
)
}
async adoptTerminalOrphans(
request: RuntimeTerminalOrphanAdoptionRequest
): Promise<RuntimeTerminalOrphanAdoptionResult> {
if (request.claims.length === 0) {
throw new Error('terminal_orphan_claims_required')
}
const worktree = await this.resolveWorktreeSelector(request.worktree)
const livePtyIds = await this.refreshPtyWorktreeRecordsFromController([worktree], worktree.id)
if (!livePtyIds) {
throw new Error('terminal_liveness_unavailable')
}
const store = this.store
const session = store?.getWorkspaceSession?.()
if (!store?.setWorkspaceSession || !store.flushOrThrow || !session) {
throw new Error('workspace_session_unavailable')
}
const sessionWorktreeId = resolveTerminalSessionWorktreeId(session, worktree.id)
if (!sessionWorktreeId) {
throw new Error('terminal_orphan_competing_owner')
}
const repoId = getRepoIdFromWorktreeId(worktree.id)
const worktreeRepo = store.getRepo(repoId)
if (!worktreeRepo) {
throw new Error('terminal_orphan_owner_mismatch')
}
const worktreeConnectionId = worktreeRepo.connectionId ?? null
let worktreeWslDistro: string | null = null
if (!worktreeConnectionId) {
try {
worktreeWslDistro =
getLocalProjectWorktreeGitOptions(this.requireStore(), worktreeRepo).wslDistro ?? null
} catch {
throw new Error('terminal_orphan_owner_mismatch')
}
}
const currentRevision = this.getTerminalTopologyRevision(worktree.id)
const seenPtyIds = new Set<string>()
const seenPaneKeys = new Set<string>()
const validated = request.claims.map((claim) => {
const paneKey = makePaneKey(claim.tabId, claim.leafId)
if (seenPtyIds.has(claim.ptyId) || seenPaneKeys.has(paneKey)) {
throw new Error('terminal_orphan_claim_duplicate')
}
seenPtyIds.add(claim.ptyId)
seenPaneKeys.add(paneKey)
const live = this.getLivePtyForHandle(claim.terminal)
const pty = live?.pty
const controllerIdentity = this.controllerTerminalIdentityByPtyId.get(claim.ptyId)
if (
!pty ||
pty.ptyId !== claim.ptyId ||
controllerIdentity?.handle !== claim.terminal ||
controllerIdentity?.incarnationId !== claim.incarnationId ||
!livePtyIds.has(claim.ptyId) ||
!pty.connected ||
!pty.incarnationId ||
pty.incarnationId !== claim.incarnationId
) {
throw new Error('terminal_orphan_stale')
}
if (
!runtimeWorktreeIdsEqual(pty.worktreeId, worktree.id) ||
!terminalOrphanExecutionOwnersEqual(
{ connectionId: worktreeConnectionId, wslDistro: worktreeWslDistro },
{
connectionId: pty.connectionId ?? null,
...(controllerIdentity?.wslDistro !== undefined
? { wslDistro: controllerIdentity.wslDistro }
: process.platform === 'win32' && !worktreeConnectionId
? {}
: { wslDistro: null })
}
)
) {
throw new Error('terminal_orphan_owner_mismatch')
}
const visualOwners = this.getLeavesForPty(claim.ptyId)
if (
visualOwners.some(
(owner) =>
!runtimeWorktreeIdsEqual(owner.worktreeId, worktree.id) ||
owner.tabId !== claim.tabId ||
owner.leafId !== claim.leafId
)
) {
throw new Error('terminal_orphan_already_visual')
}
if ((pty.tabId && pty.tabId !== claim.tabId) || (pty.paneKey && pty.paneKey !== paneKey)) {
throw new Error('terminal_orphan_competing_owner')
}
return { claim, pty, paneKey }
})
const persistedBindingsByPtyId = new Map<string, { worktreeId: string; paneKey: string }[]>()
const addPersistedBinding = (
ptyId: string,
binding: { worktreeId: string; paneKey: string }
): void => {
const bindings = persistedBindingsByPtyId.get(ptyId) ?? []
bindings.push(binding)
persistedBindingsByPtyId.set(ptyId, bindings)
}
for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree)) {
for (const tab of tabs) {
const layout = session.terminalLayoutsByTabId[tab.id]
for (const [leafId, boundPtyId] of Object.entries(layout?.ptyIdsByLeafId ?? {})) {
if (boundPtyId) {
addPersistedBinding(boundPtyId, {
worktreeId,
paneKey: makePaneKey(tab.id, leafId)
})
}
}
if (tab.ptyId && !layout) {
addPersistedBinding(tab.ptyId, { worktreeId, paneKey: tab.id })
}
}
}
const persistedBinding = (ptyId: string): { worktreeId: string; paneKey: string } | null => {
const bindings = persistedBindingsByPtyId.get(ptyId) ?? []
if (bindings.length > 1) {
throw new Error('terminal_orphan_competing_owner')
}
return bindings[0] ?? null
}
const isExactPersisted = validated.every(({ claim, paneKey }) => {
const binding = persistedBinding(claim.ptyId)
return (
binding !== null &&
runtimeWorktreeIdsEqual(binding.worktreeId, worktree.id) &&
binding.paneKey === paneKey &&
session.terminalPtyIncarnationsByPaneKey?.[paneKey] === claim.incarnationId
)
})
if (isExactPersisted && sessionWorktreeId === worktree.id) {
return {
adopted: false,
topologyRevision: currentRevision,
snapshot: await this.listMobileSessionTabs(`id:${worktree.id}`)
}
}
if (currentRevision !== request.expectedTopologyRevision) {
throw new Error('terminal_topology_conflict')
}
const topologyTabsById = new Map(request.topology?.tabs.map((tab) => [tab.tabId, tab]) ?? [])
const topologyGroups = request.topology?.groups ?? []
if (request.topology) {
const claimedLeafIdsByTabId = new Map<string, Set<string>>()
for (const { claim } of validated) {
const leafIds = claimedLeafIdsByTabId.get(claim.tabId) ?? new Set<string>()
leafIds.add(claim.leafId)
claimedLeafIdsByTabId.set(claim.tabId, leafIds)
}
if (
topologyTabsById.size !== request.topology.tabs.length ||
topologyTabsById.size !== claimedLeafIdsByTabId.size
) {
throw new Error('terminal_orphan_topology_invalid')
}
for (const [tabId, claimedLeafIds] of claimedLeafIdsByTabId) {
const topologyTab = topologyTabsById.get(tabId)
if (!topologyTab) {
throw new Error('terminal_orphan_topology_invalid')
}
const topologyLeafIds = new Set<string>()
const nodes = [topologyTab.root]
let leafCount = 0
while (nodes.length > 0) {
const node = nodes.pop()!
if (node.type === 'leaf') {
leafCount += 1
topologyLeafIds.add(node.leafId)
} else {
nodes.push(node.first, node.second)
}
}
if (
leafCount !== topologyLeafIds.size ||
topologyLeafIds.size !== claimedLeafIds.size ||
[...topologyLeafIds].some((leafId) => !claimedLeafIds.has(leafId)) ||
!topologyLeafIds.has(topologyTab.activeLeafId) ||
(topologyTab.expandedLeafId !== null && !topologyLeafIds.has(topologyTab.expandedLeafId))
) {
throw new Error('terminal_orphan_topology_invalid')
}
}
const seenGroupIds = new Set<string>()
const groupedTabIds = new Set<string>()
for (const group of topologyGroups) {
if (seenGroupIds.has(group.id) || !group.tabOrder.includes(group.activeTabId)) {
throw new Error('terminal_orphan_topology_invalid')
}
seenGroupIds.add(group.id)
for (const tabId of group.tabOrder) {
if (!topologyTabsById.has(tabId) || groupedTabIds.has(tabId)) {
throw new Error('terminal_orphan_topology_invalid')
}
groupedTabIds.add(tabId)
}
if (group.recentTabIds?.some((tabId) => !group.tabOrder.includes(tabId))) {
throw new Error('terminal_orphan_topology_invalid')
}
}
if (groupedTabIds.size !== topologyTabsById.size) {
throw new Error('terminal_orphan_topology_invalid')
}
if (request.topology.groupLayout) {
if (!hasExactTerminalOrphanGroupLayout(request.topology.groupLayout, seenGroupIds)) {
throw new Error('terminal_orphan_topology_invalid')
}
}
}
for (const { claim, paneKey } of validated) {
const existingBinding = persistedBinding(claim.ptyId)
if (
existingBinding &&
(!runtimeWorktreeIdsEqual(existingBinding.worktreeId, worktree.id) ||
existingBinding.paneKey !== paneKey)
) {
throw new Error('terminal_orphan_competing_owner')
}
const proposedPtyId =
session.terminalLayoutsByTabId[claim.tabId]?.ptyIdsByLeafId?.[claim.leafId]
if (proposedPtyId && proposedPtyId !== claim.ptyId) {
throw new Error('terminal_orphan_surface_occupied')
}
const graphOwner = this.leaves.get(this.getLeafKey(claim.tabId, claim.leafId))
if (
graphOwner &&
(graphOwner.ptyId !== claim.ptyId ||
!runtimeWorktreeIdsEqual(graphOwner.worktreeId, worktree.id))
) {
throw new Error('terminal_orphan_surface_occupied')
}
if (
Object.entries(session.tabsByWorktree).some(
([ownerWorktreeId, tabs]) =>
!runtimeWorktreeIdsEqual(ownerWorktreeId, worktree.id) &&
tabs.some((tab) => tab.id === claim.tabId)
)
) {
throw new Error('terminal_orphan_surface_occupied')
}
if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) {
throw new Error('terminal_orphan_surface_retired')
}
for (const snapshot of this.mobileSessionTabsByWorktree.values()) {
const surfaceOwner = snapshot.tabs.find(
(tab): tab is RuntimeMobileSessionTerminalTab =>
tab.type === 'terminal' &&
tab.parentTabId === claim.tabId &&
tab.leafId === claim.leafId
)
if (
surfaceOwner &&
(snapshot.worktree !== worktree.id || surfaceOwner.ptyId !== claim.ptyId)
) {
throw new Error('terminal_orphan_surface_occupied')
}
const owner = snapshot.tabs.find(
(tab): tab is RuntimeMobileSessionTerminalTab =>
tab.type === 'terminal' && tab.ptyId === claim.ptyId
)
if (
owner &&
(snapshot.worktree !== worktree.id ||
owner.parentTabId !== claim.tabId ||
owner.leafId !== claim.leafId)
) {
throw new Error('terminal_orphan_competing_owner')
}
}
}
const next = structuredClone(session)
canonicalizeTerminalSessionWorktreeId(next, sessionWorktreeId, worktree.id)
const existingTabs = next.tabsByWorktree[worktree.id] ?? []
const tabsById = new Map(existingTabs.map((tab) => [tab.id, tab]))
for (const { claim, pty, paneKey } of validated) {
let tab = tabsById.get(claim.tabId)
if (!tab) {
const title =
getLatestPtyTitle(pty) ?? pty.controllerTitle ?? `Terminal ${tabsById.size + 1}`
tab = {
id: claim.tabId,
ptyId: claim.ptyId,
worktreeId: worktree.id,
title,
defaultTitle: title,
customTitle: null,
color: null,
sortOrder: tabsById.size,
createdAt: Date.now(),
pendingActivationSpawn: true
}
tabsById.set(claim.tabId, tab)
}
const existingLayout = next.terminalLayoutsByTabId[claim.tabId]
const topologyTab = topologyTabsById.get(claim.tabId)
next.terminalLayoutsByTabId[claim.tabId] = topologyTab
? {
...existingLayout,
root: topologyTab.root,
activeLeafId: topologyTab.activeLeafId,
expandedLeafId: topologyTab.expandedLeafId,
ptyIdsByLeafId: {
...existingLayout?.ptyIdsByLeafId,
[claim.leafId]: claim.ptyId
}
}
: existingLayout
? {
...existingLayout,
root: this.collectPersistedTerminalLeafIds(existingLayout).includes(claim.leafId)
? existingLayout.root
: existingLayout.root === null
? { type: 'leaf', leafId: claim.leafId }
: {
type: 'split',
direction: 'vertical',
first: existingLayout.root,
second: { type: 'leaf', leafId: claim.leafId }
},
ptyIdsByLeafId: {
...existingLayout.ptyIdsByLeafId,
[claim.leafId]: claim.ptyId
}
}
: {
root: { type: 'leaf', leafId: claim.leafId },
activeLeafId: claim.leafId,
expandedLeafId: null,
ptyIdsByLeafId: { [claim.leafId]: claim.ptyId }
}
next.terminalPtyIncarnationsByPaneKey = {
...next.terminalPtyIncarnationsByPaneKey,
[paneKey]: claim.incarnationId
}
}
const adoptedTabIds = [...new Set(validated.map(({ claim }) => claim.tabId))]
next.tabsByWorktree[worktree.id] = [...tabsById.values()]
const activeTabId =
request.activeTabId && tabsById.has(request.activeTabId)
? request.activeTabId
: (adoptedTabIds[0] ?? null)
const existingGroups = next.tabGroups?.[worktree.id] ?? []
const targetGroupId =
(request.activeGroupId && existingGroups.some((group) => group.id === request.activeGroupId)
? request.activeGroupId
: existingGroups[0]?.id) ??
request.activeGroupId ??
randomUUID()
const proposedGroups = topologyGroups.map((group) => ({
...group,
worktreeId: worktree.id
}))
const groups =
existingGroups.length === 0 && proposedGroups.length > 0
? proposedGroups
: existingGroups.length > 0
? existingGroups
.map((group) => {
const proposed = proposedGroups.find((candidate) => candidate.id === group.id)
const tabOrder = proposed
? [
...group.tabOrder.filter((tabId) => !adoptedTabIds.includes(tabId)),
...proposed.tabOrder
]
: group.id === targetGroupId && proposedGroups.length === 0
? [...new Set([...group.tabOrder, ...adoptedTabIds])]
: group.tabOrder.filter((tabId) => !adoptedTabIds.includes(tabId))
return {
...group,
tabOrder,
activeTabId: proposed
? proposed.activeTabId
: group.id === targetGroupId && activeTabId
? activeTabId
: group.activeTabId && tabOrder.includes(group.activeTabId)
? group.activeTabId
: (tabOrder[0] ?? null),
...(proposed?.recentTabIds ? { recentTabIds: proposed.recentTabIds } : {})
}
})
.concat(
proposedGroups.filter(
(proposed) => !existingGroups.some((group) => group.id === proposed.id)
)
)
: [{ id: targetGroupId, worktreeId: worktree.id, activeTabId, tabOrder: adoptedTabIds }]
const retainedGroups = groups.filter((group) => group.tabOrder.length > 0)
next.tabGroups = {
...next.tabGroups,
[worktree.id]: retainedGroups
}
const mergedGroupLayout = mergeTerminalOrphanGroupLayout({
existingLayout: next.tabGroupLayouts?.[worktree.id],
existingGroupIds: existingGroups.map((group) => group.id),
proposedLayout: request.topology?.groupLayout,
proposedGroupIds: proposedGroups.map((group) => group.id),
mergedGroupIds: retainedGroups.map((group) => group.id)
})
if (mergedGroupLayout) {
next.tabGroupLayouts = {
...next.tabGroupLayouts,
[worktree.id]: mergedGroupLayout
}
}
const activeGroup =
(request.activeGroupId
? retainedGroups.find(
(group) =>
group.id === request.activeGroupId &&
(!activeTabId || group.tabOrder.includes(activeTabId))
)
: undefined) ??
retainedGroups.find((group) => activeTabId && group.tabOrder.includes(activeTabId)) ??
retainedGroups[0]!
const convergedActiveTabId =
activeTabId && activeGroup.tabOrder.includes(activeTabId)
? activeTabId
: activeGroup.activeTabId
next.activeTabIdByWorktree = {
...next.activeTabIdByWorktree,
...(convergedActiveTabId ? { [worktree.id]: convergedActiveTabId } : {})
}
next.activeGroupIdByWorktree = {
...next.activeGroupIdByWorktree,
[worktree.id]: activeGroup.id
}
const persisted = advanceTerminalTopologyRevision(next, worktree.id)
try {
store.setWorkspaceSession(persisted)
store.flushOrThrow()
} catch (error) {
store.setWorkspaceSession(session)
throw error
}
for (const { claim, pty, paneKey } of validated) {
pty.tabId = claim.tabId
pty.paneKey = paneKey
}
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id, {
force: true,
allowAttachedWindow: true,
onlyRuntimeOwnedTerminals: true
})
this.notifyMobileSessionTabsChanged(worktree.id)
return {
adopted: true,
topologyRevision: persisted.terminalTopologyRevisionByRepoId?.[repoId] ?? currentRevision + 1,
snapshot: await this.listMobileSessionTabs(`id:${worktree.id}`)
}
}
@ -12643,7 +13166,10 @@ export class OrcaRuntimeService {
return {
type: 'group',
groupId: group.id,
activeTabId: group.activeTabId,
activeTabId:
group.activeTabId && tabs.some((tab) => tab.tabId === group.activeTabId)
? group.activeTabId
: (tabs[0]?.tabId ?? null),
tabs
}
})
@ -24394,6 +24920,7 @@ export class OrcaRuntimeService {
lastOscTitleAt: null,
managementTitle: null,
managementTitleAt: null,
controllerTitle: null,
title: state.title ?? null,
titleUpdatedAt: titleObservedAt,
lastOutputAt: state.lastOutputAt ?? null,
@ -24519,13 +25046,56 @@ export class OrcaRuntimeService {
return null
}
const sessions = sessionsResult.value
const controllerIdentityByPtyId = new Map<
string,
{ handle: string; incarnationId: string; wslDistro?: string | null }
>()
const ptyIdByControllerHandle = new Map<string, string>()
const ambiguousControllerPtyIds = new Set<string>()
for (const session of sessions) {
const handle = session.terminalHandle?.trim()
const incarnationId = session.incarnationId?.trim()
if (!handle?.startsWith('term_') || !incarnationId) {
continue
}
const priorPtyId = ptyIdByControllerHandle.get(handle)
if (priorPtyId && priorPtyId !== session.id) {
ambiguousControllerPtyIds.add(priorPtyId)
ambiguousControllerPtyIds.add(session.id)
controllerIdentityByPtyId.delete(priorPtyId)
continue
}
if (controllerIdentityByPtyId.has(session.id)) {
ambiguousControllerPtyIds.add(session.id)
controllerIdentityByPtyId.delete(session.id)
continue
}
ptyIdByControllerHandle.set(handle, session.id)
controllerIdentityByPtyId.set(session.id, {
handle,
incarnationId,
...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {})
})
}
for (const ptyId of ambiguousControllerPtyIds) {
controllerIdentityByPtyId.delete(ptyId)
}
this.controllerTerminalIdentityByPtyId = controllerIdentityByPtyId
const persistedWorktreeIdByPtyId = indexPersistedPtyWorktreeBindings(
this.store?.getWorkspaceSession?.()
)
const persistedSurfaceByPtyId = indexPersistedPtySurfaceBindings(
this.store?.getWorkspaceSession?.()
)
const allLivePtyIds = new Set(sessions.map((session) => session.id))
const selectedLivePtyIds = new Set<string>()
for (const session of sessions) {
this.adoptControllerTerminalHandle(session.id, session.terminalHandle)
const controllerIdentity = controllerIdentityByPtyId.get(session.id)
this.adoptControllerTerminalHandle(
session.id,
controllerIdentity?.handle ?? session.terminalHandle,
controllerIdentity?.incarnationId ?? session.incarnationId
)
const persistedWorktreeId = persistedWorktreeIdByPtyId.get(session.id)
const providerWorktree = resolvedWorktrees.find(
(worktree) => session.worktreeId && runtimeWorktreeIdsEqual(worktree.id, session.worktreeId)
@ -24564,10 +25134,23 @@ export class OrcaRuntimeService {
continue
}
if (worktreeId) {
this.recordPtyWorktree(session.id, worktreeId, {
const persistedSurface = persistedSurfaceByPtyId.get(session.id)
const restoresExactSurface =
persistedSurface &&
session.incarnationId &&
persistedSurface.incarnationId === session.incarnationId &&
runtimeWorktreeIdsEqual(persistedSurface.worktreeId, worktreeId)
const pty = this.recordPtyWorktree(session.id, worktreeId, {
connected: true,
...(session.incarnationId ? { incarnationId: session.incarnationId } : {})
...(session.incarnationId ? { incarnationId: session.incarnationId } : {}),
...(session.wslDistro !== undefined
? { isWsl: Boolean(session.wslDistro), wslDistro: session.wslDistro }
: {}),
...(restoresExactSurface
? { tabId: persistedSurface.tabId, paneKey: persistedSurface.paneKey }
: {})
})
pty.controllerTitle = session.title?.trim() || null
}
// Why: fire-and-forget so this listing hot path doesn't serialize a relay round-trip per session and a throw can't abort the sweep below.
this.refreshPtyForegroundAgent(session.id)
@ -24805,9 +25388,12 @@ export class OrcaRuntimeService {
const worktree = worktreesById.get(leaf.worktreeId)
const tab = this.tabs.get(leaf.tabId) ?? null
const pty = leaf.ptyId ? this.ptysById.get(leaf.ptyId) : undefined
return {
handle: this.issueHandle(leaf),
ptyId: leaf.ptyId,
incarnationId: pty?.incarnationId ?? null,
orphaned: false,
worktreeId: leaf.worktreeId,
worktreePath: worktree?.path ?? '',
branch: worktree?.branch ?? '',
@ -26155,14 +26741,18 @@ export class OrcaRuntimeService {
): RuntimeTerminalSummary {
const worktree = worktreesById.get(pty.worktreeId)
const pane = parsePaneKey(pty.paneKey ?? '')
const orphaned = !pty.tabId || !pane || pane.tabId !== pty.tabId
return {
handle: this.issuePtyHandle(pty),
ptyId: pty.ptyId,
incarnationId: pty.incarnationId,
orphaned,
worktreeId: pty.worktreeId,
worktreePath: worktree?.path ?? '',
branch: worktree?.branch ?? '',
tabId: `pty:${pty.ptyId}`,
leafId: `pty:${pty.ptyId}`,
tabId: orphaned ? `pty:${pty.ptyId}` : pty.tabId!,
leafId: orphaned ? `pty:${pty.ptyId}` : pane.leafId,
title: getLatestPtyTitle(pty),
connected: pty.connected,
writable: pty.connected,
@ -31425,6 +32015,59 @@ function runtimeWorktreeIdentityKey(worktreeId: string): string {
: worktreeId
}
function resolveTerminalSessionWorktreeId(
session: WorkspaceSessionState,
targetWorktreeId: string
): string | null {
const keyedWorktreeIds = new Set([
...Object.keys(session.tabsByWorktree),
...Object.keys(session.tabGroups ?? {}),
...Object.keys(session.tabGroupLayouts ?? {}),
...Object.keys(session.activeTabIdByWorktree ?? {}),
...Object.keys(session.activeGroupIdByWorktree ?? {})
])
const matches = [...keyedWorktreeIds].filter((worktreeId) =>
runtimeWorktreeIdsEqual(worktreeId, targetWorktreeId)
)
return matches.length > 1 ? null : (matches[0] ?? targetWorktreeId)
}
function canonicalizeTerminalSessionWorktreeId(
session: WorkspaceSessionState,
sourceWorktreeId: string,
targetWorktreeId: string
): void {
if (sourceWorktreeId === targetWorktreeId) {
return
}
const tabs = session.tabsByWorktree[sourceWorktreeId] ?? []
delete session.tabsByWorktree[sourceWorktreeId]
session.tabsByWorktree[targetWorktreeId] = tabs.map((tab) => ({
...tab,
worktreeId: targetWorktreeId
}))
const groups = session.tabGroups?.[sourceWorktreeId]
if (groups) {
delete session.tabGroups![sourceWorktreeId]
session.tabGroups![targetWorktreeId] = groups.map((group) => ({
...group,
worktreeId: targetWorktreeId
}))
}
for (const keyedState of [
session.tabGroupLayouts,
session.activeTabIdByWorktree,
session.activeGroupIdByWorktree
]) {
if (!keyedState || !Object.hasOwn(keyedState, sourceWorktreeId)) {
continue
}
keyedState[targetWorktreeId] = keyedState[sourceWorktreeId] as never
delete keyedState[sourceWorktreeId]
}
}
function inferWorktreeIdFromPtyId(ptyId: string): string | null {
return parsePtySessionId(ptyId).worktreeId
}
@ -31461,6 +32104,49 @@ function indexPersistedPtyWorktreeBindings(
return worktreeIdByPtyId
}
function indexPersistedPtySurfaceBindings(
session: WorkspaceSessionState | null | undefined
): ReadonlyMap<
string,
{ worktreeId: string; tabId: string; paneKey: string; incarnationId: string }
> {
const bindingByPtyId = new Map<
string,
{ worktreeId: string; tabId: string; paneKey: string; incarnationId: string }
>()
const ambiguousPtyIds = new Set<string>()
for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) {
for (const tab of tabs) {
for (const [leafId, ptyId] of Object.entries(
session?.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}
)) {
if (!ptyId || ambiguousPtyIds.has(ptyId)) {
continue
}
const paneKey = makePaneKey(tab.id, leafId)
const incarnationId = session?.terminalPtyIncarnationsByPaneKey?.[paneKey]
if (!incarnationId) {
continue
}
const binding = { worktreeId, tabId: tab.id, paneKey, incarnationId }
const existing = bindingByPtyId.get(ptyId)
if (
existing &&
(existing.worktreeId !== worktreeId ||
existing.paneKey !== paneKey ||
existing.incarnationId !== incarnationId)
) {
bindingByPtyId.delete(ptyId)
ambiguousPtyIds.add(ptyId)
continue
}
bindingByPtyId.set(ptyId, binding)
}
}
}
return bindingByPtyId
}
function setsEqual<T>(a: ReadonlySet<T>, b: ReadonlySet<T>): boolean {
if (a.size !== b.size) {
return false

View File

@ -5,6 +5,7 @@ import { AUTOMATION_METHODS } from './automations'
import { REPO_METHODS } from './repo'
import { WORKTREE_METHODS } from './worktree'
import { TERMINAL_METHODS } from './terminal'
import { TERMINAL_ORPHAN_METHODS } from './terminal-orphan'
import { BROWSER_CORE_METHODS } from './browser-core'
import { BROWSER_EXTRA_METHODS } from './browser-extras'
import { BROWSER_SCREENCAST_METHODS } from './browser-screencast'
@ -49,6 +50,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
...WORKTREE_METHODS,
...AGENT_SESSION_METHODS,
...TERMINAL_METHODS,
...TERMINAL_ORPHAN_METHODS,
...BROWSER_CORE_METHODS,
...BROWSER_SCREENCAST_METHODS,
...BROWSER_EXTRA_METHODS,

View File

@ -81,7 +81,10 @@ function parseTerminalPaneLayoutNode(value: unknown): TerminalPaneLayoutNodeInpu
}
if (
node.ratio !== undefined &&
(typeof node.ratio !== 'number' || node.ratio < 0 || node.ratio > 1)
(typeof node.ratio !== 'number' ||
!Number.isFinite(node.ratio) ||
node.ratio < 0 ||
node.ratio > 1)
) {
return null
}
@ -93,7 +96,7 @@ function parseTerminalPaneLayoutNode(value: unknown): TerminalPaneLayoutNodeInpu
return value as TerminalPaneLayoutNodeInput
}
const TerminalPaneLayoutNodeSchema = z
export const TerminalPaneLayoutNodeSchema = z
.unknown()
.transform((value) => parseTerminalPaneLayoutNode(value))
.pipe(

View File

@ -0,0 +1,113 @@
import { z } from 'zod'
import type { TabGroupLayoutNode } from '../../../../shared/types'
import { isPtyIncarnationId, type PtyIncarnationId } from '../../../../shared/pty-incarnation'
import { defineMethod, type RpcAnyMethod } from '../core'
import { OptionalString, requiredString } from '../schemas'
import { TerminalPaneLayoutNodeSchema } from './session-tabs-schemas'
function parseOrphanGroupLayout(value: unknown): TabGroupLayoutNode | null {
const stack: { value: unknown; depth: number }[] = [{ value, depth: 0 }]
let count = 0
while (stack.length > 0) {
const current = stack.pop()!
if (
current.depth > 64 ||
++count > 1_024 ||
!current.value ||
typeof current.value !== 'object'
) {
return null
}
const node = current.value as Record<string, unknown>
if (node.type === 'leaf') {
if (
typeof node.groupId !== 'string' ||
node.groupId.length < 1 ||
node.groupId.length > 256
) {
return null
}
continue
}
if (
node.type !== 'split' ||
(node.direction !== 'horizontal' && node.direction !== 'vertical') ||
(node.ratio !== undefined &&
(typeof node.ratio !== 'number' ||
!Number.isFinite(node.ratio) ||
node.ratio < 0 ||
node.ratio > 1))
) {
return null
}
stack.push(
{ value: node.first, depth: current.depth + 1 },
{ value: node.second, depth: current.depth + 1 }
)
}
return value as TabGroupLayoutNode
}
const TerminalOrphanGroupLayout = z
.unknown()
.transform(parseOrphanGroupLayout)
.pipe(z.custom<TabGroupLayoutNode>((value) => value !== null, 'Invalid orphan group layout'))
const TerminalOrphanTopology = z.object({
tabs: z
.array(
z.object({
tabId: requiredString('Missing topology tab id').pipe(z.string().max(256)),
root: TerminalPaneLayoutNodeSchema,
activeLeafId: requiredString('Missing active leaf id').pipe(z.string().max(128)),
expandedLeafId: z.string().max(128).nullable()
})
)
.min(1)
.max(64),
groups: z
.array(
z.object({
id: z.string().min(1).max(256),
activeTabId: z.string().min(1).max(256),
tabOrder: z.array(z.string().min(1).max(256)).min(1).max(64),
recentTabIds: z.array(z.string().min(1).max(256)).max(64).optional()
})
)
.min(1)
.max(64),
groupLayout: TerminalOrphanGroupLayout.optional()
})
const TerminalOrphanIncarnationId = z.custom<PtyIncarnationId>(
isPtyIncarnationId,
'Invalid PTY incarnation'
)
const TerminalAdoptOrphans = z.object({
worktree: requiredString('Missing worktree selector').pipe(z.string().max(32_768)),
expectedTopologyRevision: z.number().int().nonnegative(),
claims: z
.array(
z.object({
terminal: requiredString('Missing terminal handle').pipe(z.string().max(256)),
ptyId: requiredString('Missing PTY id').pipe(z.string().max(8_192)),
incarnationId: TerminalOrphanIncarnationId,
tabId: requiredString('Missing tab id').pipe(z.string().max(256)),
leafId: requiredString('Missing leaf id').pipe(z.string().max(128))
})
)
.min(1)
.max(64),
activeTabId: OptionalString.pipe(z.string().max(256).optional()),
activeGroupId: OptionalString.pipe(z.string().max(256).optional()),
topology: TerminalOrphanTopology.optional()
})
export const TERMINAL_ORPHAN_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'terminal.adoptOrphans',
params: TerminalAdoptOrphans,
handler: async (params, { runtime }) => runtime.adoptTerminalOrphans(params)
})
]

View File

@ -831,6 +831,10 @@ const TerminalFocus = TerminalHandle.extend({
const TerminalListParams = z.object({
worktree: OptionalString,
limit: OptionalFiniteNumber,
handles: z
.array(requiredString('Missing terminal handle').pipe(z.string().max(256)))
.max(64)
.optional(),
requireFreshPtyLiveness: z.boolean().optional()
})
@ -1103,6 +1107,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
params: TerminalListParams,
handler: async (params, { runtime }) =>
runtime.listTerminals(params.worktree, params.limit, {
handles: params.handles,
requireFreshPtyLiveness: params.requireFreshPtyLiveness
})
}),

View File

@ -17,6 +17,7 @@ import {
Wait
} from './methods/browser-schemas'
import { TERMINAL_METHODS } from './methods/terminal'
import { TERMINAL_ORPHAN_METHODS } from './methods/terminal-orphan'
import { WORKTREE_METHODS } from './methods/worktree'
function expectParses(schema: ZodType, value: unknown): void {
@ -85,6 +86,90 @@ describe('RPC optional pipe schemas', () => {
expectParses(methodParams(WORKTREE_METHODS, 'worktree.prefetchCreateBase'), { repo: 'repo-1' })
})
it('requires complete, bounded orphan adoption claims and a topology revision', () => {
const adopt = methodParams(TERMINAL_ORPHAN_METHODS, 'terminal.adoptOrphans')
const claim = {
terminal: 'term-live',
ptyId: 'pty-live',
incarnationId: 'inc-live',
tabId: 'tab-live',
leafId: 'leaf-live'
}
expectParses(adopt, {
worktree: 'id:repo::/worktree',
expectedTopologyRevision: 4,
claims: [claim],
topology: {
tabs: [
{
tabId: 'tab-live',
root: { type: 'leaf', leafId: 'leaf-live' },
activeLeafId: 'leaf-live',
expandedLeafId: null
}
],
groups: [{ id: 'group-live', activeTabId: 'tab-live', tabOrder: ['tab-live'] }],
groupLayout: { type: 'leaf', groupId: 'group-live' }
}
})
expectRejects(adopt, {
worktree: 'id:repo::/worktree',
expectedTopologyRevision: -1,
claims: [claim]
})
expectRejects(adopt, {
worktree: 'id:repo::/worktree',
expectedTopologyRevision: 4,
claims: [{ ...claim, incarnationId: '' }]
})
expectRejects(adopt, {
worktree: 'id:repo::/worktree',
expectedTopologyRevision: 4,
claims: [{ ...claim, incarnationId: 'i'.repeat(129) }]
})
expectRejects(adopt, {
worktree: 'id:repo::/worktree',
expectedTopologyRevision: 4,
claims: []
})
expectRejects(adopt, {
worktree: 'id:repo::/worktree',
expectedTopologyRevision: 4,
claims: [{ ...claim, terminal: 't'.repeat(257) }]
})
expectRejects(adopt, {
worktree: 'id:repo::/worktree',
expectedTopologyRevision: 4,
claims: [claim],
topology: {
tabs: [
{
tabId: 'tab-live',
root: {
type: 'split',
direction: 'horizontal',
ratio: Number.NaN,
first: { type: 'leaf', leafId: 'leaf-live' },
second: { type: 'leaf', leafId: 'leaf-other' }
},
activeLeafId: 'leaf-live',
expandedLeafId: null
}
],
groups: [{ id: 'group-live', activeTabId: 'tab-live', tabOrder: ['tab-live'] }]
}
})
})
it('bounds targeted terminal listing used by orphan recovery', () => {
const list = methodParams(TERMINAL_METHODS, 'terminal.list')
expectParses(list, { worktree: 'id:repo::/worktree', handles: ['term-live'] })
expectRejects(list, { handles: [''] })
expectRejects(list, { handles: Array.from({ length: 65 }, (_, index) => `term-${index}`) })
})
it('accepts worktree.create payloads sent by the previous mobile protocol', () => {
const create = methodParams(WORKTREE_METHODS, 'worktree.create')

View File

@ -362,6 +362,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'terminal.ensureAgentSession',
'terminal.focus',
'terminal.agentStatus',
'terminal.adoptOrphans',
'terminal.getAutoRestoreFit',
'terminal.isRunningAgent',
'terminal.list',

View File

@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { terminalOrphanExecutionOwnersEqual } from './terminal-orphan-owner'
describe('terminal orphan execution owner', () => {
it('requires exact SSH host ownership', () => {
expect(
terminalOrphanExecutionOwnersEqual(
{ connectionId: 'ssh-a', wslDistro: null },
{ connectionId: 'ssh-b', wslDistro: null }
)
).toBe(false)
expect(
terminalOrphanExecutionOwnersEqual(
{ connectionId: 'ssh-a', wslDistro: null },
{ connectionId: 'ssh-a' }
)
).toBe(true)
})
it('matches WSL distro case-insensitively but never crosses native or another distro', () => {
const expected = { connectionId: null, wslDistro: 'Ubuntu' }
expect(
terminalOrphanExecutionOwnersEqual(expected, {
connectionId: null,
wslDistro: 'ubuntu'
})
).toBe(true)
expect(
terminalOrphanExecutionOwnersEqual(expected, { connectionId: null, wslDistro: null })
).toBe(false)
expect(
terminalOrphanExecutionOwnersEqual(expected, {
connectionId: null,
wslDistro: 'Debian'
})
).toBe(false)
expect(terminalOrphanExecutionOwnersEqual(expected, { connectionId: null })).toBe(false)
})
})

View File

@ -0,0 +1,25 @@
export type TerminalOrphanExecutionOwner = {
connectionId: string | null
wslDistro?: string | null
}
function normalizeWslDistro(distro: string | null): string | null {
const normalized = distro?.trim().toLowerCase() ?? ''
return normalized || null
}
export function terminalOrphanExecutionOwnersEqual(
expected: TerminalOrphanExecutionOwner,
actual: TerminalOrphanExecutionOwner
): boolean {
if (expected.connectionId !== actual.connectionId) {
return false
}
if (expected.connectionId !== null) {
return true
}
if (expected.wslDistro === undefined || actual.wslDistro === undefined) {
return false
}
return normalizeWslDistro(expected.wslDistro) === normalizeWslDistro(actual.wslDistro)
}

View File

@ -0,0 +1,167 @@
import { describe, expect, it } from 'vitest'
import {
hasExactTerminalOrphanGroupLayout,
mergeTerminalOrphanGroupLayout
} from './terminal-orphan-topology'
describe('terminal orphan topology', () => {
it('rejects duplicate and missing group leaves', () => {
expect(
hasExactTerminalOrphanGroupLayout(
{
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'group-a' },
second: { type: 'leaf', groupId: 'group-a' }
},
new Set(['group-a', 'group-b'])
)
).toBe(false)
})
it('keeps current host layout while preserving an unrelated proposed subtree', () => {
expect(
mergeTerminalOrphanGroupLayout({
existingLayout: { type: 'leaf', groupId: 'group-live' },
existingGroupIds: ['group-live'],
proposedLayout: {
type: 'split',
direction: 'vertical',
ratio: 0.6,
first: { type: 'leaf', groupId: 'group-old-left' },
second: { type: 'leaf', groupId: 'group-old-right' }
},
proposedGroupIds: ['group-old-left', 'group-old-right'],
mergedGroupIds: ['group-live', 'group-old-left', 'group-old-right']
})
).toEqual({
type: 'split',
direction: 'vertical',
ratio: 0.6,
first: { type: 'leaf', groupId: 'group-live' },
second: {
type: 'split',
direction: 'vertical',
ratio: 0.6,
first: { type: 'leaf', groupId: 'group-old-left' },
second: { type: 'leaf', groupId: 'group-old-right' }
}
})
})
it('grafts proposed groups beside their one current anchor without duplicating it', () => {
expect(
mergeTerminalOrphanGroupLayout({
existingLayout: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', groupId: 'group-live' },
second: { type: 'leaf', groupId: 'group-other' }
},
existingGroupIds: ['group-live', 'group-other'],
proposedLayout: {
type: 'split',
direction: 'horizontal',
ratio: 0.7,
first: { type: 'leaf', groupId: 'group-live' },
second: { type: 'leaf', groupId: 'group-recovered' }
},
proposedGroupIds: ['group-live', 'group-recovered'],
mergedGroupIds: ['group-live', 'group-other', 'group-recovered']
})
).toEqual({
type: 'split',
direction: 'vertical',
first: {
type: 'split',
direction: 'horizontal',
ratio: 0.7,
first: { type: 'leaf', groupId: 'group-live' },
second: { type: 'leaf', groupId: 'group-recovered' }
},
second: { type: 'leaf', groupId: 'group-other' }
})
})
it('uses the proposed layout when the host has no groups', () => {
expect(
mergeTerminalOrphanGroupLayout({
existingLayout: null,
existingGroupIds: [],
proposedLayout: { type: 'leaf', groupId: 'group-recovered' },
proposedGroupIds: ['group-recovered'],
mergedGroupIds: ['group-recovered']
})
).toEqual({ type: 'leaf', groupId: 'group-recovered' })
})
it('keeps the host layout when the proposal has no groups', () => {
expect(
mergeTerminalOrphanGroupLayout({
existingLayout: { type: 'leaf', groupId: 'group-live' },
existingGroupIds: ['group-live'],
proposedLayout: null,
proposedGroupIds: [],
mergedGroupIds: ['group-live']
})
).toEqual({ type: 'leaf', groupId: 'group-live' })
})
it('does not rewrite host layout when the proposal adds no groups', () => {
const existingLayout = {
type: 'split' as const,
direction: 'horizontal' as const,
ratio: 0.4,
first: { type: 'leaf' as const, groupId: 'group-a' },
second: { type: 'leaf' as const, groupId: 'group-b' }
}
expect(
mergeTerminalOrphanGroupLayout({
existingLayout,
existingGroupIds: ['group-a', 'group-b'],
proposedLayout: existingLayout,
proposedGroupIds: ['group-a', 'group-b'],
mergedGroupIds: ['group-a', 'group-b']
})
).toEqual(existingLayout)
})
it('appends new groups when multiple shared anchors make placement ambiguous', () => {
expect(
mergeTerminalOrphanGroupLayout({
existingLayout: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'group-a' },
second: { type: 'leaf', groupId: 'group-b' }
},
existingGroupIds: ['group-a', 'group-b'],
proposedLayout: {
type: 'split',
direction: 'vertical',
ratio: 0.7,
first: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'group-a' },
second: { type: 'leaf', groupId: 'group-b' }
},
second: { type: 'leaf', groupId: 'group-recovered' }
},
proposedGroupIds: ['group-a', 'group-b', 'group-recovered'],
mergedGroupIds: ['group-a', 'group-b', 'group-recovered']
})
).toEqual({
type: 'split',
direction: 'vertical',
ratio: 0.7,
first: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', groupId: 'group-a' },
second: { type: 'leaf', groupId: 'group-b' }
},
second: { type: 'leaf', groupId: 'group-recovered' }
})
})
})

View File

@ -0,0 +1,139 @@
import type { TabGroupLayoutNode } from '../../shared/types'
function collectLayoutGroupIds(node: TabGroupLayoutNode | null | undefined, ids: string[]): void {
if (!node) {
return
}
if (node.type === 'leaf') {
ids.push(node.groupId)
return
}
collectLayoutGroupIds(node.first, ids)
collectLayoutGroupIds(node.second, ids)
}
function pruneLayout(
node: TabGroupLayoutNode | null | undefined,
retainedGroupIds: ReadonlySet<string>
): TabGroupLayoutNode | null {
if (!node) {
return null
}
if (node.type === 'leaf') {
return retainedGroupIds.has(node.groupId) ? node : null
}
const first = pruneLayout(node.first, retainedGroupIds)
const second = pruneLayout(node.second, retainedGroupIds)
if (!first) {
return second
}
if (!second) {
return first
}
return { ...node, first, second }
}
function appendMissingGroups(
layout: TabGroupLayoutNode | null,
orderedGroupIds: readonly string[]
): TabGroupLayoutNode | null {
const present: string[] = []
collectLayoutGroupIds(layout, present)
const presentSet = new Set(present)
let next = layout
for (const groupId of orderedGroupIds) {
if (presentSet.has(groupId)) {
continue
}
const leaf = { type: 'leaf' as const, groupId }
next = next
? { type: 'split', direction: 'horizontal', first: next, second: leaf, ratio: 0.5 }
: leaf
presentSet.add(groupId)
}
return next
}
function replaceLeaf(
node: TabGroupLayoutNode,
groupId: string,
replacement: TabGroupLayoutNode
): TabGroupLayoutNode {
if (node.type === 'leaf') {
return node.groupId === groupId ? replacement : node
}
return {
...node,
first: replaceLeaf(node.first, groupId, replacement),
second: replaceLeaf(node.second, groupId, replacement)
}
}
export function hasExactTerminalOrphanGroupLayout(
layout: TabGroupLayoutNode,
expectedGroupIds: ReadonlySet<string>
): boolean {
const groupIds: string[] = []
collectLayoutGroupIds(layout, groupIds)
return (
groupIds.length === expectedGroupIds.size &&
new Set(groupIds).size === groupIds.length &&
groupIds.every((groupId) => expectedGroupIds.has(groupId))
)
}
export function mergeTerminalOrphanGroupLayout(args: {
existingLayout: TabGroupLayoutNode | null | undefined
existingGroupIds: readonly string[]
proposedLayout: TabGroupLayoutNode | null | undefined
proposedGroupIds: readonly string[]
mergedGroupIds: readonly string[]
}): TabGroupLayoutNode | undefined {
const mergedGroupIdSet = new Set(args.mergedGroupIds)
const existingGroupIdSet = new Set(args.existingGroupIds)
const proposedGroupIdSet = new Set(args.proposedGroupIds)
let existing = appendMissingGroups(
pruneLayout(args.existingLayout, existingGroupIdSet),
args.existingGroupIds
)
const proposed = appendMissingGroups(
pruneLayout(args.proposedLayout, proposedGroupIdSet),
args.proposedGroupIds
)
if (!existing) {
return appendMissingGroups(proposed, args.mergedGroupIds) ?? undefined
}
if (!proposed) {
return appendMissingGroups(existing, args.mergedGroupIds) ?? undefined
}
const sharedGroupIds = args.proposedGroupIds.filter((groupId) => existingGroupIdSet.has(groupId))
const newGroupIds = new Set(
args.proposedGroupIds.filter((groupId) => !existingGroupIdSet.has(groupId))
)
if (newGroupIds.size > 0 && sharedGroupIds.length === 1) {
// One shared group identifies where the recovered subtree belonged without disturbing unrelated host layout.
const anchorGroupId = sharedGroupIds[0]!
const proposalAtAnchor = pruneLayout(proposed, new Set([anchorGroupId, ...newGroupIds]))
if (proposalAtAnchor) {
existing = replaceLeaf(existing, anchorGroupId, proposalAtAnchor)
}
} else if (newGroupIds.size > 0) {
// With no unique anchor, append the intact recovered subtree so ambiguous client metadata cannot rewrite host groups.
const newSubtree = pruneLayout(proposed, newGroupIds)
if (newSubtree) {
existing = {
type: 'split',
direction: proposed.type === 'split' ? proposed.direction : 'horizontal',
first: existing,
second: newSubtree,
ratio: proposed.type === 'split' ? proposed.ratio : 0.5
}
}
}
return (
appendMissingGroups(pruneLayout(existing, mergedGroupIdSet), args.mergedGroupIds) ?? undefined
)
}

View File

@ -2144,6 +2144,9 @@ export type PreloadApi = {
/** Synchronous persisted-settings read for startup decisions that can't wait for async hydration. Blocking IPC — call sparingly. */
getSync: () => GlobalSettings | null
set: (args: Partial<GlobalSettings>) => Promise<GlobalSettings>
setActiveRuntimeEnvironmentPreference: (args: {
environmentId: string | null
}) => Promise<GlobalSettings>
updatePRBotAuthorOverride: (args: { author: string; isBot: boolean }) => Promise<GlobalSettings>
listFonts: () => Promise<string[]>
previewGhosttyImport: () => Promise<GhosttyImportPreview>

View File

@ -1839,6 +1839,11 @@ const api = {
set: (args: Record<string, unknown>): Promise<unknown> =>
ipcRenderer.invoke('settings:set', args),
setActiveRuntimeEnvironmentPreference: (args: {
environmentId: string | null
}): Promise<unknown> =>
ipcRenderer.invoke('settings:set-active-runtime-environment-preference', args),
updatePRBotAuthorOverride: (args: { author: string; isBot: boolean }): Promise<unknown> =>
ipcRenderer.invoke('settings:update-pr-bot-author-override', args),

View File

@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState, type MutableRefObject } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react'
import type { GlobalSettings } from '../../../../shared/types'
import { useAppStore } from '../../store'
import { matchesSettingsSearch } from './settings-search'
@ -21,11 +21,11 @@ import { buildSidebarHostOptions } from '../sidebar/sidebar-host-options'
import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides'
import {
getSettingsFocusedExecutionHostId,
parseExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
import { isMacUserAgent } from '@/components/terminal-pane/pane-helpers'
import { translate } from '@/i18n/i18n'
import { resolveAvailableBrowserSessionHostId } from './browser-session-host-selection'
export { getBrowserPaneCombinedSearchEntries }
type BrowserPaneProps = {
@ -59,7 +59,8 @@ export function BrowserPane({
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId)
const switchRuntimeEnvironment = useAppStore((s) => s.switchRuntimeEnvironment)
const browserSessionHostIdOverride = useAppStore((s) => s.browserSessionHostIdOverride)
const setBrowserSessionHostId = useAppStore((s) => s.setBrowserSessionHostId)
const detectedBrowsers = useAppStore((s) => s.detectedBrowsers)
const browserSessionImportState = useAppStore((s) => s.browserSessionImportState)
const defaultBrowserSessionProfileId = useAppStore((s) => s.defaultBrowserSessionProfileId)
@ -137,19 +138,28 @@ export function BrowserPane({
hostLabelOverrides
]
)
const selectedBrowserSessionHostId = getSettingsFocusedExecutionHostId(settings)
const settingsFocusedHostId = getSettingsFocusedExecutionHostId(settings)
const selectedBrowserSessionHostId = resolveAvailableBrowserSessionHostId(
browserSessionHostOptions,
browserSessionHostIdOverride,
settingsFocusedHostId
)
useEffect(() => {
const requestedHostId = browserSessionHostIdOverride ?? settingsFocusedHostId
if (selectedBrowserSessionHostId !== requestedHostId) {
void setBrowserSessionHostId(selectedBrowserSessionHostId)
}
}, [
browserSessionHostIdOverride,
selectedBrowserSessionHostId,
setBrowserSessionHostId,
settingsFocusedHostId
])
const selectBrowserSessionHost = useCallback(
(hostId: ExecutionHostId) => {
const parsed = parseExecutionHostId(hostId)
if (parsed?.kind === 'runtime') {
void switchRuntimeEnvironment(parsed.environmentId)
return
}
if (parsed?.kind === 'local') {
void switchRuntimeEnvironment(null)
}
void setBrowserSessionHostId(hostId)
},
[switchRuntimeEnvironment]
[setBrowserSessionHostId]
)
const requestSessionCookieScrollFrame = (callback: FrameRequestCallback): void => {

View File

@ -14,6 +14,7 @@ import {
getHostModelCapabilitySummary,
getRuntimeCapabilitiesSummary,
getRuntimeServerConnectionState,
isRuntimeEnvironmentRemovalBlocked,
type RuntimeHostDetails
} from './RuntimeEnvironmentsPane'
@ -208,4 +209,10 @@ describe('RuntimeEnvironmentsPane host details', () => {
expect(getActiveServerModeDescription(false)).toContain('default Host')
expect(getActiveServerModeDescription(false)).toContain('paired Orca runtime')
})
it('blocks removing the active server independently of local-runtime availability', () => {
expect(isRuntimeEnvironmentRemovalBlocked('windows-2', 'windows-2')).toBe(true)
expect(isRuntimeEnvironmentRemovalBlocked(undefined, 'windows-2')).toBe(false)
expect(isRuntimeEnvironmentRemovalBlocked('local', 'windows-2')).toBe(false)
})
})

View File

@ -67,7 +67,7 @@ const NO_RUNTIME_VALUE = '__none__'
type RuntimeEnvironmentsPaneProps = {
settings: GlobalSettings
switchRuntimeEnvironment: (environmentId: string | null) => Promise<boolean>
setActiveRuntimeEnvironmentPreference: (environmentId: string | null) => Promise<boolean>
canGeneratePairingUrl?: boolean
allowLocalRuntime?: boolean
addServerIntentSignal?: number
@ -200,6 +200,13 @@ export function getActiveServerModeDescription(allowLocalRuntime: boolean): stri
)
}
export function isRuntimeEnvironmentRemovalBlocked(
activeRuntimeEnvironmentId: string | null | undefined,
environmentId: string
): boolean {
return activeRuntimeEnvironmentId === environmentId
}
type RuntimeServerConnectionState = 'connected' | 'checking' | 'disconnected'
export function getRuntimeServerConnectionState(
@ -251,7 +258,7 @@ function getRuntimeServerDotClass(state: RuntimeServerConnectionState): string {
export function RuntimeEnvironmentsPane({
settings,
switchRuntimeEnvironment,
setActiveRuntimeEnvironmentPreference,
canGeneratePairingUrl = true,
allowLocalRuntime = true,
addServerIntentSignal
@ -294,7 +301,9 @@ export function RuntimeEnvironmentsPane({
switchingValue !== null ||
removingId !== null ||
disconnectingId !== null
const removingActiveServer = pendingRemove?.id === settings.activeRuntimeEnvironmentId
const removingActiveServer = pendingRemove
? isRuntimeEnvironmentRemovalBlocked(settings.activeRuntimeEnvironmentId, pendingRemove.id)
: false
const searchEntry = canGeneratePairingUrl
? getRuntimeEnvironmentsSearchEntry()
: getWebRuntimeEnvironmentsSearchEntry()
@ -447,12 +456,6 @@ export function RuntimeEnvironmentsPane({
}
setIsSaving(true)
try {
if (!allowLocalRuntime && settings.activeRuntimeEnvironmentId) {
const disconnected = await switchRuntimeEnvironment(null)
if (!disconnected) {
return
}
}
const result = await window.api.runtimeEnvironments.addFromPairingCode({
name: trimmedName,
pairingCode: trimmedPairingCode
@ -463,27 +466,18 @@ export function RuntimeEnvironmentsPane({
}
await loadEnvironments()
if (!allowLocalRuntime) {
const switched = await switchRuntimeEnvironment(result.environment.id)
if (!switched) {
const connected = await connectEnvironment(result.environment)
if (!connected) {
await window.api.runtimeEnvironments.remove({ selector: result.environment.id })
await loadEnvironments()
return
}
if (mountedRef.current) {
toast.success(
translate(
'auto.components.settings.RuntimeEnvironmentsPane.a5b58465b6',
'Connected to {{value0}}.',
{ value0: result.environment.name }
)
)
}
} else {
if (mountedRef.current) {
toast.success(
translate(
'auto.components.settings.RuntimeEnvironmentsPane.7b5986c8df',
'Saved {{value0}}. Use Advanced > Default runtime to make it the default.',
'Saved {{value0}}. Use Advanced > Active Server to make it the default.',
{ value0: result.environment.name }
)
)
@ -516,31 +510,16 @@ export function RuntimeEnvironmentsPane({
setRemovingId(environment.id)
setRemoveError(null)
try {
if (settings.activeRuntimeEnvironmentId === environment.id) {
const switched = await switchRuntimeEnvironment(null)
if (!switched) {
if (mountedRef.current) {
setRemoveError(
allowLocalRuntime
? 'Could not switch to Local desktop. Fix the issue and try again.'
: 'Could not disconnect from this server. Fix the issue and try again.'
if (isRuntimeEnvironmentRemovalBlocked(settings.activeRuntimeEnvironmentId, environment.id)) {
if (mountedRef.current) {
setRemoveError(
translate(
'auto.components.settings.RuntimeEnvironmentsPane.removeActiveServerBlocked',
'Choose another Active Server in Advanced before removing this server.'
)
}
return false
}
if (!allowLocalRuntime) {
await loadEnvironments()
if (mountedRef.current) {
toast.success(
translate(
'auto.components.settings.RuntimeEnvironmentsPane.b5b5114cb0',
'Removed {{value0}}.',
{ value0: environment.name }
)
)
}
return true
)
}
return false
}
await window.api.runtimeEnvironments.remove({ selector: environment.id })
await loadEnvironments()
@ -575,19 +554,6 @@ export function RuntimeEnvironmentsPane({
setDisconnectingId(environment.id)
setSwitchError(null)
try {
if (settings.activeRuntimeEnvironmentId === environment.id) {
const switched = await switchRuntimeEnvironment(null)
if (!switched) {
if (mountedRef.current) {
setSwitchError(
allowLocalRuntime
? 'Could not switch to Local desktop. Fix the issue and try again.'
: 'Could not disconnect from this server. Fix the issue and try again.'
)
}
return false
}
}
await window.api.runtimeEnvironments.disconnect({ selector: environment.id })
// Why: disconnect is non-destructive; keep the saved server but show the
// user that this live client is no longer attached to it.
@ -715,7 +681,7 @@ export function RuntimeEnvironmentsPane({
setSwitchingValue(value)
setSwitchError(null)
try {
const switched = await switchRuntimeEnvironment(
const switched = await setActiveRuntimeEnvironmentPreference(
allowLocalRuntime && value === LOCAL_RUNTIME_VALUE ? null : value
)
if (switched) {
@ -1139,7 +1105,7 @@ export function RuntimeEnvironmentsPane({
<Label id="runtime-active-server-label">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.64b6bea541',
'Default runtime'
'Active Server'
)}
</Label>
<p className="text-xs text-muted-foreground">
@ -1419,15 +1385,10 @@ export function RuntimeEnvironmentsPane({
</DialogTitle>
<DialogDescription>
{removingActiveServer
? allowLocalRuntime
? translate(
'auto.components.settings.RuntimeEnvironmentsPane.9f7665a01b',
'Removing the active server first switches Orca back to Local desktop. Existing host sessions are left alone.'
)
: translate(
'auto.components.settings.RuntimeEnvironmentsPane.b2fda48c39',
'Removing the active server disconnects this browser from that host. Existing host sessions are left alone.'
)
? translate(
'auto.components.settings.RuntimeEnvironmentsPane.removeActiveServerDescription',
'Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.'
)
: translate(
'auto.components.settings.RuntimeEnvironmentsPane.ed3e3f069d',
'This removes the saved server from Orca. It does not change the active server.'

View File

@ -280,7 +280,9 @@ function Settings(): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const keybindings = useAppStore((s) => s.keybindings)
const updateSettings = useAppStore((s) => s.updateSettings)
const switchRuntimeEnvironment = useAppStore((s) => s.switchRuntimeEnvironment)
const setActiveRuntimeEnvironmentPreference = useAppStore(
(s) => s.setActiveRuntimeEnvironmentPreference
)
const fetchSettings = useAppStore((s) => s.fetchSettings)
const fetchKeybindings = useAppStore((s) => s.fetchKeybindings)
const closeSettingsPage = useAppStore((s) => s.closeSettingsPage)
@ -1612,7 +1614,7 @@ function Settings(): React.JSX.Element {
{isSectionMounted('servers') ? (
<RuntimeEnvironmentsPane
settings={settings}
switchRuntimeEnvironment={switchRuntimeEnvironment}
setActiveRuntimeEnvironmentPreference={setActiveRuntimeEnvironmentPreference}
canGeneratePairingUrl={!isWebClient}
allowLocalRuntime={!isWebClient}
addServerIntentSignal={remoteServerAddIntentSignal}

View File

@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { resolveAvailableBrowserSessionHostId } from './browser-session-host-selection'
const options = [{ id: 'local' as const }, { id: 'runtime:linux-3' as const }]
describe('browser session host selection', () => {
it('keeps an available transient override', () => {
expect(resolveAvailableBrowserSessionHostId(options, 'runtime:linux-3', 'local')).toBe(
'runtime:linux-3'
)
})
it('falls back from a removed override to the available focused host', () => {
expect(resolveAvailableBrowserSessionHostId(options, 'runtime:windows-2', 'local')).toBe(
'local'
)
})
it('falls back to the first option when both selected hosts are unavailable', () => {
expect(
resolveAvailableBrowserSessionHostId(options, 'runtime:windows-2', 'runtime:missing-default')
).toBe('local')
})
it('falls back to local when no host option is available', () => {
expect(
resolveAvailableBrowserSessionHostId([], 'runtime:windows-2', 'runtime:missing-default')
).toBe('local')
})
})

View File

@ -0,0 +1,16 @@
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host'
export function resolveAvailableBrowserSessionHostId(
options: readonly { id: ExecutionHostId }[],
overrideHostId: ExecutionHostId | null,
focusedHostId: ExecutionHostId
): ExecutionHostId {
const availableIds = new Set(options.map((option) => option.id))
if (overrideHostId && availableIds.has(overrideHostId)) {
return overrideHostId
}
if (availableIds.has(focusedHostId)) {
return focusedHostId
}
return options[0]?.id ?? LOCAL_EXECUTION_HOST_ID
}

View File

@ -11,7 +11,7 @@ const mocks = vi.hoisted(() => ({
hostOptions: [] as SidebarHostOption[],
storeState: {
settings: { activeRuntimeEnvironmentId: null as string | null },
switchRuntimeEnvironment: vi.fn(),
setActiveRuntimeEnvironmentPreference: vi.fn(),
setSshConnectionState: vi.fn(),
sshConnectionStates: new Map(),
runtimeEnvironments: [] as { id: string; name: string; source?: 'manual' | 'ephemeral-vm' }[]
@ -98,7 +98,7 @@ describe('useAddRepoHostSelection', () => {
}
]
mocks.storeState.settings = { activeRuntimeEnvironmentId: null }
mocks.storeState.switchRuntimeEnvironment.mockResolvedValue(true)
mocks.storeState.setActiveRuntimeEnvironmentPreference.mockResolvedValue(true)
mocks.storeState.sshConnectionStates = new Map()
mocks.storeState.runtimeEnvironments = []
mocks.sshConnect.mockReset()
@ -124,7 +124,7 @@ describe('useAddRepoHostSelection', () => {
expect(result.selectedSshTargetId).toBe('ssh-1')
})
it('switches runtime before selecting a runtime host', async () => {
it('selects a runtime host without changing the durable active server', async () => {
mocks.stateValues = ['local', false]
const setStep = vi.fn()
const { useAddRepoHostSelection } = await import('./use-add-repo-host-selection')
@ -132,12 +132,12 @@ describe('useAddRepoHostSelection', () => {
const result = useAddRepoHostSelection({ isOpen: true, setStep })
await result.handleSelectAddProjectHost('runtime:env-1')
expect(mocks.storeState.switchRuntimeEnvironment).toHaveBeenCalledWith('env-1')
expect(mocks.storeState.setActiveRuntimeEnvironmentPreference).not.toHaveBeenCalled()
expect(mocks.stateSetters[0]).toHaveBeenCalledWith('runtime:env-1')
expect(setStep).toHaveBeenCalledWith('add')
})
it('clears the active runtime before selecting a local or SSH host', async () => {
it('selects a local or SSH host without changing the durable active server', async () => {
mocks.stateValues = ['runtime:env-1', false]
mocks.storeState.settings = { activeRuntimeEnvironmentId: 'env-1' }
const setStep = vi.fn()
@ -146,7 +146,7 @@ describe('useAddRepoHostSelection', () => {
const result = useAddRepoHostSelection({ isOpen: true, setStep })
await result.handleSelectAddProjectHost('ssh:ssh-1')
expect(mocks.storeState.switchRuntimeEnvironment).toHaveBeenCalledWith(null)
expect(mocks.storeState.setActiveRuntimeEnvironmentPreference).not.toHaveBeenCalled()
expect(mocks.stateSetters[0]).toHaveBeenCalledWith('ssh:ssh-1')
expect(setStep).toHaveBeenCalledWith('add')
})
@ -177,7 +177,7 @@ describe('useAddRepoHostSelection', () => {
const result = useAddRepoHostSelection({ isOpen: true, setStep })
await result.handleSelectAddProjectHost('ssh:ssh-1')
expect(mocks.storeState.switchRuntimeEnvironment).not.toHaveBeenCalled()
expect(mocks.storeState.setActiveRuntimeEnvironmentPreference).not.toHaveBeenCalled()
expect(mocks.stateSetters[0]).not.toHaveBeenCalledWith('ssh:ssh-1')
expect(setStep).not.toHaveBeenCalled()
})
@ -249,7 +249,9 @@ describe('useAddRepoHostSelection', () => {
expect(result.hostOptions.map((host) => host.id)).not.toContain('runtime:env-vm')
expect(result.selectedHostId).toBe('local')
await result.handleSelectAddProjectHost('runtime:env-vm')
expect(mocks.storeState.switchRuntimeEnvironment).not.toHaveBeenCalledWith('env-vm')
expect(mocks.storeState.setActiveRuntimeEnvironmentPreference).not.toHaveBeenCalledWith(
'env-vm'
)
expect(setStep).not.toHaveBeenCalled()
})
})

View File

@ -31,7 +31,6 @@ export function useAddRepoHostSelection({
handleConnectAddProjectHost: (hostId: ExecutionHostId) => Promise<void>
} {
const settings = useAppStore((s) => s.settings)
const switchRuntimeEnvironment = useAppStore((s) => s.switchRuntimeEnvironment)
const setSshConnectionState = useAppStore((s) => s.setSshConnectionState)
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
@ -96,22 +95,10 @@ export function useAddRepoHostSelection({
if (!host || !canSelectAddRepoHost(host)) {
return
}
const parsed = parseExecutionHostId(hostId)
if (parsed?.kind === 'runtime') {
const switched = await switchRuntimeEnvironment(parsed.environmentId)
if (!switched) {
return
}
} else if (settings?.activeRuntimeEnvironmentId?.trim()) {
const switched = await switchRuntimeEnvironment(null)
if (!switched) {
return
}
}
setSelectedAddProjectHostId(hostId)
setStep('add')
},
[selectableHostOptions, settings?.activeRuntimeEnvironmentId, setStep, switchRuntimeEnvironment]
[selectableHostOptions, setStep]
)
const handleConnectAddProjectHost = useCallback(
@ -148,12 +135,6 @@ export function useAddRepoHostSelection({
if (state?.status !== 'connected') {
return
}
if (settings?.activeRuntimeEnvironmentId?.trim()) {
const switched = await switchRuntimeEnvironment(null)
if (!switched) {
return
}
}
setSelectedAddProjectHostId(hostId)
setStep('add')
setHostSelectorOpen(false)
@ -183,14 +164,7 @@ export function useAddRepoHostSelection({
)
}
},
[
selectableHostOptions,
settings?.activeRuntimeEnvironmentId,
setSshConnectionState,
setStep,
sshConnectionStates,
switchRuntimeEnvironment
]
[selectableHostOptions, setSshConnectionState, setStep, sshConnectionStates]
)
return {

View File

@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { isConnectedRuntimeHostState, runtimeStatusForOverall } from './SshStatusSegment'
import { describe, expect, it, vi } from 'vitest'
import {
connectRuntimeHostForNavigation,
isConnectedRuntimeHostState,
runtimeStatusForOverall
} from './SshStatusSegment'
describe('SshStatusSegment host status helpers', () => {
it('counts connected remote servers as connected hosts', () => {
@ -16,3 +20,40 @@ describe('SshStatusSegment host status helpers', () => {
expect(isConnectedRuntimeHostState('disconnected')).toBe(false)
})
})
describe('connectRuntimeHostForNavigation', () => {
it('loads the transient host catalog without writing Active Server', async () => {
const refreshStatus = vi.fn().mockResolvedValue(true)
const fetchRepos = vi.fn().mockResolvedValue([{ id: 'repo-a' }, { id: 'repo-b' }])
const fetchWorktrees = vi.fn().mockResolvedValue(undefined)
const fetchLineage = vi.fn().mockResolvedValue(undefined)
await expect(
connectRuntimeHostForNavigation({
environmentId: 'windows-2',
refreshStatus,
fetchRepos,
fetchWorktrees,
fetchLineage
})
).resolves.toBe(true)
expect(fetchRepos).toHaveBeenCalledWith('windows-2')
expect(fetchWorktrees).toHaveBeenCalledTimes(2)
expect(fetchLineage).toHaveBeenCalledOnce()
})
it('does not load a catalog when the server is unreachable', async () => {
const fetchRepos = vi.fn()
await expect(
connectRuntimeHostForNavigation({
environmentId: 'windows-2',
refreshStatus: vi.fn().mockResolvedValue(false),
fetchRepos,
fetchWorktrees: vi.fn(),
fetchLineage: vi.fn()
})
).resolves.toBe(false)
expect(fetchRepos).not.toHaveBeenCalled()
})
})

View File

@ -147,6 +147,22 @@ export function isConnectedRuntimeHostState(state: RuntimeHostConnectionState):
return state === 'connected'
}
export async function connectRuntimeHostForNavigation(args: {
environmentId: string
refreshStatus: (environmentId: string, timeoutMs: number) => Promise<boolean>
fetchRepos: (environmentId: string) => Promise<{ id: string }[]>
fetchWorktrees: (repoId: string) => Promise<unknown>
fetchLineage: () => Promise<unknown>
}): Promise<boolean> {
if (!(await args.refreshStatus(args.environmentId, 5_000))) {
return false
}
const repos = await args.fetchRepos(args.environmentId)
await Promise.all(repos.map((repo) => args.fetchWorktrees(repo.id)))
await args.fetchLineage()
return true
}
export function SshStatusSegment({
compact,
iconOnly
@ -159,7 +175,6 @@ export function SshStatusSegment({
const settings = useAppStore((s) => s.settings)
const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId)
const switchRuntimeEnvironment = useAppStore((s) => s.switchRuntimeEnvironment)
const setRuntimeEnvironmentStatus = useAppStore((s) => s.setRuntimeEnvironmentStatus)
const hydrateRuntimeEnvironmentStatuses = useAppStore((s) => s.hydrateRuntimeEnvironmentStatuses)
const refreshRuntimeEnvironmentStatus = useAppStore((s) => s.refreshRuntimeEnvironmentStatus)
@ -214,7 +229,14 @@ export function SshStatusSegment({
const disconnectedTargets = targets.filter((target) => target.status !== 'connected')
const connectRuntimeHost = useCallback(
async (environmentId: string): Promise<void> => {
const reachable = await refreshRuntimeEnvironmentStatus(environmentId, 5_000)
const store = useAppStore.getState()
const reachable = await connectRuntimeHostForNavigation({
environmentId,
refreshStatus: refreshRuntimeEnvironmentStatus,
fetchRepos: store.fetchRuntimeEnvironmentRepos,
fetchWorktrees: store.fetchWorktrees,
fetchLineage: store.fetchWorktreeLineage
})
if (!reachable) {
toast.error(
translate(
@ -224,22 +246,13 @@ export function SshStatusSegment({
)
return
}
const switched = await switchRuntimeEnvironment(environmentId)
if (switched) {
recordFeatureInteraction('ssh')
}
recordFeatureInteraction('ssh')
},
[recordFeatureInteraction, refreshRuntimeEnvironmentStatus, switchRuntimeEnvironment]
[recordFeatureInteraction, refreshRuntimeEnvironmentStatus]
)
const disconnectRuntimeHost = useCallback(
async (environmentId: string, isActive: boolean): Promise<void> => {
async (environmentId: string): Promise<void> => {
try {
if (isActive) {
const switched = await switchRuntimeEnvironment(null)
if (!switched) {
return
}
}
await window.api.runtimeEnvironments.disconnect({ selector: environmentId })
setRuntimeEnvironmentStatus(environmentId, { status: null, checkedAt: Date.now() })
recordFeatureInteraction('ssh')
@ -254,7 +267,7 @@ export function SshStatusSegment({
)
}
},
[recordFeatureInteraction, setRuntimeEnvironmentStatus, switchRuntimeEnvironment]
[recordFeatureInteraction, setRuntimeEnvironmentStatus]
)
if (targets.length === 0 && runtimeHosts.length === 0) {
@ -355,7 +368,7 @@ export function SshStatusSegment({
state={host.state}
detail={runtimeHostConnectionDetail(host.remoteControl)}
onConnect={() => connectRuntimeHost(host.id)}
onDisconnect={() => disconnectRuntimeHost(host.id, host.active)}
onDisconnect={() => disconnectRuntimeHost(host.id)}
/>
))}
{connectedTargets.map((t) => (
@ -374,7 +387,7 @@ export function SshStatusSegment({
state={host.state}
detail={runtimeHostConnectionDetail(host.remoteControl)}
onConnect={() => connectRuntimeHost(host.id)}
onDisconnect={() => disconnectRuntimeHost(host.id, host.active)}
onDisconnect={() => disconnectRuntimeHost(host.id)}
/>
))}
{disconnectedTargets.map((t) => (

View File

@ -1824,8 +1824,13 @@ describe('useIpcEvents updater integration', () => {
tabsByWorktree: {} as Record<string, { id: string; ptyId?: string | null; title?: string }[]>,
folderWorkspaces: [],
projectGroups: [],
repos: [{ id: 'repo-1', connectionId: null }],
worktreesByRepo: { 'repo-1': [{ id: 'wt-2', repoId: 'repo-1' }] },
repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }],
worktreesByRepo: {
'repo-1': ['wt-1', 'wt-2', 'wt-3', 'wt-4', 'wt-history'].map((id) => ({
id,
repoId: 'repo-1'
}))
} as Record<string, { id: string; repoId: string }[]>,
openFiles: [],
browserTabsByWorktree: {},
tabBarOrderByWorktree: {},
@ -2179,14 +2184,52 @@ describe('useIpcEvents updater integration', () => {
expect(createTab).toHaveBeenCalledWith('wt-1')
expect(setActiveTabType).toHaveBeenCalledWith('terminal')
// Exact regression sequence: Local default -> connect/navigate Windows 2 ->
// reveal a local terminal -> restart. Connection and navigation are transient.
storeState.repos.push({
id: 'windows-2-repo',
connectionId: null,
executionHostId: 'runtime:windows-2'
})
storeState.worktreesByRepo['windows-2-repo'] = [
{ id: 'windows-2-worktree', repoId: 'windows-2-repo' }
]
storeState.activeWorktreeId = 'windows-2-worktree'
createTab.mockClear()
replyTerminalCreate.mockClear()
createTerminalListenerRef.current({
requestId: 'local-reveal-after-remote-navigation',
worktreeId: 'wt-2',
title: 'Local shell',
presentation: 'focused'
})
expect(createTab).toHaveBeenCalledWith('wt-2', undefined, undefined, undefined)
expect(replyTerminalCreate).toHaveBeenCalledWith({
requestId: 'local-reveal-after-remote-navigation',
tabId: 'tab-new',
title: 'Local shell'
})
expect(storeState.settings.activeRuntimeEnvironmentId).toBeUndefined()
delete storeState.worktreesByRepo['windows-2-repo']
storeState.repos = storeState.repos.filter((repo) => repo.id !== 'windows-2-repo')
storeState.activeWorktreeId = 'wt-1'
expect(storeState.settings.activeRuntimeEnvironmentId).toBeUndefined()
createWebRuntimeSessionTerminal.mockClear()
createTab.mockClear()
setActiveView.mockClear()
setActiveWorktree.mockClear()
markWorktreeVisited.mockClear()
recordWorktreeVisit.mockClear()
setActiveTabType.mockClear()
setActiveTab.mockClear()
revealWorktreeInSidebar.mockClear()
storeState.settings = {
...storeState.settings,
activeRuntimeEnvironmentId: 'windows-2'
}
createTerminalListenerRef.current({
worktreeId: 'wt-2',
title: 'Runner',
@ -2210,6 +2253,12 @@ describe('useIpcEvents updater integration', () => {
recordInteraction: false
})
expect(queueTabStartupCommand).toHaveBeenCalledWith('tab-new', { command: 'opencode' })
expect(storeState.settings.activeRuntimeEnvironmentId).toBe('windows-2')
storeState.settings = {
...storeState.settings,
activeRuntimeEnvironmentId: undefined
}
createTab.mockClear()
setActiveView.mockClear()
@ -2391,11 +2440,84 @@ describe('useIpcEvents updater integration', () => {
title: 'Blocked Local Terminal'
})
expect(createTab).not.toHaveBeenCalled()
expect(createTab).toHaveBeenCalled()
expect(replyTerminalCreate).toHaveBeenCalledWith({
requestId: 'req-runtime-blocked',
tabId: 'tab-new',
title: 'Blocked Local Terminal'
})
createTab.mockClear()
replyTerminalCreate.mockClear()
storeState.repos = [
...storeState.repos,
{
id: 'repo-remote',
connectionId: null,
executionHostId: 'runtime:focused-runtime'
}
]
storeState.worktreesByRepo = {
...storeState.worktreesByRepo,
'repo-remote': [{ id: 'wt-remote', repoId: 'repo-remote' }]
}
requestTerminalCreateListenerRef.current({
requestId: 'req-remote-owner-blocked',
worktreeId: 'wt-remote',
title: 'Remote-owned Terminal'
})
expect(createTab).not.toHaveBeenCalled()
expect(replyTerminalCreate).toHaveBeenCalledWith({
requestId: 'req-remote-owner-blocked',
error: 'Local terminal creation is unavailable while a remote runtime is active'
})
delete storeState.worktreesByRepo['repo-remote']
storeState.repos = storeState.repos.filter((repo) => repo.id !== 'repo-remote')
createTab.mockClear()
replyTerminalCreate.mockClear()
storeState.repos = [
...storeState.repos,
{
id: 'repo-conflicting-owner',
connectionId: null,
executionHostId: 'runtime:focused-runtime'
}
]
storeState.worktreesByRepo = {
...storeState.worktreesByRepo,
'repo-1': [...storeState.worktreesByRepo['repo-1'], { id: 'wt-ambiguous', repoId: 'repo-1' }],
'repo-conflicting-owner': [{ id: 'wt-ambiguous', repoId: 'repo-conflicting-owner' }]
}
requestTerminalCreateListenerRef.current({
requestId: 'req-ambiguous-owner',
worktreeId: 'wt-ambiguous',
title: 'Ambiguous Terminal',
source: 'runtime-session'
})
expect(createTab).not.toHaveBeenCalled()
expect(replyTerminalCreate).toHaveBeenCalledWith({
requestId: 'req-ambiguous-owner',
error: 'Terminal creation is unavailable because the worktree owner could not be resolved'
})
storeState.worktreesByRepo['repo-1'] = storeState.worktreesByRepo['repo-1'].filter(
(worktree) => worktree.id !== 'wt-ambiguous'
)
delete storeState.worktreesByRepo['repo-conflicting-owner']
storeState.repos = storeState.repos.filter((repo) => repo.id !== 'repo-conflicting-owner')
createTab.mockClear()
replyTerminalCreate.mockClear()
requestTerminalCreateListenerRef.current({
requestId: 'req-missing-owner',
worktreeId: 'wt-missing',
title: 'Missing Terminal'
})
expect(createTab).not.toHaveBeenCalled()
expect(replyTerminalCreate).toHaveBeenCalledWith({
requestId: 'req-missing-owner',
error: 'Terminal creation is unavailable because the worktree owner could not be resolved'
})
storeState.settings.activeRuntimeEnvironmentId = undefined
if (typeof focusTerminalListenerRef.current !== 'function') {

View File

@ -133,6 +133,7 @@ import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut
import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title'
import { titleHasAgentName } from '../../../shared/agent-detection'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route'
import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority'
import { translate } from '@/i18n/i18n'
import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions'
@ -1486,18 +1487,6 @@ export function useIpcEvents(): void {
splitTelemetrySource
}) => {
try {
if (isRuntimeEnvironmentActive()) {
if (requestId) {
window.api.ui.replyTerminalCreate({
requestId,
error: translate(
'auto.hooks.useIpcEvents.60428567b4',
'Local terminal reveal is unavailable while a remote runtime is active'
)
})
}
return
}
const store = useAppStore.getState()
const terminalPresentation = resolveTerminalPresentation({ presentation, activate })
const shouldActivate = terminalPresentation === 'focused'
@ -1710,17 +1699,6 @@ export function useIpcEvents(): void {
unsubs.push(
window.api.ui.onRequestTerminalCreate((data) => {
try {
// Why: runtime-session requests are host-owned tabs materialized by this renderer, not ordinary local creates.
if (isRuntimeEnvironmentActive() && data.source !== 'runtime-session') {
window.api.ui.replyTerminalCreate({
requestId: data.requestId,
error: translate(
'auto.hooks.useIpcEvents.7a64b31991',
'Local terminal creation is unavailable while a remote runtime is active'
)
})
return
}
const store = useAppStore.getState()
const worktreeId = data.worktreeId ?? store.activeWorktreeId
if (!worktreeId) {
@ -1730,6 +1708,28 @@ export function useIpcEvents(): void {
})
return
}
const worktreeRoute = resolveTerminalWorktreeRoute(store, worktreeId)
if (!worktreeRoute) {
window.api.ui.replyTerminalCreate({
requestId: data.requestId,
error: translate(
'auto.hooks.useIpcEvents.unresolvedTerminalWorktreeOwner',
'Terminal creation is unavailable because the worktree owner could not be resolved'
)
})
return
}
// Why: runtime-session requests are host-owned tabs materialized by this renderer, not ordinary local creates.
if (worktreeRoute.runtimeEnvironmentId && data.source !== 'runtime-session') {
window.api.ui.replyTerminalCreate({
requestId: data.requestId,
error: translate(
'auto.hooks.useIpcEvents.7a64b31991',
'Local terminal creation is unavailable while a remote runtime is active'
)
})
return
}
const terminalPresentation = resolveTerminalPresentation(data)
const shouldActivate = terminalPresentation === 'focused'
const shouldSurfaceOwner = terminalPresentation !== 'background'

View File

@ -204,7 +204,8 @@
"slices": {
"browser": {
"d175274b6d": "New Browser Tab",
"08fc23631d": "Browser"
"08fc23631d": "Browser",
"remoteCookieImportUnavailable": "Manual cookie file import is unavailable while a remote runtime is active."
},
"editor": {
"dcb521ed29": "This file is in a conflict state, but no working-tree file is available to edit.",
@ -683,7 +684,8 @@
"4f78ba5885": "Workspace synced",
"ef223fbb6b": "A device tried to connect but is not paired",
"11992d0337": "If this was your phone or another Orca client, re-pair it from Settings → Mobile.",
"6573cfe955": "Open Mobile Settings"
"6573cfe955": "Open Mobile Settings",
"unresolvedTerminalWorktreeOwner": "Terminal creation is unavailable because the worktree owner could not be resolved"
},
"useSettingsNavigationMetadata": {
"4a728cd56b": "New features that are still taking shape. Give them a try.",
@ -1273,7 +1275,17 @@
"noRunTargets": "No run targets are ready for this project.",
"perWorkspaceEnvHint": "Provision an on-demand environment from a recipe",
"branchName": "Branch name",
"branchNamePlaceholder": "feature/my-branch"
"branchNamePlaceholder": "feature/my-branch",
"connectTimedOut": "Connection timed out. It may still be connecting in the background.",
"connectingHost": "Connecting…",
"connectHost": "Connect",
"addHost": "Add host",
"addHostHint": "Register another machine or Orca server",
"addSshHost": "Add SSH host",
"addSshHostHint": "Use an existing machine over SSH",
"addRemoteOrcaServer": "Add Remote Orca Server",
"addRemoteOrcaServerHint": "Pair another Orca runtime",
"hostConnectionFailed": "Connection failed"
},
"NewWorkspaceComposerModal": {
"createWorktree": "Create worktree",
@ -4581,7 +4593,14 @@
"worktree": {
"flow": {
"c460fecc4a": "Failed to sleep some workspaces",
"8bc3fc0671": "Failed to sleep workspace"
"8bc3fc0671": "Failed to sleep workspace",
"legacy": {
"unverified": "The older host runtime could not confirm terminal shutdown. The workspace was kept open; update the host and try again."
},
"host": {
"unverified": "The host could not confirm terminal shutdown. The workspace was kept open; check the connection and try again."
},
"retry": "The workspace was kept open. Try again; if the problem continues, check the host connection."
}
}
},
@ -4848,7 +4867,8 @@
"sshImportSynced": "Synced {{value0}} host{{value1}}.",
"sshImportFailed": "Failed to import SSH config.",
"importing": "Importing...",
"importSshConfig": "Import ~/.ssh/config"
"importSshConfig": "Import ~/.ssh/config",
"advanced": "Advanced"
},
"ForgetSshWorkspaceDialog": {
"reconnectFailed": "Reconnection failed",
@ -6472,7 +6492,6 @@
"b5b5114cb0": "Removed {{value0}}.",
"6cb6eae14f": "Failed to save runtime environment.",
"7b5986c8df": "Saved {{value0}}. Use Active Server to switch when ready.",
"a5b58465b6": "Connected to {{value0}}.",
"5ef712f407": "A server named \"{{value0}}\" already exists.",
"0c55a47480": "Name and pairing code are required.",
"e6410d72c3": "Failed to load runtime environments.",
@ -6514,7 +6533,9 @@
"updateServer": "Update",
"reviewServerUpdates": "Check for Server Updates",
"updatingServers": "Updating servers…",
"orcaVersion": "Orca v{{value0}}"
"orcaVersion": "Orca v{{value0}}",
"removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.",
"removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone."
},
"RuntimePairingGeneratedUrlRows": {
"0495f68959": "Copy {{value0}}"

View File

@ -181,7 +181,8 @@
"slices": {
"browser": {
"d175274b6d": "Nueva pestaña del navegador",
"08fc23631d": "Navegador"
"08fc23631d": "Navegador",
"remoteCookieImportUnavailable": "Manual cookie file import is unavailable while a remote runtime is active."
},
"editor": {
"dcb521ed29": "Este archivo está en conflicto, pero no hay una copia editable en el worktree.",
@ -660,7 +661,8 @@
"4f78ba5885": "Espacio de trabajo sincronizado",
"ef223fbb6b": "A device tried to connect but is not paired",
"11992d0337": "If this was your phone or another Orca client, re-pair it from Settings → Mobile.",
"6573cfe955": "Open Mobile Settings"
"6573cfe955": "Open Mobile Settings",
"unresolvedTerminalWorktreeOwner": "Terminal creation is unavailable because the worktree owner could not be resolved"
},
"useSettingsNavigationMetadata": {
"4a728cd56b": "Nuevas funciones que aún están tomando forma. Pruébalas.",
@ -1250,7 +1252,17 @@
"noRunTargets": "No hay destinos de ejecución listos para este proyecto.",
"perWorkspaceEnvHint": "Provisiona un entorno bajo demanda a partir de una receta",
"branchName": "Nombre de rama",
"branchNamePlaceholder": "feature/my-branch"
"branchNamePlaceholder": "feature/my-branch",
"connectTimedOut": "Connection timed out. It may still be connecting in the background.",
"connectingHost": "Connecting…",
"connectHost": "Connect",
"addHost": "Add host",
"addHostHint": "Register another machine or Orca server",
"addSshHost": "Add SSH host",
"addSshHostHint": "Use an existing machine over SSH",
"addRemoteOrcaServer": "Add Remote Orca Server",
"addRemoteOrcaServerHint": "Pair another Orca runtime",
"hostConnectionFailed": "Connection failed"
},
"NewWorkspaceComposerModal": {
"createWorktree": "Crear worktree",
@ -4423,7 +4435,23 @@
"0bed8727db": "Puede que se haya movido o eliminado. Actualiza los espacios de trabajo o quítalo de Orca.",
"3921d3d9a5": "No se encontró la carpeta del espacio de trabajo.",
"f387af445b": "La ruta del espacio de trabajo no es una ruta local válida.",
"3ec372b664": "gestor de archivos"
"3ec372b664": "gestor de archivos",
"localOnly": "Local only",
"remoteSsh": "Remote SSH",
"remoteRuntimeUnsupported": "Opening this path in a local app is not available.",
"remoteRuntimeUnsupportedDetail": "Switch to a local or SSH workspace, then try again.",
"sshTargetNotFound": "SSH host is no longer available.",
"sshTargetNotFoundDetail": "Refresh workspaces or reconnect the host, then try again.",
"sshTargetInvalid": "SSH host configuration is incomplete.",
"sshTargetInvalidDetail": "Edit or reconnect the SSH host, then try again.",
"sshAliasRequired": "VS Code needs an SSH config alias for this host.",
"sshAliasRequiredDetail": "Add a Host alias for {{host}}:{{port}} to your local SSH config, reconnect the workspace, then try again.",
"remoteEditorUnsupported": "This app cannot open SSH workspaces.",
"remoteEditorUnsupportedDetail": "Choose VS Code or use the app locally.",
"remotePathInvalid": "Path is not valid for the SSH host.",
"remotePathInvalidDetail": "Refresh the workspace before trying again.",
"remoteLaunchFailed": "Could not open the path in VS Code.",
"remoteLaunchFailedDetail": "Check the VS Code command configured on this machine."
},
"WorktreeTitleInlineRename": {
"2f42ae024f": "No leído:",
@ -4519,7 +4547,14 @@
"worktree": {
"flow": {
"c460fecc4a": "No se pudieron poner en reposo algunos espacios de trabajo",
"8bc3fc0671": "No se pudo poner en reposo el espacio de trabajo"
"8bc3fc0671": "No se pudo poner en reposo el espacio de trabajo",
"legacy": {
"unverified": "The older host runtime could not confirm terminal shutdown. The workspace was kept open; update the host and try again."
},
"host": {
"unverified": "The host could not confirm terminal shutdown. The workspace was kept open; check the connection and try again."
},
"retry": "The workspace was kept open. Try again; if the problem continues, check the host connection."
}
}
},
@ -4809,7 +4844,8 @@
"sshImportFailed": "No se pudo importar la configuración SSH.",
"importing": "Importando...",
"importSshConfig": "Importar ~/.ssh/config",
"sshPersistenceDefault": "Los terminales remotos en este host seguirán activos hasta que los cierres o restablezcas el relay."
"sshPersistenceDefault": "Los terminales remotos en este host seguirán activos hasta que los cierres o restablezcas el relay.",
"advanced": "Advanced"
},
"ForgetSshWorkspaceDialog": {
"reconnectFailed": "Error de reconexión",
@ -6389,7 +6425,6 @@
"b5b5114cb0": "Se eliminó {{value0}}.",
"6cb6eae14f": "No se pudo guardar el runtime.",
"7b5986c8df": "Se guardó {{value0}}. Usa Servidor activo para cambiar cuando esté listo.",
"a5b58465b6": "Conectado a {{value0}}.",
"5ef712f407": "Ya existe un servidor llamado \"{{value0}}\".",
"0c55a47480": "Se requieren nombre y código de emparejamiento.",
"e6410d72c3": "No se pudieron cargar los runtimes.",
@ -6437,7 +6472,10 @@
"versionUnavailable": "Orca version unavailable",
"updateServer": "Update",
"reviewServerUpdates": "Server updates",
"orcaVersion": "Orca v{{value0}}"
"orcaVersion": "Orca v{{value0}}",
"removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.",
"removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.",
"updatingServers": "Updating servers…"
},
"RuntimePairingGeneratedUrlRows": {
"0495f68959": "Copiar {{value0}}"

View File

@ -181,7 +181,8 @@
"slices": {
"browser": {
"d175274b6d": "新規ブラウザタブ",
"08fc23631d": "ブラウザ"
"08fc23631d": "ブラウザ",
"remoteCookieImportUnavailable": "Manual cookie file import is unavailable while a remote runtime is active."
},
"editor": {
"dcb521ed29": "このファイルは競合状態にありますが、編集できる作業ツリー ファイルがありません。",
@ -660,7 +661,8 @@
"4f78ba5885": "ワークスペースが同期されました",
"ef223fbb6b": "A device tried to connect but is not paired",
"11992d0337": "If this was your phone or another Orca client, re-pair it from Settings → Mobile.",
"6573cfe955": "Open Mobile Settings"
"6573cfe955": "Open Mobile Settings",
"unresolvedTerminalWorktreeOwner": "Terminal creation is unavailable because the worktree owner could not be resolved"
},
"useSettingsNavigationMetadata": {
"4a728cd56b": "まだ形になりつつある新機能。試してみてください。",
@ -1250,7 +1252,17 @@
"noRunTargets": "このプロジェクトには実行ターゲットが準備されていません。",
"perWorkspaceEnvHint": "レシピからオンデマンド環境をプロビジョニングする",
"branchName": "ブランチ名",
"branchNamePlaceholder": "feature/my-branch"
"branchNamePlaceholder": "feature/my-branch",
"connectTimedOut": "Connection timed out. It may still be connecting in the background.",
"connectingHost": "Connecting…",
"connectHost": "Connect",
"addHost": "Add host",
"addHostHint": "Register another machine or Orca server",
"addSshHost": "Add SSH host",
"addSshHostHint": "Use an existing machine over SSH",
"addRemoteOrcaServer": "Add Remote Orca Server",
"addRemoteOrcaServerHint": "Pair another Orca runtime",
"hostConnectionFailed": "Connection failed"
},
"NewWorkspaceComposerModal": {
"createWorktree": "ワークツリーを作成する",
@ -4404,7 +4416,23 @@
"0bed8727db": "移動または削除された可能性があります。ワークスペースを更新するか、Orca からワークスペースを削除します。",
"3921d3d9a5": "ワークスペースフォルダーが見つかりませんでした。",
"f387af445b": "ワークスペース パスは有効なローカル パスではありません。",
"3ec372b664": "ファイルマネージャー"
"3ec372b664": "ファイルマネージャー",
"localOnly": "Local only",
"remoteSsh": "Remote SSH",
"remoteRuntimeUnsupported": "Opening this path in a local app is not available.",
"remoteRuntimeUnsupportedDetail": "Switch to a local or SSH workspace, then try again.",
"sshTargetNotFound": "SSH host is no longer available.",
"sshTargetNotFoundDetail": "Refresh workspaces or reconnect the host, then try again.",
"sshTargetInvalid": "SSH host configuration is incomplete.",
"sshTargetInvalidDetail": "Edit or reconnect the SSH host, then try again.",
"sshAliasRequired": "VS Code needs an SSH config alias for this host.",
"sshAliasRequiredDetail": "Add a Host alias for {{host}}:{{port}} to your local SSH config, reconnect the workspace, then try again.",
"remoteEditorUnsupported": "This app cannot open SSH workspaces.",
"remoteEditorUnsupportedDetail": "Choose VS Code or use the app locally.",
"remotePathInvalid": "Path is not valid for the SSH host.",
"remotePathInvalidDetail": "Refresh the workspace before trying again.",
"remoteLaunchFailed": "Could not open the path in VS Code.",
"remoteLaunchFailedDetail": "Check the VS Code command configured on this machine."
},
"WorktreeTitleInlineRename": {
"2f42ae024f": "未読:",
@ -4500,7 +4528,14 @@
"worktree": {
"flow": {
"c460fecc4a": "一部のワークスペースのスリープに失敗しました",
"8bc3fc0671": "ワークスペースのスリープに失敗しました"
"8bc3fc0671": "ワークスペースのスリープに失敗しました",
"legacy": {
"unverified": "The older host runtime could not confirm terminal shutdown. The workspace was kept open; update the host and try again."
},
"host": {
"unverified": "The host could not confirm terminal shutdown. The workspace was kept open; check the connection and try again."
},
"retry": "The workspace was kept open. Try again; if the problem continues, check the host connection."
}
}
},
@ -4809,7 +4844,8 @@
"sshImportFailed": "SSH 構成のインポートに失敗しました。",
"importing": "インポート中...",
"importSshConfig": "~/.ssh/config をインポートします",
"sshPersistenceDefault": "このホスト上のリモートターミナルは、終了するかリレーをリセットするまで動作し続けます。"
"sshPersistenceDefault": "このホスト上のリモートターミナルは、終了するかリレーをリセットするまで動作し続けます。",
"advanced": "Advanced"
},
"ForgetSshWorkspaceDialog": {
"reconnectFailed": "再接続に失敗しました",
@ -6411,7 +6447,6 @@
"b5b5114cb0": "{{value0}} を削除しました。",
"6cb6eae14f": "実行環境の保存に失敗しました。",
"7b5986c8df": "{{value0}} を保存しました。準備ができたら、アクティブサーバーで切り替えてください。",
"a5b58465b6": "{{value0}} に接続されています。",
"5ef712f407": "「{{value0}}」という名前のサーバーはすでに存在します。",
"0c55a47480": "名前とペアリングコードは必須です。",
"e6410d72c3": "ランタイム環境の読み込みに失敗しました。",
@ -6459,7 +6494,10 @@
"versionUnavailable": "Orca version unavailable",
"updateServer": "Update",
"reviewServerUpdates": "Server updates",
"orcaVersion": "Orca v{{value0}}"
"orcaVersion": "Orca v{{value0}}",
"removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.",
"removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.",
"updatingServers": "Updating servers…"
},
"RuntimePairingGeneratedUrlRows": {
"0495f68959": "{{value0}}をコピー"

View File

@ -181,7 +181,8 @@
"slices": {
"browser": {
"d175274b6d": "새 브라우저 탭",
"08fc23631d": "브라우저"
"08fc23631d": "브라우저",
"remoteCookieImportUnavailable": "Manual cookie file import is unavailable while a remote runtime is active."
},
"editor": {
"dcb521ed29": "이 파일은 충돌 상태에 있지만 편집할 수 있는 작업 트리 파일이 없습니다.",
@ -660,7 +661,8 @@
"4f78ba5885": "워크스페이스가 동기화되었습니다.",
"ef223fbb6b": "A device tried to connect but is not paired",
"11992d0337": "If this was your phone or another Orca client, re-pair it from Settings → Mobile.",
"6573cfe955": "Open Mobile Settings"
"6573cfe955": "Open Mobile Settings",
"unresolvedTerminalWorktreeOwner": "Terminal creation is unavailable because the worktree owner could not be resolved"
},
"useSettingsNavigationMetadata": {
"4a728cd56b": "아직 구체화되고 있는 새로운 기능입니다. 한번 시도해 보세요.",
@ -1250,7 +1252,17 @@
"noRunTargets": "이 프로젝트에는 실행 대상이 준비되어 있지 않습니다.",
"perWorkspaceEnvHint": "레시피에서 온디맨드 환경 프로비저닝",
"branchName": "브랜치 이름",
"branchNamePlaceholder": "feature/my-branch"
"branchNamePlaceholder": "feature/my-branch",
"connectTimedOut": "Connection timed out. It may still be connecting in the background.",
"connectingHost": "Connecting…",
"connectHost": "Connect",
"addHost": "Add host",
"addHostHint": "Register another machine or Orca server",
"addSshHost": "Add SSH host",
"addSshHostHint": "Use an existing machine over SSH",
"addRemoteOrcaServer": "Add Remote Orca Server",
"addRemoteOrcaServerHint": "Pair another Orca runtime",
"hostConnectionFailed": "Connection failed"
},
"NewWorkspaceComposerModal": {
"createWorktree": "작업 트리 만들기",
@ -4404,7 +4416,23 @@
"0bed8727db": "이동되었거나 삭제되었을 수 있습니다. 워크스페이스를 새로고침하거나 Orca에서 제거하세요.",
"3921d3d9a5": "워크스페이스 폴더를 찾을 수 없습니다.",
"f387af445b": "워크스페이스 경로가 유효한 로컬 경로가 아닙니다.",
"3ec372b664": "파일 관리자"
"3ec372b664": "파일 관리자",
"localOnly": "Local only",
"remoteSsh": "Remote SSH",
"remoteRuntimeUnsupported": "Opening this path in a local app is not available.",
"remoteRuntimeUnsupportedDetail": "Switch to a local or SSH workspace, then try again.",
"sshTargetNotFound": "SSH host is no longer available.",
"sshTargetNotFoundDetail": "Refresh workspaces or reconnect the host, then try again.",
"sshTargetInvalid": "SSH host configuration is incomplete.",
"sshTargetInvalidDetail": "Edit or reconnect the SSH host, then try again.",
"sshAliasRequired": "VS Code needs an SSH config alias for this host.",
"sshAliasRequiredDetail": "Add a Host alias for {{host}}:{{port}} to your local SSH config, reconnect the workspace, then try again.",
"remoteEditorUnsupported": "This app cannot open SSH workspaces.",
"remoteEditorUnsupportedDetail": "Choose VS Code or use the app locally.",
"remotePathInvalid": "Path is not valid for the SSH host.",
"remotePathInvalidDetail": "Refresh the workspace before trying again.",
"remoteLaunchFailed": "Could not open the path in VS Code.",
"remoteLaunchFailedDetail": "Check the VS Code command configured on this machine."
},
"WorktreeTitleInlineRename": {
"2f42ae024f": "읽지 않음:",
@ -4500,7 +4528,14 @@
"worktree": {
"flow": {
"c460fecc4a": "일부 워크스페이스를 절전 모드로 전환하지 못했습니다.",
"8bc3fc0671": "워크스페이스를 절전 모드로 전환하지 못했습니다."
"8bc3fc0671": "워크스페이스를 절전 모드로 전환하지 못했습니다.",
"legacy": {
"unverified": "The older host runtime could not confirm terminal shutdown. The workspace was kept open; update the host and try again."
},
"host": {
"unverified": "The host could not confirm terminal shutdown. The workspace was kept open; check the connection and try again."
},
"retry": "The workspace was kept open. Try again; if the problem continues, check the host connection."
}
}
},
@ -4809,7 +4844,8 @@
"sshImportFailed": "SSH 구성을 가져오지 못했습니다.",
"importing": "가져오는 중...",
"importSshConfig": "~/.ssh/config 가져오기",
"sshPersistenceDefault": "이 호스트의 원격 터미널은 종료하거나 릴레이를 재설정할 때까지 계속 실행됩니다."
"sshPersistenceDefault": "이 호스트의 원격 터미널은 종료하거나 릴레이를 재설정할 때까지 계속 실행됩니다.",
"advanced": "Advanced"
},
"ForgetSshWorkspaceDialog": {
"reconnectFailed": "재연결 실패",
@ -6374,7 +6410,6 @@
"b5b5114cb0": "{{value0}}을(를) 삭제했습니다.",
"6cb6eae14f": "런타임 환경을 저장하지 못했습니다.",
"7b5986c8df": "{{value0}}이(가) 저장되었습니다. 준비되면 활성 서버에서 전환하세요.",
"a5b58465b6": "{{value0}}에 연결되었습니다.",
"5ef712f407": "\"{{value0}}\"이라는 서버가 이미 존재합니다.",
"0c55a47480": "이름과 페어링 코드가 필요합니다.",
"e6410d72c3": "런타임 환경을 로드하지 못했습니다.",
@ -6422,7 +6457,10 @@
"versionUnavailable": "Orca version unavailable",
"updateServer": "Update",
"reviewServerUpdates": "Server updates",
"orcaVersion": "Orca v{{value0}}"
"orcaVersion": "Orca v{{value0}}",
"removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.",
"removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.",
"updatingServers": "Updating servers…"
},
"RuntimePairingGeneratedUrlRows": {
"0495f68959": "{{value0}} 복사"

View File

@ -181,7 +181,8 @@
"slices": {
"browser": {
"d175274b6d": "新浏览器选项卡",
"08fc23631d": "浏览器"
"08fc23631d": "浏览器",
"remoteCookieImportUnavailable": "Manual cookie file import is unavailable while a remote runtime is active."
},
"editor": {
"dcb521ed29": "该文件处于冲突状态,但没有可编辑的工作树文件。",
@ -660,7 +661,8 @@
"4f78ba5885": "工作区已同步",
"ef223fbb6b": "A device tried to connect but is not paired",
"11992d0337": "If this was your phone or another Orca client, re-pair it from Settings → Mobile.",
"6573cfe955": "Open Mobile Settings"
"6573cfe955": "Open Mobile Settings",
"unresolvedTerminalWorktreeOwner": "Terminal creation is unavailable because the worktree owner could not be resolved"
},
"useSettingsNavigationMetadata": {
"4a728cd56b": "仍在完善中的新功能。尝试一下。",
@ -1250,7 +1252,17 @@
"noRunTargets": "此项目还没有可用的运行目标。",
"perWorkspaceEnvHint": "通过环境模板按需创建环境",
"branchName": "分支名称",
"branchNamePlaceholder": "feature/my-branch"
"branchNamePlaceholder": "feature/my-branch",
"connectTimedOut": "Connection timed out. It may still be connecting in the background.",
"connectingHost": "Connecting…",
"connectHost": "Connect",
"addHost": "Add host",
"addHostHint": "Register another machine or Orca server",
"addSshHost": "Add SSH host",
"addSshHostHint": "Use an existing machine over SSH",
"addRemoteOrcaServer": "Add Remote Orca Server",
"addRemoteOrcaServerHint": "Pair another Orca runtime",
"hostConnectionFailed": "Connection failed"
},
"NewWorkspaceComposerModal": {
"createWorktree": "创建工作树",
@ -4404,7 +4416,23 @@
"0bed8727db": "它可能已被手机或删除。刷新工作区或将其从 Orca 中删除。",
"3921d3d9a5": "找不到工作区文件夹。",
"f387af445b": "工作区路径不是有效的本地路径。",
"3ec372b664": "文件管理器"
"3ec372b664": "文件管理器",
"localOnly": "Local only",
"remoteSsh": "Remote SSH",
"remoteRuntimeUnsupported": "Opening this path in a local app is not available.",
"remoteRuntimeUnsupportedDetail": "Switch to a local or SSH workspace, then try again.",
"sshTargetNotFound": "SSH host is no longer available.",
"sshTargetNotFoundDetail": "Refresh workspaces or reconnect the host, then try again.",
"sshTargetInvalid": "SSH host configuration is incomplete.",
"sshTargetInvalidDetail": "Edit or reconnect the SSH host, then try again.",
"sshAliasRequired": "VS Code needs an SSH config alias for this host.",
"sshAliasRequiredDetail": "Add a Host alias for {{host}}:{{port}} to your local SSH config, reconnect the workspace, then try again.",
"remoteEditorUnsupported": "This app cannot open SSH workspaces.",
"remoteEditorUnsupportedDetail": "Choose VS Code or use the app locally.",
"remotePathInvalid": "Path is not valid for the SSH host.",
"remotePathInvalidDetail": "Refresh the workspace before trying again.",
"remoteLaunchFailed": "Could not open the path in VS Code.",
"remoteLaunchFailedDetail": "Check the VS Code command configured on this machine."
},
"WorktreeTitleInlineRename": {
"2f42ae024f": "未读:",
@ -4500,7 +4528,14 @@
"worktree": {
"flow": {
"c460fecc4a": "无法睡眠某些工作区",
"8bc3fc0671": "无法睡眠工作区"
"8bc3fc0671": "无法睡眠工作区",
"legacy": {
"unverified": "The older host runtime could not confirm terminal shutdown. The workspace was kept open; update the host and try again."
},
"host": {
"unverified": "The host could not confirm terminal shutdown. The workspace was kept open; check the connection and try again."
},
"retry": "The workspace was kept open. Try again; if the problem continues, check the host connection."
}
}
},
@ -4809,7 +4844,8 @@
"sshImportFailed": "导入 SSH 配置失败。",
"importing": "正在导入...",
"importSshConfig": "导入 ~/.ssh/config",
"sshPersistenceDefault": "此主机上的远程终端会保持运行,直到你结束它们或重置中继。"
"sshPersistenceDefault": "此主机上的远程终端会保持运行,直到你结束它们或重置中继。",
"advanced": "Advanced"
},
"ForgetSshWorkspaceDialog": {
"reconnectFailed": "重新连接失败",
@ -6374,7 +6410,6 @@
"b5b5114cb0": "删除了 {{value0}}。",
"6cb6eae14f": "无法保存运行时环境。",
"7b5986c8df": "已保存 {{value0}}。准备好后可通过活动服务器进行切换。",
"a5b58465b6": "连接到 {{value0}}。",
"5ef712f407": "名为“{{value0}}”的服务器已存在。",
"0c55a47480": "需要名称和配对代码。",
"e6410d72c3": "无法加载运行时环境。",
@ -6422,7 +6457,10 @@
"versionUnavailable": "Orca version unavailable",
"updateServer": "Update",
"reviewServerUpdates": "Server updates",
"orcaVersion": "Orca v{{value0}}"
"orcaVersion": "Orca v{{value0}}",
"removeActiveServerBlocked": "Choose another Active Server in Advanced before removing this server.",
"removeActiveServerDescription": "Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.",
"updatingServers": "Updating servers…"
},
"RuntimePairingGeneratedUrlRows": {
"0495f68959": "复制 {{value0}}"

View File

@ -74,6 +74,7 @@ import {
} from './web-runtime-wake-terminal-respawn'
import { isRuntimeSubscriptionReplayResponse } from '../../../shared/runtime-subscription-replay'
import { queueAcceptedWebSessionTerminalSnapshot } from './web-session-terminal-handle-events'
import { recoverWebSessionTerminalOrphansBeforeApply } from './web-session-terminal-orphan-recovery'
import {
clearWebAgentSessionHandoff,
clearWebAgentSessionHandoffsForEnvironment,
@ -2684,7 +2685,7 @@ export function useWebSessionTabsSync(): void {
timeoutMs: 15_000,
expectedEnvironmentPairingRevision
})
.then((response: RuntimeRpcResponse<unknown>) => {
.then(async (response: RuntimeRpcResponse<unknown>) => {
if (
disposed ||
getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision
@ -2700,8 +2701,23 @@ export function useWebSessionTabsSync(): void {
console.warn('[web-session-tabs-sync] initial listAll returned an invalid payload')
return
}
const recovered = await Promise.all(
result.snapshots.map((snapshot) =>
recoverWebSessionTerminalOrphansBeforeApply(
useAppStore.getState(),
snapshot,
environmentId
)
)
)
if (disposed) {
return
}
const applicable = recovered.filter(
(snapshot): snapshot is RuntimeMobileSessionTabsResult => snapshot !== null
)
applyWebSessionTabsStorePatch((state) =>
applyFreshWebSessionTabsSnapshots(state, result.snapshots, environmentId)
applyFreshWebSessionTabsSnapshots(state, applicable, environmentId)
)
})
.catch((error) => {
@ -2740,25 +2756,60 @@ export function useWebSessionTabsSync(): void {
const event = response.result as SessionTabsStreamEvent
const replayed = isRuntimeSubscriptionReplayResponse(response)
if (event.type === 'snapshots') {
if (replayed) {
for (const snapshot of event.snapshots) {
acceptReplayedWebSessionTabsSnapshot(environmentId, snapshot.worktree)
}
}
applyWebSessionTabsStorePatch((state) =>
applyFreshWebSessionTabsSnapshots(state, event.snapshots, environmentId)
void Promise.all(
event.snapshots.map((snapshot) =>
recoverWebSessionTerminalOrphansBeforeApply(
useAppStore.getState(),
snapshot,
environmentId
)
)
)
.then((recovered) => {
if (!disposed) {
const applicable = recovered.filter(
(snapshot): snapshot is RuntimeMobileSessionTabsResult => snapshot !== null
)
if (replayed) {
for (const snapshot of applicable) {
acceptReplayedWebSessionTabsSnapshot(environmentId, snapshot.worktree)
}
}
applyWebSessionTabsStorePatch((state) =>
applyFreshWebSessionTabsSnapshots(state, applicable, environmentId)
)
}
})
.catch((error) => {
if (!disposed) {
console.warn('[web-session-tabs-sync] snapshot recovery failed:', error)
}
})
return
}
if (event.type !== 'snapshot' && event.type !== 'updated') {
return
}
if (replayed) {
acceptReplayedWebSessionTabsSnapshot(environmentId, event.worktree)
}
applyWebSessionTabsStorePatch((state) =>
applyFreshWebSessionTabsSnapshot(state, event, environmentId)
void recoverWebSessionTerminalOrphansBeforeApply(
useAppStore.getState(),
event,
environmentId
)
.then((recovered) => {
if (!disposed && recovered) {
if (replayed) {
acceptReplayedWebSessionTabsSnapshot(environmentId, recovered.worktree)
}
applyWebSessionTabsStorePatch((state) =>
applyFreshWebSessionTabsSnapshot(state, recovered, environmentId)
)
}
})
.catch((error) => {
if (!disposed) {
console.warn('[web-session-tabs-sync] snapshot recovery failed:', error)
}
})
},
onError: (error) => {
console.warn('[web-session-tabs-sync] global subscription error:', error.message)
@ -2822,6 +2873,71 @@ export function useWebSessionTabsSync(): void {
let requestedInitialTerminal = false
let requestedRespawnAfterWake = false
let unsubscribe: (() => void) | null = null
const applyActiveSnapshot = async (
event: RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' },
response: RuntimeRpcResponse<unknown>
): Promise<void> => {
const recovered = await recoverWebSessionTerminalOrphansBeforeApply(
useAppStore.getState(),
event,
environmentId
)
if (disposed || !recovered) {
return
}
if (isRuntimeSubscriptionReplayResponse(response)) {
acceptReplayedWebSessionTabsSnapshot(environmentId, recovered.worktree)
}
const recoveredEvent: SessionTabsStreamEvent = { ...recovered, type: event.type }
const fresh = shouldApplyWebSessionTabsSnapshot(recovered, environmentId)
const syncState = useAppStore.getState()
const localWorktreeTabs = syncState.tabsByWorktree[activeWorktreeId] ?? []
const localTerminalCount = localWorktreeTabs.length
const hasLiveLocalPty = localWorktreeTabs.some(
(tab) => (syncState.ptyIdsByTabId[tab.id] ?? []).length > 0
)
const shouldBootstrapInitialTerminal = shouldBootstrapInitialWebRuntimeTerminal({
event: recoveredEvent,
activeWorktreeId,
requestedInitialTerminal,
snapshotIsFresh: fresh,
localTerminalCount
})
const shouldRespawnAfterWake = shouldRespawnWebRuntimeTerminalAfterWake({
event: recoveredEvent,
activeWorktreeId,
requestedRespawnAfterWake,
snapshotIsFresh: fresh,
localTerminalCount,
hasLiveLocalPty,
skipWakeRespawn: shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId)
})
if (fresh) {
applyWebSessionTabsStorePatch((state) =>
applyWebSessionTabsSnapshot(state, recovered, environmentId)
)
}
if (!disposed && shouldBootstrapInitialTerminal) {
requestedInitialTerminal = true
await createWebRuntimeSessionTerminal({
worktreeId: activeWorktreeId,
environmentId,
activate: true
})
} else if (
!disposed &&
shouldRespawnAfterWake &&
beginWebRuntimeWakeTerminalRespawn(activeWorktreeId)
) {
requestedRespawnAfterWake = true
await createWebRuntimeSessionTerminal({
worktreeId: activeWorktreeId,
environmentId,
activate: true,
selectWorktree: false
}).finally(() => endWebRuntimeWakeTerminalRespawn(activeWorktreeId))
}
}
void window.api.runtimeEnvironments
.subscribe(
{
@ -2847,60 +2963,11 @@ export function useWebSessionTabsSync(): void {
if (event.type !== 'snapshot' && event.type !== 'updated') {
return
}
if (isRuntimeSubscriptionReplayResponse(response)) {
acceptReplayedWebSessionTabsSnapshot(environmentId, event.worktree)
}
const fresh = shouldApplyWebSessionTabsSnapshot(event, environmentId)
const syncState = useAppStore.getState()
const localWorktreeTabs = syncState.tabsByWorktree[activeWorktreeId] ?? []
const localTerminalCount = localWorktreeTabs.length
const hasLiveLocalPty = localWorktreeTabs.some(
(tab) => (syncState.ptyIdsByTabId[tab.id] ?? []).length > 0
)
const shouldBootstrapInitialTerminal = shouldBootstrapInitialWebRuntimeTerminal({
event,
activeWorktreeId,
requestedInitialTerminal,
snapshotIsFresh: fresh,
localTerminalCount
void applyActiveSnapshot(event, response).catch((error) => {
if (!disposed) {
console.warn('[web-session-tabs-sync] active snapshot recovery failed:', error)
}
})
const shouldRespawnAfterWake = shouldRespawnWebRuntimeTerminalAfterWake({
event,
activeWorktreeId,
requestedRespawnAfterWake,
snapshotIsFresh: fresh,
localTerminalCount,
hasLiveLocalPty,
skipWakeRespawn: shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId)
})
if (fresh) {
applyWebSessionTabsStorePatch((state) =>
applyWebSessionTabsSnapshot(state, event, environmentId)
)
}
if (!disposed && shouldBootstrapInitialTerminal) {
requestedInitialTerminal = true
void createWebRuntimeSessionTerminal({
worktreeId: activeWorktreeId,
environmentId,
activate: true
})
} else if (
!disposed &&
shouldRespawnAfterWake &&
beginWebRuntimeWakeTerminalRespawn(activeWorktreeId)
) {
requestedRespawnAfterWake = true
// Why: recreate the terminal without changing selected worktree to avoid re-triggering activation churn.
void createWebRuntimeSessionTerminal({
worktreeId: activeWorktreeId,
environmentId,
activate: true,
selectWorktree: false
}).finally(() => {
endWebRuntimeWakeTerminalRespawn(activeWorktreeId)
})
}
},
onError: (error) => {
console.warn('[web-session-tabs-sync] subscription error:', error.message)

View File

@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toRemoteRuntimePtyId } from './runtime-terminal-stream'
import {
clearWebSessionTerminalOrphanRecoveryForTests,
recoverWebSessionTerminalOrphansBeforeApply
} from './web-session-terminal-orphan-recovery'
const worktree = 'repo::/worktree'
function legacyRecoveryState() {
return {
tabsByWorktree: {
[worktree]: [{ id: 'web-terminal-host-tab', worktreeId: worktree } as never]
},
terminalLayoutsByTabId: {
'web-terminal-host-tab': {
root: { type: 'leaf' as const, leafId: 'leaf-1' },
activeLeafId: 'leaf-1',
expandedLeafId: null,
ptyIdsByLeafId: {
'leaf-1': toRemoteRuntimePtyId('term_live', 'windows-2')
}
}
},
activeTabIdByWorktree: {},
activeGroupIdByWorktree: {}
}
}
const missingSnapshot = {
worktree,
publicationEpoch: 'mixed-version',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
describe('mixed-version web terminal orphan recovery', () => {
beforeEach(() => clearWebSessionTerminalOrphanRecoveryForTests())
it.each([
{
name: 'incarnation evidence is unavailable',
result: {
terminals: [{ handle: 'term_live', ptyId: 'pty-live', worktreeId: worktree }],
totalCount: 1,
truncated: false
}
},
{
name: 'a legacy unfiltered listing truncates before the candidate',
result: {
terminals: [{ handle: 'term_other', ptyId: 'pty-other', worktreeId: worktree }],
totalCount: 101,
truncated: true
}
}
])('keeps the live candidate visible when $name', async ({ result }) => {
const call = vi.fn(async () => ({ ok: true as const, result }))
await expect(
recoverWebSessionTerminalOrphansBeforeApply(
legacyRecoveryState(),
missingSnapshot,
'windows-2',
call as never
)
).resolves.toBeNull()
expect(call).toHaveBeenCalledOnce()
})
})

View File

@ -0,0 +1,558 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toRemoteRuntimePtyId } from './runtime-terminal-stream'
import {
clearWebSessionTerminalOrphanRecoveryForTests,
recoverWebSessionTerminalOrphansBeforeApply
} from './web-session-terminal-orphan-recovery'
describe('web session terminal orphan recovery', () => {
beforeEach(() => clearWebSessionTerminalOrphanRecoveryForTests())
it('keeps a missing mirror pending until exact live orphan adoption returns', async () => {
let resolveAdoption: ((value: never) => void) | null = null
const adoptedSnapshot = {
worktree: 'repo::C:\\worktree',
publicationEpoch: 'adopted',
snapshotVersion: 2,
activeGroupId: 'group-1',
activeTabId: 'host-tab::leaf-1',
activeTabType: 'terminal' as const,
tabs: [
{
type: 'terminal' as const,
id: 'host-tab::leaf-1',
parentTabId: 'host-tab',
leafId: 'leaf-1',
title: 'Claude',
isActive: true,
status: 'ready' as const,
terminal: 'term_live'
}
]
}
const call = vi.fn(async ({ method }) => {
if (method === 'terminal.list') {
return {
ok: true as const,
result: {
terminals: [
{
handle: 'term_live',
ptyId: 'native-pty',
incarnationId: 'inc-1',
orphaned: true,
worktreeId: adoptedSnapshot.worktree
}
],
topologyRevisions: { [adoptedSnapshot.worktree]: 0 },
totalCount: 1,
truncated: false
}
}
}
return await new Promise((resolve) => {
resolveAdoption = resolve as (value: never) => void
})
})
const state = {
tabsByWorktree: {
[adoptedSnapshot.worktree]: [
{ id: 'web-terminal-host-tab', worktreeId: adoptedSnapshot.worktree } as never
]
},
terminalLayoutsByTabId: {
'web-terminal-host-tab': {
root: { type: 'leaf' as const, leafId: 'leaf-1' },
activeLeafId: 'leaf-1',
expandedLeafId: null,
ptyIdsByLeafId: { 'leaf-1': toRemoteRuntimePtyId('term_live', 'windows-2') }
}
},
activeTabIdByWorktree: { [adoptedSnapshot.worktree]: 'web-terminal-host-tab' },
activeGroupIdByWorktree: { [adoptedSnapshot.worktree]: 'group-1' },
groupsByWorktree: {
[adoptedSnapshot.worktree]: [
{
id: 'group-1',
worktreeId: adoptedSnapshot.worktree,
activeTabId: 'web-terminal-host-tab',
tabOrder: ['web-terminal-host-tab']
}
]
},
layoutByWorktree: {
[adoptedSnapshot.worktree]: { type: 'leaf' as const, groupId: 'group-1' }
}
}
const missingSnapshot = { ...adoptedSnapshot, publicationEpoch: 'missing', tabs: [] }
let settled = false
const recovery = recoverWebSessionTerminalOrphansBeforeApply(
state,
missingSnapshot,
'windows-2',
call as never
).then((result) => {
settled = true
return result
})
await vi.waitFor(() => expect(resolveAdoption).not.toBeNull())
expect(settled).toBe(false)
expect(call).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
method: 'terminal.list',
params: expect.objectContaining({ handles: ['term_live'] })
})
)
resolveAdoption!({
ok: true,
result: { adopted: true, topologyRevision: 1, snapshot: adoptedSnapshot }
} as never)
await expect(recovery).resolves.toEqual(adoptedSnapshot)
expect(call).toHaveBeenLastCalledWith(
expect.objectContaining({
method: 'terminal.adoptOrphans',
params: expect.objectContaining({
expectedTopologyRevision: 0,
activeTabId: 'host-tab',
claims: [
expect.objectContaining({
terminal: 'term_live',
ptyId: 'native-pty',
incarnationId: 'inc-1',
tabId: 'host-tab',
leafId: 'leaf-1'
})
],
topology: {
tabs: [
{
tabId: 'host-tab',
root: { type: 'leaf', leafId: 'leaf-1' },
activeLeafId: 'leaf-1',
expandedLeafId: null
}
],
groups: [{ id: 'group-1', activeTabId: 'host-tab', tabOrder: ['host-tab'] }],
groupLayout: { type: 'leaf', groupId: 'group-1' }
}
})
})
)
})
it('does not apply absence when an exact recoverable orphan cannot be adopted yet', async () => {
const worktree = 'repo::/worktree'
const call = vi.fn(async ({ method }) =>
method === 'terminal.list'
? {
ok: true as const,
result: {
terminals: [
{
handle: 'term_live',
ptyId: 'pty-live',
incarnationId: 'inc-live',
orphaned: true
}
],
topologyRevisions: { [worktree]: 4 },
totalCount: 1,
truncated: false
}
}
: { ok: false as const, error: { code: 'conflict', message: 'retry' } }
)
const state = {
tabsByWorktree: {
[worktree]: [{ id: 'web-terminal-host-tab', worktreeId: worktree } as never]
},
terminalLayoutsByTabId: {
'web-terminal-host-tab': {
root: { type: 'leaf' as const, leafId: 'leaf-1' },
activeLeafId: 'leaf-1',
expandedLeafId: null,
ptyIdsByLeafId: {
'leaf-1': toRemoteRuntimePtyId('term_live', 'windows-2')
}
}
},
activeTabIdByWorktree: {},
activeGroupIdByWorktree: {}
}
const missing = {
worktree,
publicationEpoch: 'missing',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
await expect(
recoverWebSessionTerminalOrphansBeforeApply(state, missing, 'windows-2', call as never)
).resolves.toBeNull()
})
it('proposes pruned pane and group topology using host tab identities', async () => {
const worktree = 'repo::/worktree'
const adoptedSnapshot = {
worktree,
publicationEpoch: 'adopted',
snapshotVersion: 2,
activeGroupId: 'group-right',
activeTabId: 'shell-tab',
activeTabType: 'terminal' as const,
tabs: []
}
const call = vi.fn(async ({ method }) =>
method === 'terminal.list'
? {
ok: true as const,
result: {
terminals: [
{
handle: 'term_agent',
ptyId: 'pty-agent',
incarnationId: 'inc-agent',
orphaned: true
},
{
handle: 'term_setup',
ptyId: 'pty-setup',
incarnationId: 'inc-setup',
orphaned: true
},
{
handle: 'term_shell',
ptyId: 'pty-shell',
incarnationId: 'inc-shell',
orphaned: true
}
],
topologyRevisions: { [worktree]: 8 },
totalCount: 3,
truncated: false
}
}
: {
ok: true as const,
result: { adopted: true, topologyRevision: 9, snapshot: adoptedSnapshot }
}
)
const state = {
tabsByWorktree: {
[worktree]: [
{ id: 'web-terminal-agent-tab', worktreeId: worktree } as never,
{ id: 'web-terminal-shell-tab', worktreeId: worktree } as never
]
},
terminalLayoutsByTabId: {
'web-terminal-agent-tab': {
root: {
type: 'split' as const,
direction: 'horizontal' as const,
ratio: 0.7,
first: { type: 'leaf' as const, leafId: 'leaf-agent' },
second: { type: 'leaf' as const, leafId: 'leaf-setup' }
},
activeLeafId: 'leaf-setup',
expandedLeafId: null,
ptyIdsByLeafId: {
'leaf-agent': toRemoteRuntimePtyId('term_agent', 'windows-2'),
'leaf-setup': toRemoteRuntimePtyId('term_setup', 'windows-2')
}
},
'web-terminal-shell-tab': {
root: { type: 'leaf' as const, leafId: 'leaf-shell' },
activeLeafId: 'leaf-shell',
expandedLeafId: 'leaf-shell',
ptyIdsByLeafId: {
'leaf-shell': toRemoteRuntimePtyId('term_shell', 'windows-2')
}
}
},
activeTabIdByWorktree: { [worktree]: 'web-terminal-shell-tab' },
activeGroupIdByWorktree: { [worktree]: 'group-right' },
groupsByWorktree: {
[worktree]: [
{
id: 'group-left',
worktreeId: worktree,
activeTabId: 'web-terminal-agent-tab',
tabOrder: ['web-terminal-agent-tab']
},
{
id: 'group-right',
worktreeId: worktree,
activeTabId: 'web-terminal-shell-tab',
tabOrder: ['web-terminal-shell-tab']
}
]
},
layoutByWorktree: {
[worktree]: {
type: 'split' as const,
direction: 'vertical' as const,
ratio: 0.6,
first: { type: 'leaf' as const, groupId: 'group-left' },
second: { type: 'leaf' as const, groupId: 'group-right' }
}
}
}
await expect(
recoverWebSessionTerminalOrphansBeforeApply(
state,
{ ...adoptedSnapshot, publicationEpoch: 'missing' },
'windows-2',
call as never
)
).resolves.toEqual(adoptedSnapshot)
expect(call).toHaveBeenLastCalledWith(
expect.objectContaining({
method: 'terminal.adoptOrphans',
params: expect.objectContaining({
expectedTopologyRevision: 8,
activeTabId: 'shell-tab',
activeGroupId: 'group-right',
topology: {
tabs: [
expect.objectContaining({
tabId: 'agent-tab',
root: expect.objectContaining({
type: 'split',
direction: 'horizontal',
ratio: 0.7
}),
activeLeafId: 'leaf-setup'
}),
expect.objectContaining({
tabId: 'shell-tab',
expandedLeafId: 'leaf-shell'
})
],
groups: [
expect.objectContaining({
id: 'group-left',
activeTabId: 'agent-tab',
tabOrder: ['agent-tab']
}),
expect.objectContaining({
id: 'group-right',
activeTabId: 'shell-tab',
tabOrder: ['shell-tab']
})
],
groupLayout: expect.objectContaining({
type: 'split',
direction: 'vertical',
ratio: 0.6
})
}
})
})
)
})
it('recovers a missing split leaf when another leaf in the same tab is already host-owned', async () => {
const worktree = 'repo::/worktree'
const hostSnapshot = {
worktree,
publicationEpoch: 'partial',
snapshotVersion: 2,
activeGroupId: 'group-1',
activeTabId: 'host-tab::leaf-owned',
activeTabType: 'terminal' as const,
tabs: [
{
type: 'terminal' as const,
id: 'host-tab::leaf-owned',
parentTabId: 'host-tab',
leafId: 'leaf-owned',
title: 'Shell',
isActive: true,
status: 'ready' as const,
terminal: 'term_owned'
}
]
}
const adoptedSnapshot = {
...hostSnapshot,
publicationEpoch: 'adopted',
snapshotVersion: 3
}
const call = vi.fn(async ({ method }) =>
method === 'terminal.list'
? {
ok: true as const,
result: {
terminals: [
{
handle: 'term_orphan',
ptyId: 'pty-orphan',
incarnationId: 'inc-orphan',
orphaned: true
}
],
topologyRevisions: { [worktree]: 2 },
totalCount: 1,
truncated: false
}
}
: {
ok: true as const,
result: { adopted: true, topologyRevision: 3, snapshot: adoptedSnapshot }
}
)
const state = {
tabsByWorktree: {
[worktree]: [{ id: 'web-terminal-host-tab', worktreeId: worktree } as never]
},
terminalLayoutsByTabId: {
'web-terminal-host-tab': {
root: {
type: 'split' as const,
direction: 'vertical' as const,
ratio: 0.5,
first: { type: 'leaf' as const, leafId: 'leaf-owned' },
second: { type: 'leaf' as const, leafId: 'leaf-orphan' }
},
activeLeafId: 'leaf-owned',
expandedLeafId: null,
ptyIdsByLeafId: {
'leaf-owned': toRemoteRuntimePtyId('term_owned', 'windows-2'),
'leaf-orphan': toRemoteRuntimePtyId('term_orphan', 'windows-2')
}
}
},
activeTabIdByWorktree: { [worktree]: 'web-terminal-host-tab' },
activeGroupIdByWorktree: { [worktree]: 'group-1' }
}
await expect(
recoverWebSessionTerminalOrphansBeforeApply(state, hostSnapshot, 'windows-2', call as never)
).resolves.toEqual(adoptedSnapshot)
expect(call).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
method: 'terminal.list',
params: expect.objectContaining({ handles: ['term_orphan'] })
})
)
expect(call).toHaveBeenLastCalledWith(
expect.objectContaining({
method: 'terminal.adoptOrphans',
params: expect.objectContaining({
claims: [
expect.objectContaining({
terminal: 'term_orphan',
tabId: 'host-tab',
leafId: 'leaf-orphan'
})
],
topology: expect.objectContaining({
tabs: [
expect.objectContaining({
tabId: 'host-tab',
root: { type: 'leaf', leafId: 'leaf-orphan' }
})
]
})
})
})
)
})
it('serializes a newer convergence snapshot after an in-flight adoption conflict', async () => {
const worktree = 'repo::/worktree'
let rejectFirstAdoption: (() => void) | null = null
const call = vi.fn(async ({ method }) => {
if (method === 'terminal.list') {
return {
ok: true as const,
result: {
terminals: [
{
handle: 'term_live',
ptyId: 'pty-live',
incarnationId: 'inc-live',
orphaned: true
}
],
topologyRevisions: { [worktree]: 1 },
totalCount: 1,
truncated: false
}
}
}
return await new Promise((resolve) => {
rejectFirstAdoption = () =>
resolve({ ok: false as const, error: { code: 'conflict', message: 'closed' } })
})
})
const state = {
tabsByWorktree: {
[worktree]: [{ id: 'web-terminal-host-tab', worktreeId: worktree } as never]
},
terminalLayoutsByTabId: {
'web-terminal-host-tab': {
root: { type: 'leaf' as const, leafId: 'leaf-1' },
activeLeafId: 'leaf-1',
expandedLeafId: null,
ptyIdsByLeafId: {
'leaf-1': toRemoteRuntimePtyId('term_live', 'windows-2')
}
}
},
activeTabIdByWorktree: {},
activeGroupIdByWorktree: {}
}
const missing = {
worktree,
publicationEpoch: 'missing',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
const converged = {
...missing,
publicationEpoch: 'closed',
snapshotVersion: 2,
tabs: [
{
type: 'terminal' as const,
id: 'host-tab::leaf-1',
parentTabId: 'host-tab',
leafId: 'leaf-1',
title: 'closed',
isActive: false,
status: 'pending-handle' as const,
terminal: null
}
]
}
const first = recoverWebSessionTerminalOrphansBeforeApply(
state,
missing,
'windows-2',
call as never
)
await vi.waitFor(() => expect(rejectFirstAdoption).not.toBeNull())
const second = recoverWebSessionTerminalOrphansBeforeApply(
state,
converged,
'windows-2',
call as never
)
rejectFirstAdoption!()
await expect(first).resolves.toBeNull()
await expect(second).resolves.toEqual(converged)
})
})

View File

@ -0,0 +1,203 @@
import type {
RuntimeMobileSessionTabsResult,
RuntimeTerminalListResult,
RuntimeTerminalOrphanAdoptionResult
} from '../../../shared/runtime-types'
import type { TerminalTab } from '../../../shared/types'
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import { parseRemoteRuntimePtyId } from './runtime-terminal-stream'
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
import { isWebTerminalSurfaceTabId, toHostSessionTabId } from './web-terminal-surface-id'
import {
buildWebTerminalOrphanTopologyProposal,
type WebTerminalOrphanTopologyState
} from './web-session-terminal-orphan-topology'
type TerminalOrphanRecoveryState = WebTerminalOrphanTopologyState & {
tabsByWorktree: Record<string, TerminalTab[]>
}
type RuntimeCall = (args: {
selector: string
method: string
params: unknown
timeoutMs: number
}) => Promise<RuntimeRpcResponse<unknown>>
const inFlightRecoveryByWorktree = new Map<string, Promise<RuntimeMobileSessionTabsResult | null>>()
function recoveryKey(environmentId: string, worktreeId: string): string {
return `${environmentId}\0${worktreeId}`
}
function isTerminalListResult(value: unknown): value is RuntimeTerminalListResult {
return (
Boolean(value) &&
typeof value === 'object' &&
Array.isArray((value as { terminals?: unknown }).terminals)
)
}
function isAdoptionResult(value: unknown): value is RuntimeTerminalOrphanAdoptionResult {
return (
Boolean(value) &&
typeof value === 'object' &&
Boolean((value as { snapshot?: unknown }).snapshot) &&
Array.isArray((value as { snapshot?: { tabs?: unknown } }).snapshot?.tabs)
)
}
async function recoverTerminalOrphans(
state: TerminalOrphanRecoveryState,
snapshot: RuntimeMobileSessionTabsResult,
environmentId: string,
call: RuntimeCall
): Promise<RuntimeMobileSessionTabsResult | null> {
const hostSurfaceKeys = new Set(
snapshot.tabs
.filter((tab) => tab.type === 'terminal')
.map((tab) => `${tab.parentTabId}\0${tab.leafId}`)
)
const candidates = (state.tabsByWorktree[snapshot.worktree] ?? []).filter(
(tab) =>
isWebTerminalSurfaceTabId(tab.id) &&
Object.keys(state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}).some(
(leafId) => !hostSurfaceKeys.has(`${toHostSessionTabId(tab.id)}\0${leafId}`)
)
)
if (candidates.length === 0) {
return snapshot
}
const candidateSurfaces = candidates.flatMap((tab) => {
const layout = state.terminalLayoutsByTabId[tab.id]
return Object.entries(layout?.ptyIdsByLeafId ?? {}).flatMap(([leafId, remotePtyId]) => {
const remote = parseRemoteRuntimePtyId(remotePtyId)
return remote?.environmentId === environmentId &&
!hostSurfaceKeys.has(`${toHostSessionTabId(tab.id)}\0${leafId}`)
? [{ tabId: toHostSessionTabId(tab.id), leafId, handle: remote.handle }]
: []
})
})
const candidateHandles = new Set(candidateSurfaces.map((surface) => surface.handle))
if (candidateHandles.size === 0) {
return snapshot
}
if (candidateHandles.size > 64) {
return null
}
const listedResponse = await call({
selector: environmentId,
method: 'terminal.list',
params: {
worktree: toRuntimeWorktreeSelector(snapshot.worktree),
handles: [...candidateHandles],
requireFreshPtyLiveness: true
},
timeoutMs: 15_000
})
if (listedResponse.ok === false || !isTerminalListResult(listedResponse.result)) {
return null
}
const listed = listedResponse.result
const orphanByHandle = new Map(
listed.terminals
.filter(
(terminal) =>
terminal.orphaned === true &&
typeof terminal.ptyId === 'string' &&
typeof terminal.incarnationId === 'string'
)
.map((terminal) => [terminal.handle, terminal])
)
const claims = candidateSurfaces.flatMap(({ tabId, leafId, handle }) => {
const orphan = orphanByHandle.get(handle)
if (!orphan?.ptyId || !orphan.incarnationId) {
return []
}
return [
{
terminal: orphan.handle,
ptyId: orphan.ptyId,
incarnationId: orphan.incarnationId,
tabId,
leafId
}
]
})
const claimedHandles = new Set(claims.map((claim) => claim.terminal))
const listedCandidateHandles = new Set(
listed.terminals
.filter((terminal) => candidateHandles.has(terminal.handle))
.map((terminal) => terminal.handle)
)
if (
listed.truncated &&
[...candidateHandles].some((handle) => !listedCandidateHandles.has(handle))
) {
return null
}
const hasUnresolvedLiveCandidate = listed.terminals.some(
(terminal) => candidateHandles.has(terminal.handle) && !claimedHandles.has(terminal.handle)
)
if (hasUnresolvedLiveCandidate) {
return null
}
if (claims.length === 0) {
return snapshot
}
const localActiveTabId = state.activeTabIdByWorktree[snapshot.worktree]
const activeTabId =
localActiveTabId && isWebTerminalSurfaceTabId(localActiveTabId)
? toHostSessionTabId(localActiveTabId)
: undefined
const activeGroupId = state.activeGroupIdByWorktree[snapshot.worktree] ?? undefined
const topology = buildWebTerminalOrphanTopologyProposal(
state,
snapshot.worktree,
candidates,
claims
)
const response = await call({
selector: environmentId,
method: 'terminal.adoptOrphans',
params: {
worktree: toRuntimeWorktreeSelector(snapshot.worktree),
expectedTopologyRevision: listed.topologyRevisions?.[snapshot.worktree] ?? 0,
claims,
...(activeTabId ? { activeTabId } : {}),
...(activeGroupId ? { activeGroupId } : {}),
...(topology ? { topology } : {})
},
timeoutMs: 15_000
})
return response.ok !== false &&
isAdoptionResult(response.result) &&
response.result.snapshot.worktree === snapshot.worktree
? response.result.snapshot
: null
}
export function recoverWebSessionTerminalOrphansBeforeApply(
state: TerminalOrphanRecoveryState,
snapshot: RuntimeMobileSessionTabsResult,
environmentId: string,
call: RuntimeCall = (args) => window.api.runtimeEnvironments.call(args)
): Promise<RuntimeMobileSessionTabsResult | null> {
const key = recoveryKey(environmentId, snapshot.worktree)
const existing = inFlightRecoveryByWorktree.get(key)
const recovery = (existing ?? Promise.resolve(null))
.catch(() => null)
.then(() => recoverTerminalOrphans(state, snapshot, environmentId, call))
.catch(() => null)
.finally(() => {
if (inFlightRecoveryByWorktree.get(key) === recovery) {
inFlightRecoveryByWorktree.delete(key)
}
})
inFlightRecoveryByWorktree.set(key, recovery)
return recovery
}
export function clearWebSessionTerminalOrphanRecoveryForTests(): void {
inFlightRecoveryByWorktree.clear()
}

View File

@ -0,0 +1,140 @@
import type { RuntimeTerminalOrphanTopology } from '../../../shared/runtime-types'
import type {
TabGroup,
TabGroupLayoutNode,
TerminalLayoutSnapshot,
TerminalPaneLayoutNode,
TerminalTab
} from '../../../shared/types'
import { toHostSessionTabId } from './web-terminal-surface-id'
export type WebTerminalOrphanTopologyState = {
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
activeTabIdByWorktree: Record<string, string | null | undefined>
activeGroupIdByWorktree: Record<string, string | null | undefined>
groupsByWorktree?: Record<string, TabGroup[] | undefined>
layoutByWorktree?: Record<string, TabGroupLayoutNode | undefined>
}
function prunePaneLayout(
node: TerminalPaneLayoutNode | null,
retainedLeafIds: ReadonlySet<string>
): TerminalPaneLayoutNode | null {
if (!node) {
return null
}
if (node.type === 'leaf') {
return retainedLeafIds.has(node.leafId) ? node : null
}
const first = prunePaneLayout(node.first, retainedLeafIds)
const second = prunePaneLayout(node.second, retainedLeafIds)
if (!first) {
return second
}
if (!second) {
return first
}
return { ...node, first, second }
}
function pruneGroupLayout(
node: TabGroupLayoutNode | undefined,
retainedGroupIds: ReadonlySet<string>
): TabGroupLayoutNode | undefined {
if (!node) {
return undefined
}
if (node.type === 'leaf') {
return retainedGroupIds.has(node.groupId) ? node : undefined
}
const first = pruneGroupLayout(node.first, retainedGroupIds)
const second = pruneGroupLayout(node.second, retainedGroupIds)
if (!first) {
return second
}
if (!second) {
return first
}
return { ...node, first, second }
}
export function buildWebTerminalOrphanTopologyProposal(
state: WebTerminalOrphanTopologyState,
worktreeId: string,
candidates: readonly TerminalTab[],
claims: readonly { tabId: string; leafId: string }[]
): RuntimeTerminalOrphanTopology | undefined {
const leafIdsByTabId = new Map<string, Set<string>>()
for (const claim of claims) {
const leafIds = leafIdsByTabId.get(claim.tabId) ?? new Set<string>()
leafIds.add(claim.leafId)
leafIdsByTabId.set(claim.tabId, leafIds)
}
const hostTabIdByLocalId = new Map(
candidates.map((tab) => [tab.id, toHostSessionTabId(tab.id)] as const)
)
const tabs = candidates.flatMap((tab) => {
const tabId = hostTabIdByLocalId.get(tab.id)!
const retainedLeafIds = leafIdsByTabId.get(tabId)
const layout = state.terminalLayoutsByTabId[tab.id]
const root = retainedLeafIds ? prunePaneLayout(layout?.root ?? null, retainedLeafIds) : null
if (!layout || !root || !retainedLeafIds || retainedLeafIds.size === 0) {
return []
}
const fallbackLeafId = [...retainedLeafIds][0]!
return [
{
tabId,
root,
activeLeafId: retainedLeafIds.has(layout.activeLeafId ?? '')
? layout.activeLeafId!
: fallbackLeafId,
expandedLeafId:
layout.expandedLeafId && retainedLeafIds.has(layout.expandedLeafId)
? layout.expandedLeafId
: null
}
]
})
if (tabs.length !== leafIdsByTabId.size) {
return undefined
}
const adoptedTabIds = new Set(tabs.map((tab) => tab.tabId))
const groups = (state.groupsByWorktree?.[worktreeId] ?? []).flatMap((group) => {
const tabOrder = group.tabOrder
.map((tabId) => hostTabIdByLocalId.get(tabId))
.filter((tabId): tabId is string => Boolean(tabId && adoptedTabIds.has(tabId)))
if (tabOrder.length === 0) {
return []
}
const requestedActive = group.activeTabId
? hostTabIdByLocalId.get(group.activeTabId)
: undefined
const recentTabIds = group.recentTabIds
?.map((tabId) => hostTabIdByLocalId.get(tabId))
.filter((tabId): tabId is string => Boolean(tabId && tabOrder.includes(tabId)))
return [
{
id: group.id,
activeTabId:
requestedActive && tabOrder.includes(requestedActive) ? requestedActive : tabOrder[0]!,
tabOrder,
...(recentTabIds && recentTabIds.length > 0 ? { recentTabIds } : {})
}
]
})
const completeGroups =
groups.length > 0
? groups
: [
{
id: state.activeGroupIdByWorktree[worktreeId] ?? 'recovered-orphans',
activeTabId: tabs[0]!.tabId,
tabOrder: tabs.map((tab) => tab.tabId)
}
]
const groupIds = new Set(completeGroups.map((group) => group.id))
const groupLayout = pruneGroupLayout(state.layoutByWorktree?.[worktreeId], groupIds)
return { tabs, groups: completeGroups, ...(groupLayout ? { groupLayout } : {}) }
}

View File

@ -1,7 +1,7 @@
/* eslint-disable max-lines -- Why: browser slice behavior shares one mocked store harness; splitting only the tests would duplicate more setup than it saves. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { create } from 'zustand'
import { createBrowserSlice } from './browser'
import { createBrowserSlice, isLocalBrowserPageOwner } from './browser'
import type { AppState } from '../types'
import {
createCompatibleRuntimeStatusResponseIfNeeded,
@ -602,6 +602,159 @@ describe('createBrowserSlice runtime guard', () => {
expect(store.getState().browserSessionProfiles[0]?.id).toBe('local-default')
})
it('routes browser settings per client without changing the durable Active Server', async () => {
runtimeEnvironmentCall.mockImplementation((request: RuntimeEnvironmentCallRequest) => {
const { selector, method } = request as RuntimeEnvironmentCallRequest & { selector: string }
return Promise.resolve({
id: `${selector}-${method}`,
ok: true,
result:
method === 'browser.profileList'
? {
profiles: [
{
id: `${selector}-default`,
scope: 'default',
partition: `persist:${selector}`,
label: `${selector} Default`,
source: null
}
]
}
: { browsers: [] },
_meta: { runtimeId: `runtime-${selector}` }
})
})
const firstClient = createTestStore()
const secondClient = createTestStore()
void firstClient.getState().setBrowserSessionHostId('runtime:windows-2')
void secondClient.getState().setBrowserSessionHostId('runtime:linux-3')
await vi.waitFor(() => {
expect(firstClient.getState().browserSessionProfiles[0]?.id).toBe('windows-2-default')
expect(secondClient.getState().browserSessionProfiles[0]?.id).toBe('linux-3-default')
})
expect(firstClient.getState().settings?.activeRuntimeEnvironmentId).toBeNull()
expect(secondClient.getState().settings?.activeRuntimeEnvironmentId).toBeNull()
const restartedClient = createTestStore()
expect(restartedClient.getState().browserSessionHostIdOverride).toBeNull()
expect(restartedClient.getState().settings?.activeRuntimeEnvironmentId).toBeNull()
})
it('does not let a slower server response overwrite the newly selected host', async () => {
let resolveWindowsProfiles: ((value: unknown) => void) | undefined
runtimeEnvironmentCall.mockImplementation((request: RuntimeEnvironmentCallRequest) => {
const { selector, method } = request as RuntimeEnvironmentCallRequest & {
selector: string
}
if (method !== 'browser.profileList') {
return Promise.resolve({
id: `${selector}-${method}`,
ok: true,
result: { browsers: [] },
_meta: { runtimeId: `runtime-${selector}` }
})
}
if (selector === 'windows-2') {
return new Promise((resolve) => {
resolveWindowsProfiles = resolve
})
}
return Promise.resolve({
id: 'linux-profiles',
ok: true,
result: {
profiles: [
{
id: 'linux-default',
scope: 'default',
partition: 'persist:linux',
label: 'Linux Default',
source: null
}
]
},
_meta: { runtimeId: 'runtime-linux' }
})
})
const store = createTestStore()
void store.getState().setBrowserSessionHostId('runtime:windows-2')
void store.getState().setBrowserSessionHostId('runtime:linux-3')
await vi.waitFor(() =>
expect(store.getState().browserSessionProfiles[0]?.id).toBe('linux-default')
)
resolveWindowsProfiles?.({
id: 'windows-profiles',
ok: true,
result: {
profiles: [
{
id: 'windows-default',
scope: 'default',
partition: 'persist:windows',
label: 'Windows Default',
source: null
}
]
},
_meta: { runtimeId: 'runtime-windows' }
})
await vi.waitFor(() =>
expect(store.getState().browserSessionProfilesByHostId['runtime:windows-2']?.[0]?.id).toBe(
'windows-default'
)
)
expect(store.getState().browserSessionHostIdOverride).toBe('runtime:linux-3')
expect(store.getState().browserSessionProfiles[0]?.id).toBe('linux-default')
expect(store.getState().settings?.activeRuntimeEnvironmentId).toBeNull()
})
it('does not let an import completion refresh or overwrite a newly selected host', async () => {
let resolveImport: ((value: unknown) => void) | undefined
runtimeEnvironmentCall.mockImplementation((request: RuntimeEnvironmentCallRequest) => {
const { selector, method } = request as RuntimeEnvironmentCallRequest & { selector: string }
if (selector === 'windows-2' && method === 'browser.profileImportFromBrowser') {
return new Promise((resolve) => {
resolveImport = resolve
})
}
return Promise.resolve({
id: `${selector}-${method}`,
ok: true,
result: method === 'browser.profileList' ? { profiles: [] } : { browsers: [] },
_meta: { runtimeId: `runtime-${selector}` }
})
})
const store = createTestStore()
store.setState({ browserSessionHostIdOverride: 'runtime:windows-2' })
const importing = store
.getState()
.importCookiesFromBrowser('windows-profile', 'chrome', 'Default')
await vi.waitFor(() => expect(resolveImport).toBeDefined())
await store.getState().setBrowserSessionHostId('runtime:linux-3')
const callsBeforeCompletion = runtimeEnvironmentCall.mock.calls.length
resolveImport?.({
id: 'windows-import',
ok: true,
result: {
ok: true,
profileId: 'windows-profile',
summary: { totalCookies: 2, importedCookies: 2, skippedCookies: 0, domains: [] }
},
_meta: { runtimeId: 'runtime-windows' }
})
await expect(importing).resolves.toMatchObject({ ok: true, profileId: 'windows-profile' })
expect(store.getState().browserSessionHostIdOverride).toBe('runtime:linux-3')
expect(store.getState().browserSessionImportState).toBeNull()
expect(runtimeEnvironmentCall.mock.calls).toHaveLength(callsBeforeCompletion)
})
it('uses the target worktree host default profile when creating a browser tab', () => {
const store = createTestStore()
store.setState({
@ -652,6 +805,42 @@ describe('createBrowserSlice runtime guard', () => {
expect(tab.sessionProfileId).toBe('remote-default')
})
it('routes browser bridge ownership from the workspace instead of Active Server', () => {
const store = createTestStore()
store.setState({
settings: { activeRuntimeEnvironmentId: 'windows-2' } as AppState['settings'],
repos: [
{
id: 'local-repo',
path: '/local',
displayName: 'Local',
badgeColor: '#000000',
addedAt: 1,
connectionId: null,
executionHostId: 'local'
},
{
id: 'remote-repo',
path: '/remote',
displayName: 'Remote',
badgeColor: '#000000',
addedAt: 2,
connectionId: null,
executionHostId: 'runtime:windows-2'
}
],
worktreesByRepo: {
'local-repo': [{ id: 'local-wt', repoId: 'local-repo' }] as never,
'remote-repo': [{ id: 'remote-wt', repoId: 'remote-repo' }] as never
}
})
expect(isLocalBrowserPageOwner(store.getState(), 'local-wt', undefined)).toBe(true)
expect(isLocalBrowserPageOwner(store.getState(), 'remote-wt', undefined)).toBe(false)
expect(isLocalBrowserPageOwner(store.getState(), 'local-wt', 'windows-2')).toBe(false)
expect(isLocalBrowserPageOwner(store.getState(), 'remote-wt', null)).toBe(true)
})
it('stores a runtime-resolved browser partition without a renderer profile mirror', () => {
const store = createTestStore()
store.setState({ browserSessionProfiles: [] })

View File

@ -25,11 +25,7 @@ import {
import { pickNeighbor } from './tab-group-state'
import { destroyWorkspaceWebviews } from './browser-webview-cleanup'
import { pushRecentlyClosedTabKind } from './recently-closed-tabs'
import {
callRuntimeRpc,
getActiveRuntimeTarget,
type RuntimeClientTarget
} from '@/runtime/runtime-rpc-client'
import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
import type {
BrowserDetectProfilesResult,
@ -44,6 +40,7 @@ import { translate } from '@/i18n/i18n'
import {
getSettingsFocusedExecutionHostId,
LOCAL_EXECUTION_HOST_ID,
parseExecutionHostId,
toRuntimeExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
@ -176,6 +173,8 @@ export type BrowserSlice = {
) => void
browserSessionProfiles: BrowserSessionProfile[]
browserSessionProfilesByHostId: Partial<Record<ExecutionHostId, BrowserSessionProfile[]>>
browserSessionHostIdOverride: ExecutionHostId | null
setBrowserSessionHostId: (hostId: ExecutionHostId) => Promise<void>
browserSessionImportState: {
profileId: string
status: 'idle' | 'importing' | 'success' | 'error'
@ -235,12 +234,17 @@ function normalizeBrowserTitle(title: string | null | undefined, url: string): s
return title
}
function isRuntimeEnvironmentActive(state: AppState): boolean {
return Boolean(state.settings?.activeRuntimeEnvironmentId?.trim())
function getBrowserSettingsHostId(
state: Pick<AppState, 'browserSessionHostIdOverride' | 'settings'>
): ExecutionHostId {
return state.browserSessionHostIdOverride ?? getSettingsFocusedExecutionHostId(state.settings)
}
function getBrowserSettingsHostId(state: Pick<AppState, 'settings'>): ExecutionHostId {
return getSettingsFocusedExecutionHostId(state.settings)
function getBrowserSettingsRuntimeEnvironmentId(
state: Pick<AppState, 'browserSessionHostIdOverride' | 'settings'>
): string | null {
const parsed = parseExecutionHostId(getBrowserSettingsHostId(state))
return parsed?.kind === 'runtime' ? parsed.environmentId : null
}
function getBrowserWorktreeHostId(state: AppState, worktreeId: string): ExecutionHostId {
@ -264,19 +268,60 @@ function getBrowserSessionProfileHostId(
return getBrowserWorktreeHostId(state, worktreeId)
}
export function isLocalBrowserPageOwner(
state: AppState,
worktreeId: string,
browserRuntimeEnvironmentId: string | null | undefined
): boolean {
return (
parseExecutionHostId(
getBrowserSessionProfileHostId(state, worktreeId, browserRuntimeEnvironmentId)
)?.kind !== 'runtime'
)
}
function profileListByHostUpdate(
state: Pick<AppState, 'browserSessionProfilesByHostId' | 'settings'>,
profiles: BrowserSessionProfile[]
state: Pick<
AppState,
'browserSessionHostIdOverride' | 'browserSessionProfilesByHostId' | 'settings'
>,
profiles: BrowserSessionProfile[],
hostId: ExecutionHostId = getBrowserSettingsHostId(state)
): Partial<BrowserSlice> {
return {
browserSessionProfiles: profiles,
...(getBrowserSettingsHostId(state) === hostId ? { browserSessionProfiles: profiles } : {}),
browserSessionProfilesByHostId: {
...state.browserSessionProfilesByHostId,
[getBrowserSettingsHostId(state)]: profiles
[hostId]: profiles
}
}
}
function getBrowserProfilesForHost(
state: AppState,
hostId: ExecutionHostId
): BrowserSessionProfile[] {
return (
state.browserSessionProfilesByHostId[hostId] ??
(getBrowserSettingsHostId(state) === hostId ? state.browserSessionProfiles : [])
)
}
function getDefaultBrowserProfileForHost(state: AppState, hostId: ExecutionHostId): string | null {
return (
state.defaultBrowserSessionProfileIdByHostId[hostId] ??
(getBrowserSettingsHostId(state) === hostId ? state.defaultBrowserSessionProfileId : null)
)
}
function browserImportStateForHostUpdate(
state: AppState,
hostId: ExecutionHostId,
browserSessionImportState: BrowserSlice['browserSessionImportState']
): Partial<BrowserSlice> {
return getBrowserSettingsHostId(state) === hostId ? { browserSessionImportState } : {}
}
function closeRemoteBrowserPageInOwningEnvironment(
worktreeId: string,
handle: RemoteBrowserPageHandle
@ -468,11 +513,29 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
pendingAddressBarFocusByPageId: {},
browserSessionProfiles: [],
browserSessionProfilesByHostId: {},
browserSessionHostIdOverride: null,
browserSessionImportState: null,
browserUrlHistory: [],
defaultBrowserSessionProfileId: null,
defaultBrowserSessionProfileIdByHostId: {},
setBrowserSessionHostId: async (hostId) => {
const parsed = parseExecutionHostId(hostId)
if (parsed?.kind !== 'local' && parsed?.kind !== 'runtime') {
return
}
const nextHostId = parsed.id
set((s) => ({
browserSessionHostIdOverride: nextHostId,
browserSessionProfiles: s.browserSessionProfilesByHostId[nextHostId] ?? [],
defaultBrowserSessionProfileId: s.defaultBrowserSessionProfileIdByHostId[nextHostId] ?? null,
browserSessionImportState: null,
detectedBrowsers: [],
detectedBrowsersLoaded: false
}))
await Promise.all([get().fetchBrowserSessionProfiles(), get().fetchDetectedBrowsers()])
},
setDefaultBrowserSessionProfileId: (profileId) => {
set((s) => ({
defaultBrowserSessionProfileId: profileId,
@ -885,9 +948,18 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
// Why: notify the CDP bridge of the active guest; it keys on page IDs not workspace IDs, so resolve the workspace's active page.
const workspace = findWorkspace(get().browserTabsByWorktree, tabId)
const activePage = workspace?.activePageId
? (get().browserPagesByWorkspace[workspace.id] ?? []).find(
(page) => page.id === workspace.activePageId
)
: undefined
if (
workspace?.activePageId &&
!isRuntimeEnvironmentActive(get()) &&
isLocalBrowserPageOwner(
get(),
workspace.worktreeId,
activePage?.browserRuntimeEnvironmentId
) &&
typeof window !== 'undefined' &&
window.api?.browser
) {
@ -1128,15 +1200,22 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
})
// Why: switching the active page changes which guest webContents the CDP bridge targets for agent commands.
const activePage = (get().browserPagesByWorkspace[workspaceId] ?? []).find(
(page) => page.id === pageId
)
const workspace = findWorkspace(get().browserTabsByWorktree, workspaceId)
if (
!isRuntimeEnvironmentActive(get()) &&
workspace &&
isLocalBrowserPageOwner(
get(),
workspace.worktreeId,
activePage?.browserRuntimeEnvironmentId
) &&
typeof window !== 'undefined' &&
window.api?.browser
) {
window.api.browser.notifyActiveTabChanged({ browserPageId: pageId }).catch(() => {})
}
const workspace = findWorkspace(get().browserTabsByWorktree, workspaceId)
if (!workspace) {
return
}
@ -1189,8 +1268,9 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
})
// Why: notify the CDP bridge which guest webContents is active so agent commands target the correct page.
const focusedPage = pages.find((page) => page.id === browserPageId)
if (
!isRuntimeEnvironmentActive(get()) &&
isLocalBrowserPageOwner(get(), worktreeId, focusedPage?.browserRuntimeEnvironmentId) &&
typeof window !== 'undefined' &&
window.api?.browser
) {
@ -1711,33 +1791,37 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
},
fetchBrowserSessionProfiles: async () => {
if (isRuntimeEnvironmentActive(get())) {
const hostId = getBrowserSettingsHostId(get())
const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get())
if (runtimeEnvironmentId) {
try {
const result = await callRuntimeRpc<BrowserProfileListResult>(
getActiveRuntimeTarget(get().settings),
{ kind: 'environment', environmentId: runtimeEnvironmentId },
'browser.profileList',
undefined,
{ timeoutMs: 15_000 }
)
set((s) => profileListByHostUpdate(s, result.profiles))
set((s) => profileListByHostUpdate(s, result.profiles, hostId))
} catch {
set((s) => profileListByHostUpdate(s, []))
set((s) => profileListByHostUpdate(s, [], hostId))
}
return
}
try {
const profiles = (await window.api.browser.sessionListProfiles()) as BrowserSessionProfile[]
set((s) => profileListByHostUpdate(s, profiles))
set((s) => profileListByHostUpdate(s, profiles, hostId))
} catch {
/* best-effort — stale profile list is preferable to a crash */
}
},
createBrowserSessionProfile: async (scope, label) => {
if (isRuntimeEnvironmentActive(get())) {
const hostId = getBrowserSettingsHostId(get())
const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get())
if (runtimeEnvironmentId) {
try {
const result = await callRuntimeRpc<BrowserProfileCreateResult>(
getActiveRuntimeTarget(get().settings),
{ kind: 'environment', environmentId: runtimeEnvironmentId },
'browser.profileCreate',
{ scope, label },
{ timeoutMs: 15_000 }
@ -1745,7 +1829,11 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
const profile = result.profile
if (profile) {
set((s) => ({
...profileListByHostUpdate(s, [...s.browserSessionProfiles, profile])
...profileListByHostUpdate(
s,
[...getBrowserProfilesForHost(s, hostId), profile],
hostId
)
}))
}
return profile
@ -1760,7 +1848,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
})) as BrowserSessionProfile | null
if (profile) {
set((s) => ({
...profileListByHostUpdate(s, [...s.browserSessionProfiles, profile])
...profileListByHostUpdate(s, [...getBrowserProfilesForHost(s, hostId), profile], hostId)
}))
}
return profile
@ -1770,10 +1858,12 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
},
deleteBrowserSessionProfile: async (profileId) => {
if (isRuntimeEnvironmentActive(get())) {
const hostId = getBrowserSettingsHostId(get())
const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get())
if (runtimeEnvironmentId) {
try {
const result = await callRuntimeRpc<BrowserProfileDeleteResult>(
getActiveRuntimeTarget(get().settings),
{ kind: 'environment', environmentId: runtimeEnvironmentId },
'browser.profileDelete',
{ profileId },
{ timeoutMs: 15_000 }
@ -1782,14 +1872,17 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
set((s) => ({
...profileListByHostUpdate(
s,
s.browserSessionProfiles.filter((p) => p.id !== profileId)
getBrowserProfilesForHost(s, hostId).filter((profile) => profile.id !== profileId),
hostId
),
...(s.defaultBrowserSessionProfileId === profileId
...(getDefaultBrowserProfileForHost(s, hostId) === profileId
? {
defaultBrowserSessionProfileId: null,
...(getBrowserSettingsHostId(s) === hostId
? { defaultBrowserSessionProfileId: null }
: {}),
defaultBrowserSessionProfileIdByHostId: {
...s.defaultBrowserSessionProfileIdByHostId,
[getBrowserSettingsHostId(s)]: null
[hostId]: null
}
}
: {})
@ -1806,14 +1899,17 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
set((s) => ({
...profileListByHostUpdate(
s,
s.browserSessionProfiles.filter((p) => p.id !== profileId)
getBrowserProfilesForHost(s, hostId).filter((profile) => profile.id !== profileId),
hostId
),
...(s.defaultBrowserSessionProfileId === profileId
...(getDefaultBrowserProfileForHost(s, hostId) === profileId
? {
defaultBrowserSessionProfileId: null,
...(getBrowserSettingsHostId(s) === hostId
? { defaultBrowserSessionProfileId: null }
: {}),
defaultBrowserSessionProfileIdByHostId: {
...s.defaultBrowserSessionProfileIdByHostId,
[getBrowserSettingsHostId(s)]: null
[hostId]: null
}
}
: {})
@ -1826,64 +1922,70 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
},
importCookiesToProfile: async (profileId) => {
if (isRuntimeEnvironmentActive(get())) {
const reason = 'Manual cookie file import is unavailable while a remote runtime is active.'
set({
browserSessionImportState: {
const hostId = getBrowserSettingsHostId(get())
if (getBrowserSettingsRuntimeEnvironmentId(get())) {
const reason = translate(
'auto.store.slices.browser.remoteCookieImportUnavailable',
'Manual cookie file import is unavailable while a remote runtime is active.'
)
set((state) =>
browserImportStateForHostUpdate(state, hostId, {
profileId,
status: 'error',
summary: null,
error: reason
}
})
})
)
return { ok: false as const, reason }
}
set({
browserSessionImportState: {
set((state) =>
browserImportStateForHostUpdate(state, hostId, {
profileId,
status: 'importing',
summary: null,
error: null
}
})
})
)
try {
const result = (await window.api.browser.sessionImportCookies({
profileId
})) as BrowserCookieImportResult
if (result.ok) {
get().recordFeatureInteraction?.('cookie-import')
set({
browserSessionImportState: {
set((state) =>
browserImportStateForHostUpdate(state, hostId, {
profileId,
status: 'success',
summary: result.summary,
error: null
}
})
await get()
.fetchBrowserSessionProfiles()
.catch(() => {})
})
)
if (getBrowserSettingsHostId(get()) === hostId) {
await get()
.fetchBrowserSessionProfiles()
.catch(() => {})
}
} else {
set({
browserSessionImportState: {
set((state) =>
browserImportStateForHostUpdate(state, hostId, {
profileId,
status: result.reason === 'canceled' ? 'idle' : 'error',
summary: null,
error: result.reason === 'canceled' ? null : result.reason
}
})
})
)
}
return result
} catch (err) {
const reason = String((err as Error)?.message ?? err)
set({
browserSessionImportState: {
set((state) =>
browserImportStateForHostUpdate(state, hostId, {
profileId,
status: 'error',
summary: null,
error: reason
}
})
})
)
return { ok: false as const, reason }
}
},
@ -1896,17 +1998,27 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
detectedBrowsersLoaded: false,
fetchDetectedBrowsers: async () => {
if (isRuntimeEnvironmentActive(get())) {
const hostId = getBrowserSettingsHostId(get())
const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get())
if (runtimeEnvironmentId) {
try {
const result = await callRuntimeRpc<BrowserDetectProfilesResult>(
getActiveRuntimeTarget(get().settings),
{ kind: 'environment', environmentId: runtimeEnvironmentId },
'browser.profileDetectBrowsers',
undefined,
{ timeoutMs: 15_000 }
)
set({ detectedBrowsers: result.browsers, detectedBrowsersLoaded: true })
set((s) =>
getBrowserSettingsHostId(s) === hostId
? { detectedBrowsers: result.browsers, detectedBrowsersLoaded: true }
: {}
)
} catch {
set({ detectedBrowsers: [], detectedBrowsersLoaded: true })
set((s) =>
getBrowserSettingsHostId(s) === hostId
? { detectedBrowsers: [], detectedBrowsersLoaded: true }
: {}
)
}
return
}
@ -1920,74 +2032,82 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
profiles: { name: string; directory: string }[]
selectedProfile: string
}[]
set({ detectedBrowsers: browsers, detectedBrowsersLoaded: true })
set((s) =>
getBrowserSettingsHostId(s) === hostId
? { detectedBrowsers: browsers, detectedBrowsersLoaded: true }
: {}
)
} catch {
/* best-effort — empty list is acceptable fallback */
set({ detectedBrowsersLoaded: true })
set((s) => (getBrowserSettingsHostId(s) === hostId ? { detectedBrowsersLoaded: true } : {}))
}
},
importCookiesFromBrowser: async (profileId, browserFamily, browserProfile?) => {
if (isRuntimeEnvironmentActive(get())) {
set({
browserSessionImportState: {
const hostId = getBrowserSettingsHostId(get())
const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get())
if (runtimeEnvironmentId) {
set((state) =>
browserImportStateForHostUpdate(state, hostId, {
profileId,
status: 'importing',
summary: null,
error: null
}
})
})
)
try {
const result = await callRuntimeRpc<BrowserProfileImportFromBrowserResult>(
getActiveRuntimeTarget(get().settings),
{ kind: 'environment', environmentId: runtimeEnvironmentId },
'browser.profileImportFromBrowser',
{ profileId, browserFamily, browserProfile },
{ timeoutMs: 30_000 }
)
if (result.ok) {
set({
browserSessionImportState: {
set((state) =>
browserImportStateForHostUpdate(state, hostId, {
profileId,
status: 'success',
summary: result.summary,
error: null
}
})
await get()
.fetchBrowserSessionProfiles()
.catch(() => {})
})
)
if (getBrowserSettingsHostId(get()) === hostId) {
await get()
.fetchBrowserSessionProfiles()
.catch(() => {})
}
} else {
set({
browserSessionImportState: {
set((state) =>
browserImportStateForHostUpdate(state, hostId, {
profileId,
status: 'error',
summary: null,
error: result.reason
}
})
})
)
}
return result
} catch (err) {
const reason = String((err as Error)?.message ?? err)
set({
browserSessionImportState: {
set((state) =>
browserImportStateForHostUpdate(state, hostId, {
profileId,
status: 'error',
summary: null,
error: reason
}
})
})
)
return { ok: false as const, reason }
}
}
set({
browserSessionImportState: {
set((state) =>
browserImportStateForHostUpdate(state, hostId, {
profileId,
status: 'importing',
summary: null,
error: null
}
})
})
)
try {
const result = (await window.api.browser.sessionImportFromBrowser({
profileId,
@ -1996,52 +2116,56 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
})) as BrowserCookieImportResult
if (result.ok) {
get().recordFeatureInteraction?.('cookie-import')
set({
browserSessionImportState: {
set((state) =>
browserImportStateForHostUpdate(state, hostId, {
profileId,
status: 'success',
summary: result.summary,
error: null
}
})
await get()
.fetchBrowserSessionProfiles()
.catch(() => {})
})
)
if (getBrowserSettingsHostId(get()) === hostId) {
await get()
.fetchBrowserSessionProfiles()
.catch(() => {})
}
} else {
set({
browserSessionImportState: {
set((state) =>
browserImportStateForHostUpdate(state, hostId, {
profileId,
status: 'error',
summary: null,
error: result.reason
}
})
})
)
}
return result
} catch (err) {
const reason = String((err as Error)?.message ?? err)
set({
browserSessionImportState: {
set((state) =>
browserImportStateForHostUpdate(state, hostId, {
profileId,
status: 'error',
summary: null,
error: reason
}
})
})
)
return { ok: false as const, reason }
}
},
clearDefaultSessionCookies: async () => {
if (isRuntimeEnvironmentActive(get())) {
const hostId = getBrowserSettingsHostId(get())
const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get())
if (runtimeEnvironmentId) {
try {
const result = await callRuntimeRpc<BrowserProfileClearDefaultCookiesResult>(
getActiveRuntimeTarget(get().settings),
{ kind: 'environment', environmentId: runtimeEnvironmentId },
'browser.profileClearDefaultCookies',
undefined,
{ timeoutMs: 15_000 }
)
if (result.cleared) {
if (result.cleared && getBrowserSettingsHostId(get()) === hostId) {
await get().fetchBrowserSessionProfiles()
}
return result.cleared
@ -2051,7 +2175,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
}
try {
const ok = await window.api.browser.sessionClearDefaultCookies()
if (ok) {
if (ok && getBrowserSettingsHostId(get()) === hostId) {
get().recordFeatureInteraction?.('cookie-import')
await get().fetchBrowserSessionProfiles()
}

View File

@ -22,6 +22,7 @@ vi.mock('@/lib/agent-status', async (importOriginal) => {
const runtimeEnvironmentCall = vi.fn()
const runtimeEnvironmentGetStatus = vi.fn()
const settingsSet = vi.fn().mockResolvedValue(undefined)
const setActiveRuntimeEnvironmentPreference = vi.fn().mockResolvedValue(undefined)
const worktreesListDetected = vi.fn()
const env2Lineage: WorktreeLineage = {
@ -132,7 +133,7 @@ beforeEach(() => {
})
vi.stubGlobal('window', {
api: {
settings: { set: settingsSet },
settings: { set: settingsSet, setActiveRuntimeEnvironmentPreference },
runtimeEnvironments: { call: runtimeEnvironmentCall, getStatus: runtimeEnvironmentGetStatus },
worktrees: { listDetected: worktreesListDetected }
}
@ -251,9 +252,11 @@ describe('createSettingsSlice runtime switching', () => {
jiraIssueCache: { 'JIRA-1': { data: { key: 'JIRA-1' } as never, fetchedAt: Date.now() } }
})
await expect(store.getState().switchRuntimeEnvironment('env-2')).resolves.toBe(true)
await expect(store.getState().setActiveRuntimeEnvironmentPreference('env-2')).resolves.toBe(
true
)
expect(settingsSet).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: 'env-2' })
expect(setActiveRuntimeEnvironmentPreference).toHaveBeenCalledWith({ environmentId: 'env-2' })
expect(runtimeEnvironmentGetStatus).toHaveBeenCalledWith({
selector: 'env-2',
timeoutMs: 15_000
@ -372,9 +375,11 @@ describe('createSettingsSlice runtime switching', () => {
}
})
await expect(store.getState().switchRuntimeEnvironment('env-2')).resolves.toBe(true)
await expect(store.getState().setActiveRuntimeEnvironmentPreference('env-2')).resolves.toBe(
true
)
expect(settingsSet).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: 'env-2' })
expect(setActiveRuntimeEnvironmentPreference).toHaveBeenCalledWith({ environmentId: 'env-2' })
expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith(
expect.objectContaining({ selector: 'env-1', method: 'terminal.close' })
)
@ -437,7 +442,9 @@ describe('createSettingsSlice runtime switching', () => {
}
})
await expect(store.getState().switchRuntimeEnvironment('env-2')).resolves.toBe(true)
await expect(store.getState().setActiveRuntimeEnvironmentPreference('env-2')).resolves.toBe(
true
)
// No teardown RPC was issued against the previous host's live resources.
expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith(
@ -496,9 +503,11 @@ describe('createSettingsSlice runtime switching', () => {
editorDrafts: { '/env-1/repo/dirty.md': 'draft' }
})
await expect(store.getState().switchRuntimeEnvironment('env-2')).resolves.toBe(true)
await expect(store.getState().setActiveRuntimeEnvironmentPreference('env-2')).resolves.toBe(
true
)
expect(settingsSet).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: 'env-2' })
expect(setActiveRuntimeEnvironmentPreference).toHaveBeenCalledWith({ environmentId: 'env-2' })
expect(runtimeEnvironmentCall).toHaveBeenCalledWith(
expect.objectContaining({ selector: 'env-2', method: 'repo.list' })
)
@ -520,9 +529,11 @@ describe('createSettingsSlice runtime switching', () => {
ptyIdsByTabId: { tab1: ['remote:env-1@@terminal-a'] }
})
await expect(store.getState().switchRuntimeEnvironment('env-2')).resolves.toBe(false)
await expect(store.getState().setActiveRuntimeEnvironmentPreference('env-2')).resolves.toBe(
false
)
expect(settingsSet).not.toHaveBeenCalled()
expect(setActiveRuntimeEnvironmentPreference).not.toHaveBeenCalled()
expect(runtimeEnvironmentGetStatus).toHaveBeenCalledWith({
selector: 'env-2',
timeoutMs: 15_000
@ -558,9 +569,11 @@ describe('createSettingsSlice runtime switching', () => {
openFiles: []
})
await expect(store.getState().switchRuntimeEnvironment('env-old')).resolves.toBe(false)
await expect(store.getState().setActiveRuntimeEnvironmentPreference('env-old')).resolves.toBe(
false
)
expect(settingsSet).not.toHaveBeenCalled()
expect(setActiveRuntimeEnvironmentPreference).not.toHaveBeenCalled()
expect(runtimeEnvironmentGetStatus).toHaveBeenCalledWith({
selector: 'env-old',
timeoutMs: 15_000

View File

@ -28,7 +28,7 @@ export type SettingsSlice = SettingsSearchState & {
settings: GlobalSettings | null
fetchSettings: () => Promise<void>
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void>
switchRuntimeEnvironment: (environmentId: string | null) => Promise<boolean>
setActiveRuntimeEnvironmentPreference: (environmentId: string | null) => Promise<boolean>
}
type LegacyTerminalScrollbackSettingsUpdate = Partial<GlobalSettings> & {
@ -142,7 +142,7 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice>
}
},
switchRuntimeEnvironment: async (environmentId) => {
setActiveRuntimeEnvironmentPreference: async (environmentId) => {
const nextId = normalizeRuntimeEnvironmentId(environmentId)
const previousId = normalizeRuntimeEnvironmentId(get().settings?.activeRuntimeEnvironmentId)
if (previousId === nextId) {
@ -151,8 +151,8 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice>
try {
clearRuntimeCompatibilityCache(nextId)
await verifyRuntimeEnvironmentReachable(nextId)
const nextSettings = await window.api.settings.set({
activeRuntimeEnvironmentId: nextId
const nextSettings = await window.api.settings.setActiveRuntimeEnvironmentPreference({
environmentId: nextId
})
bumpProviderRuntimeSessionGeneration()
set((s) => ({

View File

@ -213,6 +213,81 @@ describe('web runtime environment identity', () => {
).rejects.toThrow('Unknown Orca runtime environment: web-server-a')
})
it('keeps pairing state separate from generic Active Server settings writes', async () => {
const globals = installBrowserGlobals('Linux')
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
const paired = await globals.window.api.runtimeEnvironments.addFromPairingCode({
name: 'Windows 2',
pairingCode: encodePairingCode({ publicKeyB64: 'windows-2-key' })
})
const settings = await globals.window.api.settings.set({ activeRuntimeEnvironmentId: null })
await expect(globals.window.api.runtimeEnvironments.list()).resolves.toMatchObject([
{ id: paired.environment.id, name: 'Windows 2' }
])
expect(settings.activeRuntimeEnvironmentId).toBeNull()
expect(globals.window.api.settings.getSync()?.activeRuntimeEnvironmentId).toBeNull()
expect(JSON.parse(globals.storage.getItem('orca.web.settings.v1') ?? '{}')).not.toHaveProperty(
'activeRuntimeEnvironmentId'
)
await expect(
globals.window.api.runtimeEnvironments.remove({ selector: paired.environment.id })
).resolves.toMatchObject({ removed: { id: paired.environment.id } })
await expect(globals.window.api.runtimeEnvironments.list()).resolves.toEqual([])
})
it('persists an explicit Active Server choice across unrelated web settings writes', async () => {
const globals = installBrowserGlobals('Linux')
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
const paired = await globals.window.api.runtimeEnvironments.addFromPairingCode({
name: 'Windows 2',
pairingCode: encodePairingCode({ publicKeyB64: 'windows-2-key' })
})
await globals.window.api.settings.setActiveRuntimeEnvironmentPreference({
environmentId: 'Windows 2'
})
await globals.window.api.settings.set({ terminalFontSize: 15 })
expect(JSON.parse(globals.storage.getItem('orca.web.settings.v1') ?? '{}')).toMatchObject({
activeRuntimeEnvironmentId: paired.environment.id,
terminalFontSize: 15
})
await globals.window.api.settings.setActiveRuntimeEnvironmentPreference({
environmentId: null
})
await globals.window.api.settings.set({ terminalFontSize: 16 })
expect(JSON.parse(globals.storage.getItem('orca.web.settings.v1') ?? '{}')).toMatchObject({
activeRuntimeEnvironmentId: null,
terminalFontSize: 16
})
})
it('rejects an unknown explicit Active Server choice without corrupting the preference', async () => {
const globals = installBrowserGlobals('Linux')
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
const paired = await globals.window.api.runtimeEnvironments.addFromPairingCode({
name: 'Windows 2',
pairingCode: encodePairingCode({ publicKeyB64: 'windows-2-key' })
})
await globals.window.api.settings.setActiveRuntimeEnvironmentPreference({
environmentId: paired.environment.id
})
await expect(
globals.window.api.settings.setActiveRuntimeEnvironmentPreference({
environmentId: 'unknown-server'
})
).rejects.toThrow('Unknown Orca runtime environment: unknown-server')
expect(JSON.parse(globals.storage.getItem('orca.web.settings.v1') ?? '{}')).toMatchObject({
activeRuntimeEnvironmentId: paired.environment.id
})
})
it('keeps old selectors only when re-pairing proves the same server key', async () => {
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage, 'web-server-a')
@ -479,7 +554,12 @@ describe('web settings preload API', () => {
return Promise.resolve({
id: `call-${runtimeCalls.length}`,
ok: true,
result: { settings: { compactWorktreeCards: true } },
result: {
settings: {
compactWorktreeCards: true,
activeRuntimeEnvironmentId: 'host-internal-default'
}
},
_meta: { runtimeId: 'runtime-1' }
})
}
@ -499,7 +579,9 @@ describe('web settings preload API', () => {
}
expect(settings.compactWorktreeCards).toBe(true)
expect(settings.activeRuntimeEnvironmentId).toBeNull()
expect(stored.compactWorktreeCards).toBe(true)
expect(stored).not.toHaveProperty('activeRuntimeEnvironmentId')
expect(runtimeCalls).toEqual([{ method: 'settings.get', params: undefined }])
}, 15_000)
@ -615,7 +697,12 @@ describe('web settings preload API', () => {
return Promise.resolve({
id: `call-${runtimeCalls.length}`,
ok: true,
result: { settings: { compactWorktreeCards: true } },
result: {
settings: {
compactWorktreeCards: true,
activeRuntimeEnvironmentId: 'host-internal-default'
}
},
_meta: { runtimeId: 'runtime-1' }
})
}
@ -636,7 +723,9 @@ describe('web settings preload API', () => {
}
expect(settings.compactWorktreeCards).toBe(true)
expect(settings.activeRuntimeEnvironmentId).toBeNull()
expect(stored.compactWorktreeCards).toBe(true)
expect(stored).not.toHaveProperty('activeRuntimeEnvironmentId')
expect(runtimeCalls).toEqual([
{ method: 'settings.update', params: { compactWorktreeCards: true } }
])

View File

@ -629,19 +629,28 @@ function createWebPreloadApi(): Partial<PreloadApi> {
// Why: localStorage-backed settings are synchronous, so the pre-hydration kill-switch read works the same as desktop.
getSync: () => getStoredSettings(),
set: async (updates) => {
if (updates.activeRuntimeEnvironmentId === null) {
disconnectActiveRuntimeEnvironment()
}
const sanitizedUpdates = { ...updates }
delete sanitizedUpdates.activeRuntimeEnvironmentId
if ('autoRenameBranchFromWorkDefaultedOn' in sanitizedUpdates) {
sanitizedUpdates.autoRenameBranchFromWorkDefaultedOn = true
}
const next = mergeSettings(getStoredSettings(), sanitizedUpdates, {
preserveAutoRenameBranchFromWorkUpdate: 'autoRenameBranchFromWork' in sanitizedUpdates
})
writeJson(SETTINGS_STORAGE_KEY, next)
writeStoredSettings(next)
return syncRuntimeBackedSettings(sanitizedUpdates, next)
},
setActiveRuntimeEnvironmentPreference: async ({ environmentId }) => {
const requestedEnvironmentId = environmentId?.trim() || null
const activeRuntimeEnvironmentId = requestedEnvironmentId
? resolveEnvironment(requestedEnvironmentId).id
: null
const next = mergeSettings(getStoredSettings(), {
activeRuntimeEnvironmentId
})
writeStoredSettings(next, activeRuntimeEnvironmentId)
return next
},
updatePRBotAuthorOverride: (args) => updateRuntimePRBotAuthorOverride(args),
listFonts: () => Promise.resolve([]),
onChanged: () => noopUnsubscribe
@ -3318,7 +3327,7 @@ function updateEnvironmentFromResponse(
}
function getStoredSettings(): GlobalSettings {
const environment = (activeEnvironment = activeEnvironment ?? readStoredWebRuntimeEnvironment())
activeEnvironment = activeEnvironment ?? readStoredWebRuntimeEnvironment()
const defaults = getDefaultSettings('~')
const rawStoredSettings = window.localStorage.getItem(SETTINGS_STORAGE_KEY)
const stored = readJson<Partial<GlobalSettings>>(SETTINGS_STORAGE_KEY, {})
@ -3354,12 +3363,30 @@ function getStoredSettings(): GlobalSettings {
...defaults,
floatingTerminalEnabled: false,
rightSidebarOpenByDefault: false,
activeRuntimeEnvironmentId: environment?.id ?? null
activeRuntimeEnvironmentId: null
},
migratedStored
)
}
function writeStoredSettings(
settings: GlobalSettings,
explicitActiveRuntimeEnvironmentId?: string | null
): void {
const durable = { ...settings }
if (explicitActiveRuntimeEnvironmentId !== undefined) {
durable.activeRuntimeEnvironmentId = explicitActiveRuntimeEnvironmentId
} else {
const stored = readJson<Partial<GlobalSettings>>(SETTINGS_STORAGE_KEY, {})
if (Object.hasOwn(stored, 'activeRuntimeEnvironmentId')) {
durable.activeRuntimeEnvironmentId = stored.activeRuntimeEnvironmentId ?? null
} else {
delete durable.activeRuntimeEnvironmentId
}
}
writeJson(SETTINGS_STORAGE_KEY, durable)
}
async function getRuntimeBackedStoredSettings(): Promise<GlobalSettings> {
const local = getStoredSettings()
if (!requireActiveEnvironmentOrNull()) {
@ -3391,7 +3418,7 @@ async function getRuntimeBackedStoredSettings(): Promise<GlobalSettings> {
)
}
const next = mergeSettings(local, runtimeSettings)
writeJson(SETTINGS_STORAGE_KEY, next)
writeStoredSettings(next)
return next
} catch {
// Why: unpaired/offline web clients keep a local settings fallback.
@ -3433,8 +3460,10 @@ async function syncRuntimeBackedSettings(
runtimeUpdates,
15_000
)
const next = mergeSettings(localNext, result.settings)
writeJson(SETTINGS_STORAGE_KEY, next)
const runtimeSettings = { ...result.settings }
delete runtimeSettings.activeRuntimeEnvironmentId
const next = mergeSettings(localNext, runtimeSettings)
writeStoredSettings(next)
return next
} catch {
// Why: unpaired/offline web clients still need local settings persistence.
@ -3457,7 +3486,7 @@ async function updateRuntimePRBotAuthorOverride(args: {
const next = mergeSettings(local, {
prBotAuthorOverrides: normalizePRBotAuthorOverrides(result.settings.prBotAuthorOverrides)
})
writeJson(SETTINGS_STORAGE_KEY, next)
writeStoredSettings(next)
return next
}
const next = mergeSettings(local, {
@ -3467,7 +3496,7 @@ async function updateRuntimePRBotAuthorOverride(args: {
args.isBot
)
})
writeJson(SETTINGS_STORAGE_KEY, next)
writeStoredSettings(next)
return next
}
@ -3647,7 +3676,9 @@ function mergeSettings(
...(base.voice ?? defaults.voice),
...updates.voice
} as NonNullable<GlobalSettings['voice']>,
activeRuntimeEnvironmentId: activeEnvironment?.id ?? updates.activeRuntimeEnvironmentId ?? null,
activeRuntimeEnvironmentId: Object.hasOwn(updates, 'activeRuntimeEnvironmentId')
? (updates.activeRuntimeEnvironmentId ?? null)
: (base.activeRuntimeEnvironmentId ?? null),
terminalCustomThemes: normalizeTerminalCustomThemes(
updates.terminalCustomThemes ?? base.terminalCustomThemes
),

View File

@ -39,6 +39,7 @@ import type {
import type { StartupCommandDelivery } from './codex-startup-delivery'
import type { RemoteServerUpdateSupport } from './remote-server-update'
import type { ExecutionHostId } from './execution-host'
import type { PtyIncarnationId } from './pty-incarnation'
export type { RuntimeMarkdownReadTabResult, RuntimeMarkdownSaveTabResult }
@ -417,6 +418,8 @@ export type RuntimeFileReadChunkResult = {
export type RuntimeTerminalSummary = {
handle: string
ptyId: string | null
incarnationId?: string | null
orphaned?: boolean
worktreeId: string
worktreePath: string
branch: string
@ -480,10 +483,54 @@ export type RuntimeTerminalVisualLayout = {
export type RuntimeTerminalListResult = {
terminals: RuntimeTerminalSummary[]
visualLayouts?: RuntimeTerminalVisualLayout[]
topologyRevisions?: Record<string, number>
totalCount: number
truncated: boolean
}
export type RuntimeTerminalOrphanAdoptionClaim = {
terminal: string
ptyId: string
incarnationId: PtyIncarnationId
tabId: string
leafId: string
}
export type RuntimeTerminalOrphanTopologyTab = {
tabId: string
root: TerminalPaneLayoutNode
activeLeafId: string
expandedLeafId: string | null
}
export type RuntimeTerminalOrphanTopologyGroup = {
id: string
activeTabId: string
tabOrder: string[]
recentTabIds?: string[]
}
export type RuntimeTerminalOrphanTopology = {
tabs: RuntimeTerminalOrphanTopologyTab[]
groups: RuntimeTerminalOrphanTopologyGroup[]
groupLayout?: TabGroupLayoutNode
}
export type RuntimeTerminalOrphanAdoptionRequest = {
worktree: string
expectedTopologyRevision: number
claims: RuntimeTerminalOrphanAdoptionClaim[]
activeTabId?: string
activeGroupId?: string
topology?: RuntimeTerminalOrphanTopology
}
export type RuntimeTerminalOrphanAdoptionResult = {
adopted: boolean
topologyRevision: number
snapshot: RuntimeMobileSessionTabsResult
}
export type RuntimeWorktreeTerminalSleepFailure =
| 'terminal_liveness_unavailable'
| 'terminal_worktree_sleep_still_live'