Split web runtime session responsibilities (#17263)

* Split speech session lifecycle

* Split terminal output scheduler pipeline

* Split mobile browser pane modules

* Prune resolved max-lines suppressions

* Split pane tree equalization logic

* Extract mobile troubleshoot screen styles

* Split external automation manager

* Split main window service attachments

* Split hosted review creation checks

* Split automation dispatch event handling

* Split settings navigation metadata

* Split daemon initialization lifecycle

* Split GitLab item dialog

* Split relay dispatcher layers

* Split mobile host screen

* Retarget mobile view settings source test

* Split runtime file client layers

* Split ports panel layers

* Split runtime environments pane layers

* Split local PTY provider responsibilities

* Split CDP bridge responsibilities

* Split relay Git handler responsibilities

* Track moved relay Git fetch audit

* Split Linear item drawer responsibilities

* Split telemetry event schema responsibilities

* Split resource usage status responsibilities

* Split remote terminal multiplexer responsibilities

* Split Git worktree responsibilities

* Split Codex hook service responsibilities

* Keep mirrored hook trust type private

* Split web runtime session responsibilities

* Fix F3-speech for #17123

* Fix F1-cycle for #17131

* Fix F4-navtest for #17157

* Fix F2-allowlist for #17161
This commit is contained in:
Neil
2026-08-29 20:21:36 -07:00
committed by GitHub
parent 1369821bad
commit 07652e252f
18 changed files with 2026 additions and 1788 deletions
-1
View File
@@ -45,7 +45,6 @@ inline src/renderer/src/components/terminal-pane/TerminalPane.tsx
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
inline src/renderer/src/runtime/sync-runtime-graph.ts
inline src/renderer/src/runtime/web-runtime-session.ts
inline src/renderer/src/runtime/web-session-tabs-sync.ts
inline src/renderer/src/store/slices/agent-status.ts
inline src/renderer/src/store/slices/browser.ts
@@ -144,7 +144,7 @@ describe('host-session-mirror settle census', () => {
// singular frame, scoped active frame.
'runtime/web-session-tabs-sync.ts': 5,
// The eager post-create session.tabs.list refresh.
'runtime/web-runtime-session.ts': 1,
'runtime/web-runtime-session-snapshot.ts': 1,
// The local structured-session inventory/subscription frame.
'runtime/local-structured-session-tabs-sync.ts': 1
})
@@ -192,7 +192,7 @@ describe('host-session-mirror settle census', () => {
// frame patch, and its patchless twin) and three mirror ones
// (visibility-resume repair, global singular frame patch and patchless).
'runtime/web-session-tabs-sync.ts': { settleHydration: 4, settleMirror: 3 },
'runtime/web-runtime-session.ts': { settleMirror: 1 },
'runtime/web-runtime-session-snapshot.ts': { settleMirror: 1 },
'runtime/local-structured-session-tabs-sync.ts': { settleStructuredSessionMirror: 1 }
})
})
@@ -0,0 +1,265 @@
import type { BrowserClientHostPlacementPreference } from '../../../shared/browser-client-host-placement'
import { BROWSER_TAB_CREATE_KNOWN_ID_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
import { assertRuntimeManagedBrowserCreationAvailable } from '../lib/client-creation-action-policy'
import { createBrowserUuid } from '../lib/browser-uuid'
import { useAppStore } from '../store'
import {
clearWebSessionFocusIntentIfMatches,
peekWebSessionFocusIntent,
recordWebSessionFocusIntent,
resolveWebSessionVisibleTabId
} from './web-session-focus-intent'
import {
forgetWebSessionBrowserPlacement,
markWebSessionBrowserPlacementGroupMaterialized,
moveWebSessionBrowserPlacement,
recordWebSessionBrowserPlacement
} from './web-session-browser-placement'
import {
rehomeStagedWebRuntimeBrowserTab,
restageWebRuntimeBrowserTabHostingIntent,
stageWebRuntimeBrowserTab,
type StagedWebRuntimeBrowserTab
} from './web-runtime-browser-tab-staging'
import { throwIfE2eWebRuntimeBrowserCapabilityUnavailable } from './web-runtime-browser-creation-e2e-fault'
import { expectsBrowserClientHosting } from '../../../shared/browser-client-hosting-eligibility'
import {
captureRuntimeEnvironmentCall,
captureWebSessionIntentOwner,
matchesWebSessionIntentOwner
} from './web-runtime-session-environment'
import { selectWebRuntimeSessionBrowserWorktree } from './web-runtime-session-workspace-selection'
export type CreateWebRuntimeSessionBrowserTabArgs = {
worktreeId: string
environmentId?: string | null
url?: string
profileId?: string | null
targetGroupId?: string
clientTargetGroupId?: string
clientTargetGroupCreated?: boolean
focusOnCreate?: boolean
/** Wait until a renderer-backed host can publish the new page in its session snapshot. */
waitForRegistration?: boolean
selectWorktree?: boolean
stagedTitle?: string
stagedFocusAddressBar?: boolean
failureLogMode?: 'details' | 'operation-only'
placementPreference?: BrowserClientHostPlacementPreference
}
export type WebRuntimeBrowserCreationContext = {
args: CreateWebRuntimeSessionBrowserTabArgs
environmentId: string
intentOwner: ReturnType<typeof captureWebSessionIntentOwner>
callEnvironment: ReturnType<typeof captureRuntimeEnvironmentCall>
shouldFocusOnCreate: boolean
shouldSelectWorktree: boolean
provisionalPageId: string
hostSupportsKnownPageId: boolean
expectsClientHosting: boolean
unsubscribeFocusGuard: () => void
guardedPageId: string
createdPageId: string | null
createAttempted: boolean
staged: StagedWebRuntimeBrowserTab | null
expectedCurrentLocalTabId?: string | null
}
export function createWebRuntimeBrowserCreationContext(
args: CreateWebRuntimeSessionBrowserTabArgs,
environmentId: string
): WebRuntimeBrowserCreationContext {
const intentOwner = captureWebSessionIntentOwner(environmentId)
const callEnvironment = captureRuntimeEnvironmentCall(environmentId, intentOwner.pairingRevision)
const shouldFocusOnCreate = args.focusOnCreate !== false
const provisionalPageId = createBrowserUuid()
const advertisedCapabilities =
useAppStore.getState().runtimeStatusByEnvironmentId?.get(environmentId)?.status?.capabilities ??
[]
const hostSupportsKnownPageId = advertisedCapabilities.includes(
BROWSER_TAB_CREATE_KNOWN_ID_RUNTIME_CAPABILITY
)
// Why the same predicate the main process uses: this mounts the staged pane one round-trip
// before prepareBrowserClientHostPlacement answers, so the two have to reach the same verdict
// from the same inputs. A cached status disagreeing with the live one is corrected by
// restageWebRuntimeBrowserTabHostingIntent below; it never decides the placement itself.
const expectsClientHosting = expectsBrowserClientHosting({
enabled: useAppStore.getState().settings?.browserClientHostedRemoteEnabled !== false,
preference: args.placementPreference,
deviceScope: useAppStore.getState().runtimeStatusByEnvironmentId?.get(environmentId)?.status
?.deviceScope,
capabilities: advertisedCapabilities
})
return {
args,
environmentId,
intentOwner,
callEnvironment,
shouldFocusOnCreate,
shouldSelectWorktree: args.selectWorktree !== false,
provisionalPageId,
hostSupportsKnownPageId,
expectsClientHosting,
unsubscribeFocusGuard: (): void => {},
guardedPageId: provisionalPageId,
createdPageId: null,
createAttempted: false,
staged: null
}
}
export function stageWebRuntimeBrowserCreation(context: WebRuntimeBrowserCreationContext): void {
const { args, environmentId, intentOwner, provisionalPageId, shouldFocusOnCreate } = context
throwIfE2eWebRuntimeBrowserCapabilityUnavailable()
assertRuntimeManagedBrowserCreationAvailable(useAppStore.getState(), environmentId)
if (args.clientTargetGroupId) {
recordWebSessionBrowserPlacement({
environmentId,
worktreeId: args.worktreeId,
remotePageId: provisionalPageId,
groupId: args.clientTargetGroupId,
callerCreatedGroup: args.clientTargetGroupCreated
})
}
if (context.shouldSelectWorktree) {
selectWebRuntimeSessionBrowserWorktree(args.worktreeId, environmentId)
}
// Why: everything below this point is a host round-trip; stage the tab first so the strip
// reacts to the click instead of to the runtime.
context.staged = stageWebRuntimeBrowserTab({
environmentId,
worktreeId: args.worktreeId,
remotePageId: provisionalPageId,
activate: shouldFocusOnCreate,
...(args.url !== undefined ? { url: args.url } : {}),
...(args.stagedTitle !== undefined ? { title: args.stagedTitle } : {}),
...(args.profileId !== undefined ? { profileId: args.profileId } : {}),
...((args.clientTargetGroupId ?? args.targetGroupId)
? { targetGroupId: (args.clientTargetGroupId ?? args.targetGroupId) as string }
: {}),
...(args.stagedFocusAddressBar !== undefined
? { focusAddressBar: args.stagedFocusAddressBar }
: {}),
clientHosted: context.expectsClientHosting
})
// Why: sample the focus expectation and arm its guard against the state staging just wrote,
// before any await. Sampling after the client-host preparation round-trip baked a switch made
// during it into the baseline, so the guard read the user's new tab as "hasn't moved" and
// adoption stole focus back.
const initialFocusState = shouldFocusOnCreate ? useAppStore.getState() : null
const expectedActiveWorktreeId = initialFocusState?.activeWorktreeId
const expectedActiveWorkspaceExecutionHostId = initialFocusState?.activeWorkspaceExecutionHostId
context.expectedCurrentLocalTabId = initialFocusState
? resolveWebSessionVisibleTabId(initialFocusState, args.worktreeId)
: null
if (shouldFocusOnCreate && matchesWebSessionIntentOwner(intentOwner)) {
recordWebSessionFocusIntent(
intentOwner,
args.worktreeId,
provisionalPageId,
undefined,
context.expectedCurrentLocalTabId
)
context.unsubscribeFocusGuard = useAppStore.subscribe((state, previousState) => {
if (
state.activeBrowserTabIdByWorktree === previousState.activeBrowserTabIdByWorktree &&
state.activeFileIdByWorktree === previousState.activeFileIdByWorktree &&
// Why: resolveWebSessionVisibleTabId now reads group state, so a group-only focus move
// must re-evaluate it.
state.activeGroupIdByWorktree === previousState.activeGroupIdByWorktree &&
state.activeTabIdByWorktree === previousState.activeTabIdByWorktree &&
state.activeTabType === previousState.activeTabType &&
state.activeTabTypeByWorktree === previousState.activeTabTypeByWorktree &&
state.activeWorktreeId === previousState.activeWorktreeId &&
state.activeWorkspaceExecutionHostId === previousState.activeWorkspaceExecutionHostId &&
state.groupsByWorktree === previousState.groupsByWorktree &&
state.unifiedTabsByWorktree === previousState.unifiedTabsByWorktree
) {
return
}
if (
state.activeWorktreeId === expectedActiveWorktreeId &&
state.activeWorkspaceExecutionHostId === expectedActiveWorkspaceExecutionHostId &&
resolveWebSessionVisibleTabId(state, args.worktreeId) === context.expectedCurrentLocalTabId
) {
return
}
clearWebSessionFocusIntentIfMatches(intentOwner, args.worktreeId, context.guardedPageId)
context.unsubscribeFocusGuard()
})
}
}
export function restageWebRuntimeBrowserCreation(
context: WebRuntimeBrowserCreationContext,
clientHosted: boolean
): void {
if (context.staged) {
context.staged = restageWebRuntimeBrowserTabHostingIntent(context.staged, {
environmentId: context.environmentId,
remotePageId: context.provisionalPageId,
clientHosted
})
}
}
export function rehomeWebRuntimeBrowserCreation(
context: WebRuntimeBrowserCreationContext,
remotePageId: string
): void {
const { args, environmentId, intentOwner, provisionalPageId } = context
moveWebSessionBrowserPlacement({
environmentId,
worktreeId: args.worktreeId,
fromRemotePageId: provisionalPageId,
toRemotePageId: remotePageId
})
if (context.staged) {
context.staged = rehomeStagedWebRuntimeBrowserTab(context.staged, {
environmentId,
worktreeId: args.worktreeId,
remotePageId
})
}
const focusIntent = context.shouldFocusOnCreate
? peekWebSessionFocusIntent(intentOwner, args.worktreeId)
: null
if (focusIntent?.hostTabId === provisionalPageId) {
recordWebSessionFocusIntent(
intentOwner,
args.worktreeId,
remotePageId,
undefined,
focusIntent.expectedCurrentLocalTabId
)
}
context.guardedPageId = remotePageId
}
export function completeWebRuntimeBrowserCreation(context: WebRuntimeBrowserCreationContext): true {
const { args, environmentId, intentOwner, guardedPageId } = context
context.staged = null
const remainingFocusIntent = context.shouldFocusOnCreate
? peekWebSessionFocusIntent(intentOwner, args.worktreeId)
: null
if (
remainingFocusIntent?.hostTabId === guardedPageId &&
remainingFocusIntent.expectedCurrentLocalTabId === context.expectedCurrentLocalTabId
) {
clearWebSessionFocusIntentIfMatches(intentOwner, args.worktreeId, guardedPageId)
}
context.unsubscribeFocusGuard()
if (args.clientTargetGroupId) {
markWebSessionBrowserPlacementGroupMaterialized({
worktreeId: args.worktreeId,
groupId: args.clientTargetGroupId
})
}
forgetWebSessionBrowserPlacement({
environmentId,
worktreeId: args.worktreeId,
remotePageId: guardedPageId
})
return true
}
@@ -0,0 +1,118 @@
import { useAppStore } from '../store'
import { hasRuntimeRpcErrorCode } from './runtime-rpc-client'
import { clearWebSessionFocusIntentIfMatches } from './web-session-focus-intent'
import {
claimWebSessionBrowserPlacementGroupCleanup,
forgetWebSessionBrowserPlacement,
releaseWebSessionBrowserPlacementGroup
} from './web-session-browser-placement'
import {
discardStagedWebRuntimeBrowserTab,
StagedWebRuntimeBrowserTabCancelledError
} from './web-runtime-browser-tab-staging'
import type { WebRuntimeBrowserCreationContext } from './web-runtime-browser-creation-context'
const DEFINITIVE_BROWSER_CREATE_FAILURE_CODES = [
'browser_error',
'capability_unsupported',
'invalid_argument',
'invalid_params',
'method_not_found',
'runtime_rpc_queue_overloaded',
'selector_ambiguous',
'selector_not_found',
'unauthorized'
] as const
function isDefinitiveBrowserCreateFailure(error: unknown): boolean {
return DEFINITIVE_BROWSER_CREATE_FAILURE_CODES.some((code) => hasRuntimeRpcErrorCode(error, code))
}
type WebRuntimeBrowserCreationFailure = {
cleanupPageId: string | null
createOutcomeUnknown: boolean
ownsClientGroupCleanup: boolean
recoveryError: unknown
}
export function prepareWebRuntimeBrowserCreationFailure(
context: WebRuntimeBrowserCreationContext,
error: unknown
): WebRuntimeBrowserCreationFailure {
const { args, environmentId, guardedPageId } = context
context.unsubscribeFocusGuard()
// Why: unwind the optimistic tab before the cleanup round-trips below, so a failed create
// does not leave a dead tab sitting in the strip for the length of browser.tabClose.
if (context.staged) {
discardStagedWebRuntimeBrowserTab(context.staged)
}
const createFailureDefinitive = isDefinitiveBrowserCreateFailure(error)
const cleanupPageId =
context.createdPageId ??
(context.createAttempted && !createFailureDefinitive && context.hostSupportsKnownPageId
? context.provisionalPageId
: null)
const createOutcomeUnknown = !cleanupPageId && !createFailureDefinitive
const ownsClientGroupCleanup = args.clientTargetGroupId
? releaseWebSessionBrowserPlacementGroup({
environmentId,
worktreeId: args.worktreeId,
remotePageId: guardedPageId,
groupId: args.clientTargetGroupId,
callerCreatedGroup: args.clientTargetGroupCreated === true
})
: false
if (!args.clientTargetGroupId) {
forgetWebSessionBrowserPlacement({
environmentId,
worktreeId: args.worktreeId,
remotePageId: guardedPageId
})
}
return { cleanupPageId, createOutcomeUnknown, ownsClientGroupCleanup, recoveryError: null }
}
export function finishWebRuntimeBrowserCreationFailure(
context: WebRuntimeBrowserCreationContext,
failure: WebRuntimeBrowserCreationFailure,
error: unknown
): false {
const { args, intentOwner, guardedPageId } = context
if (context.shouldFocusOnCreate) {
clearWebSessionFocusIntentIfMatches(intentOwner, args.worktreeId, guardedPageId)
}
if (
args.clientTargetGroupId &&
claimWebSessionBrowserPlacementGroupCleanup({
worktreeId: args.worktreeId,
groupId: args.clientTargetGroupId,
ownsGroupCleanup: failure.ownsClientGroupCleanup
})
) {
useAppStore.getState().closeEmptyGroup(args.worktreeId, args.clientTargetGroupId)
}
if (error instanceof StagedWebRuntimeBrowserTabCancelledError) {
console.warn('[web-runtime-session] browser tab was closed before its create finished')
} else if (args.failureLogMode === 'operation-only') {
console.warn('[web-runtime-session] failed to create browser tab')
} else {
console.warn(
'[web-runtime-session] failed to create browser tab:',
error instanceof Error ? error.message : String(error)
)
}
if (failure.recoveryError) {
throw new Error('The paired runtime could not recover the failed browser creation.', {
cause: failure.recoveryError
})
}
if (!context.createAttempted) {
throw error
}
if (failure.createOutcomeUnknown) {
throw new Error('The paired runtime did not confirm whether the browser tab was created.', {
cause: error
})
}
return false
}
@@ -0,0 +1,255 @@
import type { BrowserPageCreationPlacement } from '../../../shared/browser-client-host-placement'
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import type { BrowserTabCreateResult } from '../../../shared/runtime-types'
import { translate } from '../i18n/i18n'
import { useAppStore } from '../store'
import { hasRuntimeRpcErrorCode, unwrapRuntimeRpcResult } from './runtime-rpc-client'
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
import { hasMaterializedWebRuntimeBrowserPage } from './web-runtime-browser-materialization'
import { waitForWebRuntimeBrowserPageMaterialization } from './web-runtime-browser-materialization-wait'
import {
isStagedWebRuntimeBrowserTabLive,
resolveStagedWebRuntimeBrowserTabGroupId,
StagedWebRuntimeBrowserTabCancelledError
} from './web-runtime-browser-tab-staging'
import {
completeWebRuntimeBrowserCreation,
createWebRuntimeBrowserCreationContext,
rehomeWebRuntimeBrowserCreation,
restageWebRuntimeBrowserCreation,
stageWebRuntimeBrowserCreation,
type CreateWebRuntimeSessionBrowserTabArgs
} from './web-runtime-browser-creation-context'
import {
finishWebRuntimeBrowserCreationFailure,
prepareWebRuntimeBrowserCreationFailure
} from './web-runtime-browser-creation-failure'
import {
pauseAfterE2eWebRuntimeBrowserCreate,
pauseDuringE2eWebRuntimeBrowserClientHostPreparation
} from './web-runtime-browser-creation-e2e-fault'
import { isWebRuntimeSessionActive } from './web-runtime-session-environment'
import { refreshWebRuntimeSessionTabsSnapshot } from './web-runtime-session-snapshot'
export async function createWebRuntimeSessionBrowserTab(
args: CreateWebRuntimeSessionBrowserTabArgs
): Promise<boolean> {
const environmentId =
args.environmentId?.trim() ??
useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim() ??
null
if (!environmentId || !isWebRuntimeSessionActive(environmentId)) {
return false
}
const context = createWebRuntimeBrowserCreationContext(args, environmentId)
try {
stageWebRuntimeBrowserCreation(context)
const placementPreference = args.placementPreference ?? 'auto'
let placement: BrowserPageCreationPlacement = { kind: 'server' }
// Why no cached-capability gate here: the renderer's runtime status can hold a pre-upgrade
// "cannot client-host" verdict for a whole catalog TTL, and skipping the preparation on it
// pinned a capable pair to server placement for that long. The preparation reads live status
// and answers `server` for a runtime that truly cannot host, so staleness now costs a round
// trip against an incapable host instead of the wrong placement. An unreachable host pays for
// that on the failure path too: the probe's 15s ceiling, then the tabCreate behind it and the
// cleanup close its non-definitive failure needs, where before the create never started.
if (placementPreference !== 'server') {
try {
await pauseDuringE2eWebRuntimeBrowserClientHostPreparation()
placement = await window.api.runtimeEnvironments.prepareBrowserClientHostPlacement({
selector: environmentId,
expectedPairingRevision: context.intentOwner.pairingRevision,
preference: placementPreference
})
} catch (error) {
console.warn('[web-runtime-session] failed to prepare client browser host:', error)
throw new Error(
translate(
'browser.clientHosted.preparationFailed',
"Couldn't start the remote browser on this desktop. Check the paired connection and try again."
),
{ cause: error }
)
}
}
restageWebRuntimeBrowserCreation(context, placement.kind === 'client')
context.createAttempted = true
const navigateAfterCreate =
args.waitForRegistration === true && args.url && args.url !== 'about:blank'
const created = unwrapRuntimeRpcResult(
(await context.callEnvironment({
method: 'browser.tabCreate',
params: {
worktree: toRuntimeWorktreeSelector(args.worktreeId),
url: navigateAfterCreate ? undefined : args.url,
...(context.hostSupportsKnownPageId ? { page: context.provisionalPageId } : {}),
...(placement.kind === 'client' ? { placement } : {}),
profileId: args.profileId ?? undefined,
activate: context.shouldFocusOnCreate,
// Why: `activate` alone made every paired device — the host desktop included — jump to a
// tab this client created. New hosts read `navigation`; old ones ignore it and keep
// today's behavior, which is also what keeps their targetGroupId placement working.
navigation: 'caller',
// Why: place the new browser in the clicked split group so the host snapshot is authoritative for it (no left-snap).
...(args.targetGroupId ? { targetGroupId: args.targetGroupId } : {}),
// Why: web clients need the local tab now; waiting for host webview registration makes the workspace appear to close.
waitForRegistration: args.waitForRegistration ?? false
},
timeoutMs: 15_000
})) as RuntimeRpcResponse<BrowserTabCreateResult>
)
context.createdPageId = created.browserPageId
if (navigateAfterCreate) {
void context
.callEnvironment({
method: 'browser.goto',
params: {
worktree: toRuntimeWorktreeSelector(args.worktreeId),
page: created.browserPageId,
url: args.url
},
timeoutMs: 15_000
})
.then((response) => unwrapRuntimeRpcResult(response))
.catch((error) => {
console.warn(
'[web-runtime-session] created browser tab navigation failed:',
error instanceof Error ? error.message : String(error)
)
})
}
await pauseAfterE2eWebRuntimeBrowserCreate(created.browserPageId)
// Why: the strip's X on a staged tab only unwinds this client's rows — it cannot close a host
// page whose id did not exist when the user clicked. Hand the cancel to the cleanup path in the
// catch below, which already owns retiring an unreconciled host page.
// Why (accepted bound): the host page has no name until the create answers, so a cancel during
// that round-trip leaves it alive for up to the RPC timeout. That is the same bound the
// unreconciled-cleanup path has always accepted, so no sweep is added for it.
if (context.staged && !isStagedWebRuntimeBrowserTabLive(context.staged, args.worktreeId)) {
throw new StagedWebRuntimeBrowserTabCancelledError()
}
if (created.browserPageId !== context.provisionalPageId) {
rehomeWebRuntimeBrowserCreation(context, created.browserPageId)
}
try {
await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId, {
expectedEnvironmentPairingRevision: context.intentOwner.pairingRevision,
acceptCurrentSnapshot: true,
afterCurrentInFlight: true,
errorMode: 'throw'
})
} catch (error) {
if (
!hasMaterializedWebRuntimeBrowserPage(
useAppStore.getState(),
environmentId,
args.worktreeId,
created.browserPageId,
args.clientTargetGroupId ?? args.targetGroupId
)
) {
throw error
}
}
const expectedGroupId =
(context.staged
? resolveStagedWebRuntimeBrowserTabGroupId(context.staged, args.worktreeId)
: undefined) ??
args.clientTargetGroupId ??
args.targetGroupId
let materialized = hasMaterializedWebRuntimeBrowserPage(
useAppStore.getState(),
environmentId,
args.worktreeId,
created.browserPageId,
expectedGroupId
)
if (!materialized) {
materialized = await waitForWebRuntimeBrowserPageMaterialization({
environmentId,
worktreeId: args.worktreeId,
remotePageId: created.browserPageId,
...(expectedGroupId ? { expectedGroupId } : {})
})
}
if (!materialized && expectedGroupId) {
// Why: an older runtime can honor creation but not group targeting; a live tab in
// the wrong split beats destroying what the user just made.
materialized = hasMaterializedWebRuntimeBrowserPage(
useAppStore.getState(),
environmentId,
args.worktreeId,
created.browserPageId
)
if (materialized) {
console.warn(
'[web-runtime-session] created browser tab landed outside the requested group:',
expectedGroupId
)
}
}
if (!materialized) {
throw new Error('The created browser tab did not materialize in the client.')
}
// Why: materialization only proves *some* workspace in this worktree carries the page — when the
// host mirrors it under its own id, the predicate goes true even though the staged row the user
// X-ed is gone. The whole materialization wait above is a live window for that X, so re-check the
// row here before the stage is surrendered, or the create reports success and never retires the
// host page.
if (context.staged && !isStagedWebRuntimeBrowserTabLive(context.staged, args.worktreeId)) {
throw new StagedWebRuntimeBrowserTabCancelledError()
}
// Why: materialization means the snapshot has taken ownership of these rows, so nothing
// downstream may still unwind them as an optimistic stage.
return completeWebRuntimeBrowserCreation(context)
} catch (error) {
const failure = prepareWebRuntimeBrowserCreationFailure(context, error)
if (failure.cleanupPageId) {
try {
const closeResult = unwrapRuntimeRpcResult(
(await context.callEnvironment({
method: 'browser.tabClose',
params: {
worktree: toRuntimeWorktreeSelector(args.worktreeId),
page: failure.cleanupPageId
},
timeoutMs: 15_000
})) as RuntimeRpcResponse<{ closed: boolean }>
)
if (!closeResult.closed) {
throw new Error('The paired runtime did not close the unreconciled browser tab.')
}
await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId, {
expectedEnvironmentPairingRevision: context.intentOwner.pairingRevision,
afterCurrentInFlight: true,
errorMode: 'throw'
})
if (
hasMaterializedWebRuntimeBrowserPage(
useAppStore.getState(),
environmentId,
args.worktreeId,
failure.cleanupPageId
)
) {
throw new Error('The closed browser tab remained materialized in the client.')
}
} catch (cleanupError) {
if (
!context.createdPageId &&
context.hostSupportsKnownPageId &&
hasRuntimeRpcErrorCode(cleanupError, 'browser_tab_not_found')
) {
failure.recoveryError = null
} else {
failure.recoveryError = cleanupError
console.warn(
'[web-runtime-session] failed to clean up unreconciled browser tab:',
cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
)
}
}
}
return finishWebRuntimeBrowserCreationFailure(context, failure, error)
}
}
@@ -0,0 +1,37 @@
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import { getRuntimeEnvironmentRevision } from './runtime-environment-revision'
import type { WebSessionIntentOwner } from './web-session-intent-owner'
export function isWebRuntimeSessionActive(
activeRuntimeEnvironmentId: string | null | undefined
): boolean {
// Why: headless serve sessions are owned by the remote runtime, whether the client is web or desktop Electron.
return Boolean(activeRuntimeEnvironmentId?.trim())
}
export function captureRuntimeEnvironmentCall(
environmentId: string,
expectedEnvironmentPairingRevision = getRuntimeEnvironmentRevision(environmentId)
): (args: {
method: string
params?: unknown
timeoutMs?: number
}) => Promise<RuntimeRpcResponse<unknown>> {
return (args) =>
window.api.runtimeEnvironments.call({
selector: environmentId,
...args,
expectedEnvironmentPairingRevision
})
}
export function captureWebSessionIntentOwner(environmentId: string): WebSessionIntentOwner {
return {
environmentId,
pairingRevision: getRuntimeEnvironmentRevision(environmentId)
}
}
export function matchesWebSessionIntentOwner(owner: WebSessionIntentOwner): boolean {
return getRuntimeEnvironmentRevision(owner.environmentId) === owner.pairingRevision
}
@@ -0,0 +1,169 @@
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status'
import { useAppStore } from '../store'
import {
listRemoteRuntimeSessionTabsAfterCurrentInFlight,
listRemoteRuntimeSessionTabsDeduped
} from './remote-runtime-session-tabs-inflight'
import { getRuntimeEnvironmentRevision } from './runtime-environment-revision'
import { unwrapRuntimeRpcResult } from './runtime-rpc-client'
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
import { captureRuntimeEnvironmentCall } from './web-runtime-session-environment'
import { throwIfE2eWebRuntimeBrowserReconciliationFails } from './web-runtime-browser-creation-e2e-fault'
const pendingRuntimeWorktreeRecoveryRefreshes = new Map<string, symbol>()
const RUNTIME_WORKTREE_RECOVERY_REFRESH_DELAYS_MS = [250, 500, 1_000, 2_000, 4_000] as const
export async function refreshWebRuntimeSessionTabsSnapshot(
environmentId: string,
worktreeId: string,
options: {
expectedEnvironmentPairingRevision?: number
acceptCurrentSnapshot?: boolean
confirmAgentSessionHandoff?: {
provisionalTabId: string
hostTabId: string
hostTerminalHandle: string
}
afterCurrentInFlight?: boolean
errorMode?: 'warn' | 'throw'
} = {}
): Promise<void> {
const webSessionTabsSync = await import('./web-session-tabs-sync')
const expectedEnvironmentPairingRevision =
options.expectedEnvironmentPairingRevision ?? getRuntimeEnvironmentRevision(environmentId)
const expectedEnvironmentConnectionGeneration =
getRuntimeEnvironmentConnectionGeneration(environmentId)
const expectedTrackingGeneration =
webSessionTabsSync.getWebSessionTabsTrackingGeneration(environmentId)
const callEnvironment = captureRuntimeEnvironmentCall(
environmentId,
expectedEnvironmentPairingRevision
)
try {
if (options.acceptCurrentSnapshot) {
const { acceptReplayedWebSessionTabsSnapshot } = await import('./web-session-tabs-sync')
// Why: the host snapshot may have arrived before structured create returned;
// re-accept its current version after the exact provisional handoff is known.
acceptReplayedWebSessionTabsSnapshot(environmentId, worktreeId)
}
const listSessionTabs =
options.confirmAgentSessionHandoff || options.afterCurrentInFlight
? listRemoteRuntimeSessionTabsAfterCurrentInFlight
: listRemoteRuntimeSessionTabsDeduped
if (options.afterCurrentInFlight) {
throwIfE2eWebRuntimeBrowserReconciliationFails()
}
const snapshot = await listSessionTabs({
environmentId,
worktreeId,
load: async () => {
const response = await callEnvironment({
method: 'session.tabs.list',
params: {
worktree: toRuntimeWorktreeSelector(worktreeId)
},
timeoutMs: 15_000
})
return unwrapRuntimeRpcResult(
response as RuntimeRpcResponse<RuntimeMobileSessionTabsResult>
)
}
})
if (options.confirmAgentSessionHandoff) {
const { confirmWebAgentSessionHandoffAfterCreate } =
await import('./web-agent-session-handoff')
// Why: this list completed after structured creation, so absence now proves the exact host tab already retired.
confirmWebAgentSessionHandoffAfterCreate({
environmentId,
worktreeId,
...options.confirmAgentSessionHandoff
})
}
const {
applyWebSessionTabsSnapshot,
applyWebSessionTabsStorePatch,
decideWebSessionTabsSnapshot
} = webSessionTabsSync
if (getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision) {
return
}
// Why: this list is the host answering, but only the frame's own decision
// says whether that answer is evidence — a workspace the mirror never
// writes is discarded with nothing accepted behind it.
const decision = decideWebSessionTabsSnapshot(snapshot, environmentId)
const settleMirror = applyWebSessionTabsStorePatch(
(state) => {
// Why: eager refreshes can resolve after the user switched worktrees; update tabs without stealing focus.
const patch = decision.apply
? applyWebSessionTabsSnapshot(state, snapshot, environmentId)
: state
return patch === state ? state : patch
},
{
frames: [
{
environmentId,
worktreeId: snapshot.worktree,
decision,
expectedEnvironmentConnectionGeneration,
expectedEnvironmentPairingRevision,
expectedTrackingGeneration
}
]
},
snapshot
)
settleMirror()
} catch (error) {
if (options.errorMode === 'throw') {
throw error
}
// Why: host creation already succeeded; the long-lived session.tabs subscription catches up if this eager refresh fails.
console.warn(
'[web-runtime-session] failed to refresh session-tabs snapshot:',
error instanceof Error ? error.message : String(error)
)
}
}
export function scheduleRuntimeWorktreeRecoveryRefresh(
environmentId: string,
worktreeId: string,
expectedEnvironmentPairingRevision = getRuntimeEnvironmentRevision(environmentId)
): void {
const initialState = useAppStore.getState()
if (!('tabsByWorktree' in initialState)) {
return
}
if ((initialState.tabsByWorktree[worktreeId] ?? []).length > 0) {
return
}
const key = `${environmentId}\0${expectedEnvironmentPairingRevision ?? ''}\0${worktreeId}`
const token = Symbol(key)
pendingRuntimeWorktreeRecoveryRefreshes.set(key, token)
void (async () => {
try {
for (const delayMs of RUNTIME_WORKTREE_RECOVERY_REFRESH_DELAYS_MS) {
await new Promise<void>((resolve) => setTimeout(resolve, delayMs))
if (pendingRuntimeWorktreeRecoveryRefreshes.get(key) !== token) {
return
}
if (getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision) {
return
}
await refreshWebRuntimeSessionTabsSnapshot(environmentId, worktreeId, {
expectedEnvironmentPairingRevision
})
if ((useAppStore.getState().tabsByWorktree[worktreeId] ?? []).length > 0) {
return
}
}
} finally {
if (pendingRuntimeWorktreeRecoveryRefreshes.get(key) === token) {
pendingRuntimeWorktreeRecoveryRefreshes.delete(key)
}
}
})()
}
@@ -0,0 +1,176 @@
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import type {
RuntimeMobileSessionTabCloseResult,
RuntimeSessionTabCloseReason
} from '../../../shared/runtime-types'
import { useAppStore } from '../store'
import { hasRuntimeRpcErrorCode, unwrapRuntimeRpcResult } from './runtime-rpc-client'
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
import { clearWebSessionCloseIntent, recordWebSessionCloseIntent } from './web-session-close-intent'
import {
clearWebSessionFocusIntentIfMatches,
recordWebSessionFocusIntent
} from './web-session-focus-intent'
import { toHostSessionTabId } from './web-terminal-surface-id'
import {
captureRuntimeEnvironmentCall,
captureWebSessionIntentOwner,
isWebRuntimeSessionActive
} from './web-runtime-session-environment'
import { refreshWebRuntimeSessionTabsSnapshot } from './web-runtime-session-snapshot'
export async function activateWebRuntimeSessionTab(args: {
worktreeId: string
tabId: string
environmentId?: string | null
}): Promise<boolean> {
return (await callWebRuntimeSessionTabMethod('session.tabs.activate', args)) === 'applied'
}
/**
* Why 'unknown-tab' is its own outcome: it is the host's definitive answer that it has no such
* tab, which is the only evidence that lets a client finish a teardown the host cannot. Every
* other failure -- a dropped connection, a timeout -- is a "not now", and treating it the same
* would tear down tabs a reachable host still holds.
*/
export type WebRuntimeSessionTabCloseOutcome = 'applied' | 'unknown-tab' | 'failed'
export async function closeWebRuntimeSessionTab(args: {
worktreeId: string
tabId: string
environmentId?: string | null
reason: RuntimeSessionTabCloseReason
publicationEpoch?: string | null
terminalHandle?: string | null
}): Promise<WebRuntimeSessionTabCloseOutcome> {
return callWebRuntimeSessionTabMethod('session.tabs.close', args)
}
async function callWebRuntimeSessionTabMethod(
method: 'session.tabs.activate' | 'session.tabs.close',
args: {
worktreeId: string
tabId: string
environmentId?: string | null
reason?: RuntimeSessionTabCloseReason
publicationEpoch?: string | null
terminalHandle?: string | null
}
): Promise<WebRuntimeSessionTabCloseOutcome> {
const environmentId =
args.environmentId?.trim() ??
useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim() ??
null
if (!environmentId || !isWebRuntimeSessionActive(environmentId)) {
return 'failed'
}
const intentOwner = captureWebSessionIntentOwner(environmentId)
const callEnvironment = captureRuntimeEnvironmentCall(environmentId, intentOwner.pairingRevision)
const closeIntentTabIds = new Set<string>()
let activationHostTabId: string | null = null
const isClose = method === 'session.tabs.close'
const isLifecycleClose = isClose && args.reason !== 'user'
if (isLifecycleClose && (!args.publicationEpoch || !args.terminalHandle)) {
// Why: missing host-generation or terminal-incarnation evidence means keep;
// a tab id alone can be stale or reused after reconnect.
const { acceptReplayedWebSessionTabsSnapshot } = await import('./web-session-tabs-sync')
acceptReplayedWebSessionTabsSnapshot(environmentId, args.worktreeId)
await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId)
console.warn('[web-runtime-session] suppressed lifecycle close without incarnation evidence', {
closeReason: args.reason
})
return 'failed'
}
const immediateHostTabId = toHostSessionTabId(args.tabId)
if (isClose) {
// Why: record before async id resolution so a stale snapshot cannot flash the closed tab back.
closeIntentTabIds.add(immediateHostTabId)
recordWebSessionCloseIntent(intentOwner, args.worktreeId, immediateHostTabId, Date.now())
}
try {
const { resolveHostSessionTabIdForWebSessionTab } = await import('./web-session-tabs-sync')
const state = useAppStore.getState()
const hostTabId =
resolveHostSessionTabIdForWebSessionTab(state, {
environmentId,
worktreeId: args.worktreeId,
tabId: args.tabId
}) ?? toHostSessionTabId(args.tabId)
if (isClose) {
// Why: suppress until the host confirms removal, else an in-flight pre-close snapshot flashes the tab back.
closeIntentTabIds.add(hostTabId)
recordWebSessionCloseIntent(intentOwner, args.worktreeId, hostTabId, Date.now())
} else {
activationHostTabId = hostTabId
recordWebSessionFocusIntent(intentOwner, args.worktreeId, hostTabId)
}
const response = await callEnvironment({
// Why: old hosts cannot route this additive method, so a generation
// cutover fails closed before their destructive legacy close handler.
method: isLifecycleClose ? 'session.tabs.closeLifecycle' : method,
params: {
worktree: toRuntimeWorktreeSelector(args.worktreeId),
tabId: hostTabId,
...(method === 'session.tabs.activate'
? {
// Why: the additive navigation target protects new hosts while notifyClients:false protects old hosts.
notifyClients: false,
navigation: 'caller' as const,
// Why: every caller here is a tab click, shortcut, or palette pick —
// the gesture that is supposed to wake a slept pane.
intent: 'user' as const
}
: {}),
...(isLifecycleClose
? {
reason: args.reason,
publicationEpoch: args.publicationEpoch,
terminal: args.terminalHandle
}
: isClose
? { reason: args.reason }
: {})
},
timeoutMs: 15_000
})
const result = unwrapRuntimeRpcResult(
response as RuntimeRpcResponse<RuntimeMobileSessionTabCloseResult | undefined>
)
if (isClose) {
if (result?.refused === true && result.snapshotRepublished === true) {
// Why: the host kept an authoritative live PTY. Stop hiding its mirror
// only when it republished; dead-leaf refusals must stay suppressed.
clearWebSessionCloseIntent(intentOwner, args.worktreeId, immediateHostTabId)
clearWebSessionCloseIntent(intentOwner, args.worktreeId, hostTabId)
const { acceptReplayedWebSessionTabsSnapshot } = await import('./web-session-tabs-sync')
acceptReplayedWebSessionTabsSnapshot(environmentId, args.worktreeId)
}
await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId, {
expectedEnvironmentPairingRevision: intentOwner.pairingRevision
})
}
return 'applied'
} catch (error) {
if (activationHostTabId) {
clearWebSessionFocusIntentIfMatches(intentOwner, args.worktreeId, activationHostTabId)
}
for (const hostTabId of closeIntentTabIds) {
clearWebSessionCloseIntent(intentOwner, args.worktreeId, hostTabId)
}
if (isLifecycleClose) {
const { acceptReplayedWebSessionTabsSnapshot } = await import('./web-session-tabs-sync')
acceptReplayedWebSessionTabsSnapshot(environmentId, args.worktreeId)
await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId, {
expectedEnvironmentPairingRevision: intentOwner.pairingRevision
})
}
console.warn(
`[web-runtime-session] failed to ${isClose ? 'close' : 'activate'} tab:`,
error instanceof Error ? error.message : String(error)
)
return hasRuntimeRpcErrorCode(error, 'tab_not_found') ? 'unknown-tab' : 'failed'
}
}
@@ -0,0 +1,125 @@
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import type {
RuntimeMobileSessionTabMove,
RuntimeMobileSessionTabMoveResult
} from '../../../shared/runtime-types'
import { useAppStore } from '../store'
import { unwrapRuntimeRpcResult } from './runtime-rpc-client'
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
import {
clearWebSessionReorderIntent,
recordWebSessionReorderIntent
} from './web-session-reorder-intent'
import { isWebTerminalSurfaceTabId, toHostSessionTabId } from './web-terminal-surface-id'
import {
captureRuntimeEnvironmentCall,
captureWebSessionIntentOwner,
isWebRuntimeSessionActive
} from './web-runtime-session-environment'
export async function moveWebRuntimeSessionTab(
args: RuntimeMobileSessionTabMove & {
worktreeId: string
environmentId?: string | null
}
): Promise<boolean> {
const environmentId =
args.environmentId?.trim() ??
useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim() ??
null
if (!environmentId || !isWebRuntimeSessionActive(environmentId)) {
return false
}
const intentOwner = captureWebSessionIntentOwner(environmentId)
const callEnvironment = captureRuntimeEnvironmentCall(environmentId, intentOwner.pairingRevision)
if (args.kind === 'reorder') {
// Why: record local order synchronously before async host resolution, so a pre-move snapshot can't snap the tab back.
recordWebSessionReorderIntent(
intentOwner,
args.worktreeId,
args.targetGroupId,
args.tabOrder,
Date.now()
)
}
try {
const { resolveHostSessionTabIdForWebSessionTab } = await import('./web-session-tabs-sync')
const state = useAppStore.getState()
const resolveHostBackedTabId = (tabId: string): string | null =>
resolveHostSessionTabIdForWebSessionTab(state, {
environmentId,
worktreeId: args.worktreeId,
tabId
}) ?? (isWebTerminalSurfaceTabId(tabId) ? toHostSessionTabId(tabId) : null)
const toHostTabId = (tabId: string): string => resolveHostBackedTabId(tabId) ?? tabId
const movedHostTabId =
args.kind === 'reorder' ? resolveHostBackedTabId(args.tabId) : toHostTabId(args.tabId)
if (!movedHostTabId) {
clearWebSessionReorderIntent(intentOwner, args.worktreeId, args.targetGroupId)
return false
}
const reorderedHostTabOrder =
args.kind === 'reorder'
? args.tabOrder
.map(resolveHostBackedTabId)
.filter((tabId): tabId is string => Boolean(tabId))
: null
if (reorderedHostTabOrder && !reorderedHostTabOrder.includes(movedHostTabId)) {
clearWebSessionReorderIntent(intentOwner, args.worktreeId, args.targetGroupId)
return false
}
const targetHostIndex =
args.kind === 'move-to-group' && typeof args.index === 'number'
? (state.groupsByWorktree?.[args.worktreeId]
?.find((group) => group.id === args.targetGroupId)
?.tabOrder.slice(0, args.index)
.map(resolveHostBackedTabId)
.filter((tabId): tabId is string => Boolean(tabId)).length ?? args.index)
: args.kind === 'move-to-group'
? args.index
: undefined
const base = {
worktree: toRuntimeWorktreeSelector(args.worktreeId),
tabId: movedHostTabId,
targetGroupId: args.targetGroupId
}
const move =
args.kind === 'reorder'
? {
...base,
kind: 'reorder' as const,
// Why: the host reorder API only accepts host tab ids, so local-only tabs must be omitted from the mirrored order.
tabOrder: reorderedHostTabOrder
}
: args.kind === 'split'
? {
...base,
kind: 'split' as const,
splitDirection: args.splitDirection
}
: {
...base,
kind: 'move-to-group' as const,
// Why: web groups can contain local-only tabs, so host insertion indexes count only the filtered host-backed order.
index: targetHostIndex
}
const response = await callEnvironment({
method: 'session.tabs.move',
params: move,
timeoutMs: 15_000
})
unwrapRuntimeRpcResult(response as RuntimeRpcResponse<RuntimeMobileSessionTabMoveResult>)
return true
} catch (error) {
if (args.kind === 'reorder') {
clearWebSessionReorderIntent(intentOwner, args.worktreeId, args.targetGroupId)
}
console.warn(
'[web-runtime-session] failed to move tab:',
error instanceof Error ? error.message : String(error)
)
return false
}
}
@@ -0,0 +1,50 @@
import type {
AgentProviderSessionMetadata,
SleepingAgentLaunchConfig
} from '../../../shared/agent-session-resume'
import type {
AgentLaunchPreferences,
AgentPromptDelivery
} from '../../../shared/agent-session-host-authority'
import type { StartupCommandDelivery } from '../../../shared/codex-startup-delivery'
import type { RuntimeTerminalCreate } from '../../../shared/runtime-types'
import type { TuiAgent } from '../../../shared/tui-agent'
export type WebRuntimeTerminalCreateOutcome =
| { status: 'created' }
| { status: 'failed'; message: string }
export type CreateWebRuntimeSessionTerminalArgs = {
worktreeId: string
environmentId?: string | null
afterTabId?: string
targetGroupId?: string
command?: string
cwd?: string
env?: Record<string, string>
envToDelete?: string[]
startupCommandDelivery?: StartupCommandDelivery
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
agent?: TuiAgent
launchAgent?: TuiAgent
agentSessionKind?: 'fresh' | 'resume'
prompt?: string
promptDelivery?: AgentPromptDelivery
/** Explicit CLI override; omission leaves the remote host's defaults authoritative. */
agentArgs?: string | null
launchPreferences?: AgentLaunchPreferences
providerSession?: AgentProviderSessionMetadata
viewMode?: 'terminal' | 'chat'
activate?: boolean
selectWorktree?: boolean
}
export type CreatedWebRuntimeSessionTerminal = {
outcome: WebRuntimeTerminalCreateOutcome
hostTabId?: string
}
export type CreatedAgentTerminalIdentity = Pick<RuntimeTerminalCreate, 'tabId' | 'paneKey'> & {
leafId?: string
}
@@ -0,0 +1,52 @@
import { toRuntimeExecutionHostId } from '../../../shared/execution-host'
import { useAppStore } from '../store'
import {
shouldRestoreWebRuntimeSessionWorkspaceSelection,
type WebRuntimeSessionWorkspaceSelection
} from './web-runtime-session-workspace-routing'
export function selectWebRuntimeSessionWorktree(worktreeId: string, environmentId: string): void {
useAppStore.getState().setActiveWorktree(worktreeId, toRuntimeExecutionHostId(environmentId))
}
export type WebRuntimeSessionWorkspaceSelectionRollback = {
previous: WebRuntimeSessionWorkspaceSelection
applied: WebRuntimeSessionWorkspaceSelection
}
export function readActiveWorkspaceSelection(): WebRuntimeSessionWorkspaceSelection {
const state = useAppStore.getState()
return {
worktreeId: state.activeWorktreeId ?? null,
executionHostId: state.activeWorkspaceExecutionHostId ?? null
}
}
export function restoreActiveWorkspaceSelection(
rollback: WebRuntimeSessionWorkspaceSelectionRollback
): void {
if (
!shouldRestoreWebRuntimeSessionWorkspaceSelection({
...rollback,
current: readActiveWorkspaceSelection()
})
) {
return
}
useAppStore
.getState()
.setActiveWorktree(rollback.previous.worktreeId, rollback.previous.executionHostId ?? undefined)
}
export function selectWebRuntimeSessionBrowserWorktree(
worktreeId: string,
environmentId: string
): void {
const state = useAppStore.getState()
if (
state.activeWorktreeId !== worktreeId ||
state.activeWorkspaceExecutionHostId !== toRuntimeExecutionHostId(environmentId)
) {
state.setActiveWorktree(worktreeId, toRuntimeExecutionHostId(environmentId))
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,288 @@
import { toast } from 'sonner'
import type { TerminalPaneSplitSource } from '../../../shared/feature-education-telemetry'
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import type { RuntimeTerminalClose, RuntimeTerminalSplit } from '../../../shared/runtime-types'
import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types'
import { getRuntimeEnvironmentIdForWorktree } from '../lib/worktree-runtime-owner'
import { useAppStore } from '../store'
import { unwrapRuntimeRpcResult } from './runtime-rpc-client'
import { parseRemoteRuntimePtyId } from './runtime-terminal-stream'
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
import { isWebTerminalSurfaceTabId, toHostSessionTabId } from './web-terminal-surface-id'
import {
captureRuntimeEnvironmentCall,
isWebRuntimeSessionActive
} from './web-runtime-session-environment'
const pendingWebRuntimeSplitMirrorTelemetry = new Map<string, Set<string>>()
const WEB_RUNTIME_SPLIT_MIRROR_SUPPRESSION_TTL_MS = 30_000
let pendingWebRuntimeSplitMirrorTelemetryId = 0
export function splitWebRuntimeTerminal(
ptyId: string | null | undefined,
direction: 'horizontal' | 'vertical',
telemetrySource: TerminalPaneSplitSource
): boolean {
if (!ptyId) {
return false
}
const remote = parseRemoteRuntimePtyId(ptyId)
const environmentId = remote?.environmentId?.trim()
if (!remote || !environmentId || !isWebRuntimeSessionActive(environmentId)) {
return false
}
// Why: split must run on the host pane; a local split mints a web-only pane the host mirrors back as a tab, not a split.
const pendingMirrorSuppressionId = reservePendingWebRuntimeSplitMirrorTelemetry(ptyId, direction)
const releasePendingMirrorSuppression = schedulePendingWebRuntimeSplitMirrorTelemetryRelease(
ptyId,
direction,
pendingMirrorSuppressionId
)
void window.api.runtimeEnvironments
.call({
selector: environmentId,
method: 'terminal.split',
params: {
terminal: remote.handle,
direction,
telemetrySource
},
timeoutMs: 15_000
})
.then((response) => {
unwrapRuntimeRpcResult(response as RuntimeRpcResponse<{ split: RuntimeTerminalSplit }>)
})
.catch((error) => {
releasePendingMirrorSuppression()
const message = error instanceof Error ? error.message : String(error)
// Why: a split that fails only in the console leaves the user with a pane that silently
// never appears.
toast.error(message)
console.warn('[web-runtime-session] failed to split terminal:', message)
})
return true
}
export function consumePendingWebRuntimeSplitMirrorTelemetry(
sourcePtyId: string | null | undefined,
direction: 'horizontal' | 'vertical'
): boolean {
if (!sourcePtyId) {
return false
}
const key = getPendingWebRuntimeSplitMirrorTelemetryKey(sourcePtyId, direction)
const ids = pendingWebRuntimeSplitMirrorTelemetry.get(key)
const id = ids?.values().next().value
if (!ids || !id) {
return false
}
ids.delete(id)
if (ids.size === 0) {
pendingWebRuntimeSplitMirrorTelemetry.delete(key)
}
return true
}
function reservePendingWebRuntimeSplitMirrorTelemetry(
sourcePtyId: string,
direction: 'horizontal' | 'vertical'
): string {
const id = String(++pendingWebRuntimeSplitMirrorTelemetryId)
const key = getPendingWebRuntimeSplitMirrorTelemetryKey(sourcePtyId, direction)
const ids = pendingWebRuntimeSplitMirrorTelemetry.get(key) ?? new Set<string>()
ids.add(id)
pendingWebRuntimeSplitMirrorTelemetry.set(key, ids)
return id
}
function schedulePendingWebRuntimeSplitMirrorTelemetryRelease(
sourcePtyId: string,
direction: 'horizontal' | 'vertical',
id: string
): () => void {
let released = false
const release = (): void => {
if (released) {
return
}
released = true
releasePendingWebRuntimeSplitMirrorTelemetry(sourcePtyId, direction, id)
}
const timeout = globalThis.setTimeout(release, WEB_RUNTIME_SPLIT_MIRROR_SUPPRESSION_TTL_MS)
return () => {
globalThis.clearTimeout(timeout)
release()
}
}
function releasePendingWebRuntimeSplitMirrorTelemetry(
sourcePtyId: string,
direction: 'horizontal' | 'vertical',
id: string
): void {
const key = getPendingWebRuntimeSplitMirrorTelemetryKey(sourcePtyId, direction)
const ids = pendingWebRuntimeSplitMirrorTelemetry.get(key)
if (!ids) {
return
}
ids.delete(id)
if (ids.size === 0) {
pendingWebRuntimeSplitMirrorTelemetry.delete(key)
}
}
function getPendingWebRuntimeSplitMirrorTelemetryKey(
sourcePtyId: string,
direction: 'horizontal' | 'vertical'
): string {
return `${direction}:${sourcePtyId}`
}
export function closeWebRuntimeTerminal(ptyId: string | null | undefined): boolean {
if (!ptyId) {
return false
}
const remote = parseRemoteRuntimePtyId(ptyId)
const environmentId = remote?.environmentId?.trim()
if (!remote || !environmentId || !isWebRuntimeSessionActive(environmentId)) {
return false
}
// Why: host owns the real pane graph; close the host terminal first so later snapshots can't resurrect the removed pane.
void window.api.runtimeEnvironments
.call({
selector: environmentId,
method: 'terminal.close',
params: {
terminal: remote.handle
},
timeoutMs: 15_000
})
.then((response) => {
unwrapRuntimeRpcResult(response as RuntimeRpcResponse<{ close: RuntimeTerminalClose }>)
})
.catch((error) => {
console.warn(
'[web-runtime-session] failed to close terminal pane:',
error instanceof Error ? error.message : String(error)
)
})
return true
}
// Why: pane geometry is host-authoritative for remote tabs; local-only changes revert on next snapshot, so push to host.
export async function updateWebRuntimePaneLayout(args: {
worktreeId: string
tabId: string
root: TerminalPaneLayoutNode | null
expandedLeafId: string | null
titlesByLeafId?: Record<string, string>
}): Promise<boolean> {
const environmentId =
getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), args.worktreeId) ?? null
if (!environmentId || !isWebRuntimeSessionActive(environmentId)) {
return false
}
const callEnvironment = captureRuntimeEnvironmentCall(environmentId)
const hostTabId = isWebTerminalSurfaceTabId(args.tabId)
? toHostSessionTabId(args.tabId)
: args.tabId
try {
const response = await callEnvironment({
method: 'session.tabs.updatePaneLayout',
params: {
worktree: toRuntimeWorktreeSelector(args.worktreeId),
tabId: hostTabId,
root: args.root,
expandedLeafId: args.expandedLeafId,
...(args.titlesByLeafId ? { titlesByLeafId: args.titlesByLeafId } : {})
},
timeoutMs: 15_000
})
unwrapRuntimeRpcResult(response as RuntimeRpcResponse<{ updated: true }>)
return true
} catch (error) {
console.warn(
'[web-runtime-session] failed to update pane layout:',
error instanceof Error ? error.message : String(error)
)
return false
}
}
// Why: tab color/pin are host-authoritative; mirror the change so it persists (undefined field = leave as-is on host).
export function setWebRuntimeTabProps(args: {
worktreeId: string
tabId: string
color?: string | null
isPinned?: boolean
viewMode?: 'terminal' | 'chat'
}): boolean {
const environmentId =
getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), args.worktreeId) ?? null
if (!environmentId || !isWebRuntimeSessionActive(environmentId)) {
return false
}
const callEnvironment = captureRuntimeEnvironmentCall(environmentId)
const state = useAppStore.getState()
void import('./web-session-tabs-sync')
.then(({ resolveHostSessionTabIdForWebSessionTab }) => {
const hostTabId =
resolveHostSessionTabIdForWebSessionTab(state, {
environmentId,
worktreeId: args.worktreeId,
tabId: args.tabId
}) ?? (isWebTerminalSurfaceTabId(args.tabId) ? toHostSessionTabId(args.tabId) : args.tabId)
return callEnvironment({
method: 'session.tabs.setTabProps',
params: {
worktree: toRuntimeWorktreeSelector(args.worktreeId),
tabId: hostTabId,
...(args.color !== undefined ? { color: args.color } : {}),
...(args.isPinned !== undefined ? { isPinned: args.isPinned } : {}),
...(args.viewMode !== undefined ? { viewMode: args.viewMode } : {})
},
timeoutMs: 15_000
})
})
.then((response) => {
unwrapRuntimeRpcResult(response as RuntimeRpcResponse<{ updated: true }>)
})
.catch((error) => {
console.warn(
'[web-runtime-session] failed to set tab props:',
error instanceof Error ? error.message : String(error)
)
})
return true
}
// Why: local pane.terminal.clear() is undone by the next host snapshot replay; clear the host buffer so it sticks.
export function clearWebRuntimeTerminalBuffer(ptyId: string | null | undefined): boolean {
if (!ptyId) {
return false
}
const remote = parseRemoteRuntimePtyId(ptyId)
const environmentId = remote?.environmentId?.trim()
if (!remote || !environmentId || !isWebRuntimeSessionActive(environmentId)) {
return false
}
void window.api.runtimeEnvironments
.call({
selector: environmentId,
method: 'terminal.clearBuffer',
params: { terminal: remote.handle },
timeoutMs: 15_000
})
.then((response) => {
unwrapRuntimeRpcResult(response as RuntimeRpcResponse<{ clear: unknown }>)
})
.catch((error) => {
console.warn(
'[web-runtime-session] failed to clear terminal buffer:',
error instanceof Error ? error.message : String(error)
)
})
return true
}
@@ -0,0 +1,297 @@
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import type { RuntimeMobileSessionCreateTerminalResult } from '../../../shared/runtime-types'
import type {
RuntimeCreateAgentSessionResult,
RuntimeEnsureAgentSessionResult
} from '../../../shared/agent-session-host-authority'
import { toRuntimeExecutionHostId } from '../../../shared/execution-host'
import { translate } from '../i18n/i18n'
import { useAppStore } from '../store'
import { agentResumeHostAuthorityCapability } from './agent-resume-host-authority-capability'
import {
createAgentSessionCreateOperation,
withAgentSessionCreateOperationId
} from './agent-session-create-operation'
import { runRemoteAgentSessionLaunch } from './remote-agent-session-launch'
import { unwrapRuntimeRpcResult } from './runtime-rpc-client'
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
import { resolveWebRuntimeSessionEnvironmentId } from './web-runtime-session-workspace-routing'
import { recordWebSessionFocusIntent } from './web-session-focus-intent'
import {
forgetWebSessionTerminalPlacement,
recordWebSessionTerminalPlacement,
webTerminalPlacementParentTabId
} from './web-session-terminal-placement'
import { toHostSessionTabId } from './web-terminal-surface-id'
import {
captureRuntimeEnvironmentCall,
captureWebSessionIntentOwner,
isWebRuntimeSessionActive,
matchesWebSessionIntentOwner
} from './web-runtime-session-environment'
import { refreshWebRuntimeSessionTabsSnapshot } from './web-runtime-session-snapshot'
import type {
CreatedAgentTerminalIdentity,
CreatedWebRuntimeSessionTerminal,
CreateWebRuntimeSessionTerminalArgs
} from './web-runtime-session-types'
import {
readActiveWorkspaceSelection,
restoreActiveWorkspaceSelection,
selectWebRuntimeSessionWorktree,
type WebRuntimeSessionWorkspaceSelectionRollback
} from './web-runtime-session-workspace-selection'
import { createdTerminalLeafId } from './web-runtime-terminal-identity'
import { settleWebRuntimeTerminalPlacement } from './web-runtime-terminal-placement-settlement'
export async function createWebRuntimeSessionTerminalResult(
args: CreateWebRuntimeSessionTerminalArgs
): Promise<CreatedWebRuntimeSessionTerminal> {
const environmentId = resolveWebRuntimeSessionEnvironmentId(
args.environmentId,
useAppStore.getState().settings?.activeRuntimeEnvironmentId
)
if (!environmentId || !isWebRuntimeSessionActive(environmentId)) {
return {
outcome: {
status: 'failed',
message: translate(
'auto.runtime.webRuntimeSession.remoteHostDisconnected',
'The workspace is not connected to a remote Orca host.'
)
}
}
}
const intentOwner = captureWebSessionIntentOwner(environmentId)
const callEnvironment = captureRuntimeEnvironmentCall(environmentId, intentOwner.pairingRevision)
let workspaceSelectionRollback: WebRuntimeSessionWorkspaceSelectionRollback | null = null
if (args.selectWorktree !== false) {
const previous = readActiveWorkspaceSelection()
selectWebRuntimeSessionWorktree(args.worktreeId, environmentId)
workspaceSelectionRollback = {
previous,
applied: {
worktreeId: args.worktreeId,
executionHostId: toRuntimeExecutionHostId(environmentId)
}
}
}
let hostCreated = false
let createdTabId: string | undefined
let createdLeafId: string | undefined
try {
const agent = args.launchAgent ?? args.agent
const agentArgsOverride =
args.agentArgs !== undefined ? args.agentArgs : args.launchConfig?.agentArgs
if (agent) {
let legacyAlreadyPlacedInGroup = false
// Why: structured creation cannot yet express afterTabId; keep the exact legacy placement contract until it can.
// Why: focus belongs to the paired client; a headless execution host has no renderer to focus.
const hostAuthority = args.afterTabId
? undefined
: args.agentSessionKind === 'resume'
? args.providerSession
? async () =>
unwrapRuntimeRpcResult(
(await callEnvironment({
method: 'terminal.ensureAgentSession',
params: {
kind: 'explicit',
worktree: toRuntimeWorktreeSelector(args.worktreeId),
agent,
providerSession: args.providerSession!,
...(args.launchConfig?.ompResumeFilePath
? { ompResumeFilePath: args.launchConfig.ompResumeFilePath }
: {}),
...(agentArgsOverride !== undefined ? { agentArgs: agentArgsOverride } : {}),
...(args.launchPreferences
? { launchPreferences: args.launchPreferences }
: {}),
presentation: 'background'
},
timeoutMs: 15_000
})) as RuntimeRpcResponse<RuntimeEnsureAgentSessionResult>
)
: undefined
: async () =>
await createAgentSessionCreateOperation().run(async (clientOperationId) =>
unwrapRuntimeRpcResult(
(await callEnvironment({
method: 'terminal.createAgentSession',
params: withAgentSessionCreateOperationId(
{
worktree: toRuntimeWorktreeSelector(args.worktreeId),
agent,
...(args.prompt ? { prompt: args.prompt } : {}),
...(args.promptDelivery ? { promptDelivery: args.promptDelivery } : {}),
...(agentArgsOverride !== undefined
? { agentArgs: agentArgsOverride }
: {}),
...(args.launchPreferences
? { launchPreferences: args.launchPreferences }
: {}),
...(args.cwd ? { startupCwd: args.cwd } : {}),
...(args.viewMode ? { viewMode: args.viewMode } : {}),
presentation: 'background'
},
clientOperationId
),
timeoutMs: 15_000
})) as RuntimeRpcResponse<RuntimeCreateAgentSessionResult>
)
)
const resumeHostAuthorityCapability =
args.agentSessionKind === 'resume' ? agentResumeHostAuthorityCapability(agent) : undefined
const created = await runRemoteAgentSessionLaunch<{
terminal: CreatedAgentTerminalIdentity
}>({
environmentId,
...(hostAuthority ? { hostAuthority } : {}),
...(resumeHostAuthorityCapability
? { hostAuthorityCapability: resumeHostAuthorityCapability }
: {}),
legacy: async () => {
const response = await callEnvironment({
method: 'session.tabs.createTerminal',
params: {
worktree: toRuntimeWorktreeSelector(args.worktreeId),
afterTabId: args.afterTabId ? toHostSessionTabId(args.afterTabId) : undefined,
targetGroupId: args.targetGroupId,
command: args.command,
cwd: args.cwd,
...(args.env ? { env: args.env } : {}),
...(args.envToDelete ? { envToDelete: args.envToDelete } : {}),
startupCommandDelivery: args.startupCommandDelivery,
...(args.launchConfig ? { launchConfig: args.launchConfig } : {}),
...(args.launchToken ? { launchToken: args.launchToken } : {}),
...(args.agent ? { agent: args.agent } : {}),
...(args.launchAgent ? { launchAgent: args.launchAgent } : {}),
...(args.viewMode ? { viewMode: args.viewMode } : {}),
// Why: old hosts understand activate:false; new hosts use select/navigation for caller-local focus.
activate: false,
select: args.activate !== false,
navigation: 'caller'
},
timeoutMs: 15_000
})
const legacyCreated = unwrapRuntimeRpcResult(
response as RuntimeRpcResponse<RuntimeMobileSessionCreateTerminalResult>
)
legacyAlreadyPlacedInGroup = true
return {
terminal: {
tabId: legacyCreated.tab.id,
leafId: legacyCreated.tab.leafId
}
}
}
})
hostCreated = true
createdTabId = created.terminal.tabId
createdLeafId = legacyAlreadyPlacedInGroup
? created.terminal.leafId
: createdTerminalLeafId(created.terminal)
if (args.targetGroupId && createdTabId && !legacyAlreadyPlacedInGroup) {
await callEnvironment({
method: 'session.tabs.move',
params: {
worktree: toRuntimeWorktreeSelector(args.worktreeId),
tabId: createdTabId,
targetGroupId: args.targetGroupId,
kind: 'move-to-group'
},
timeoutMs: 15_000
})
}
} else {
const response = await callEnvironment({
method: 'session.tabs.createTerminal',
params: {
worktree: toRuntimeWorktreeSelector(args.worktreeId),
afterTabId: args.afterTabId ? toHostSessionTabId(args.afterTabId) : undefined,
targetGroupId: args.targetGroupId,
command: args.command,
cwd: args.cwd,
...(args.env ? { env: args.env } : {}),
...(args.envToDelete ? { envToDelete: args.envToDelete } : {}),
startupCommandDelivery: args.startupCommandDelivery,
...(args.launchConfig ? { launchConfig: args.launchConfig } : {}),
...(args.launchToken ? { launchToken: args.launchToken } : {}),
...(args.viewMode ? { viewMode: args.viewMode } : {}),
// Why: old hosts understand activate:false; new hosts use select/navigation for caller-local focus.
activate: false,
select: args.activate !== false,
navigation: 'caller'
},
timeoutMs: 15_000
})
const created = unwrapRuntimeRpcResult(
response as RuntimeRpcResponse<RuntimeMobileSessionCreateTerminalResult>
)
hostCreated = true
createdTabId = created.tab.id
createdLeafId = created.tab.leafId
}
if (args.targetGroupId && createdTabId) {
// Why: the host drops client-minted group ids, so this client's own record is what
// lands the mirrored tab in the requested pane under client-owned placement.
recordWebSessionTerminalPlacement({
environmentId,
worktreeId: args.worktreeId,
hostTabId: webTerminalPlacementParentTabId(createdTabId),
groupId: args.targetGroupId
})
}
if (args.activate !== false && createdTabId && matchesWebSessionIntentOwner(intentOwner)) {
// Why: record focus intent so the reconcile follows the snapshot's active
// tab to THIS new terminal, instead of sticky-keeping the prior tab.
recordWebSessionFocusIntent(intentOwner, args.worktreeId, createdTabId, createdLeafId)
}
await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId, {
expectedEnvironmentPairingRevision: intentOwner.pairingRevision,
// Why: the publication can beat the RPC response; replay it once after caller intent exists.
acceptCurrentSnapshot:
Boolean(createdTabId) && (args.activate !== false || Boolean(args.targetGroupId)),
// Why: a placement record needs a post-create list; a deduped in-flight one can predate it.
...(args.targetGroupId && createdTabId ? { afterCurrentInFlight: true } : {})
})
if (args.targetGroupId && createdTabId) {
await settleWebRuntimeTerminalPlacement(
environmentId,
args.worktreeId,
webTerminalPlacementParentTabId(createdTabId),
{ groupId: args.targetGroupId, activate: args.activate !== false }
)
}
return {
outcome: { status: 'created' },
...(createdTabId ? { hostTabId: createdTabId } : {})
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.warn(
hostCreated
? '[web-runtime-session] terminal created but reconciliation failed:'
: '[web-runtime-session] failed to create terminal:',
message
)
if (createdTabId) {
// Why: a record that outlives the create flow could yank a user-dragged tab back later.
forgetWebSessionTerminalPlacement({
environmentId,
worktreeId: args.worktreeId,
hostTabId: webTerminalPlacementParentTabId(createdTabId)
})
}
if (!hostCreated && workspaceSelectionRollback) {
restoreActiveWorkspaceSelection(workspaceSelectionRollback)
}
// Why: once the host accepted creation, reporting failure invites the user
// to retry with a new operation ID and can duplicate a fresh agent.
return {
outcome: hostCreated ? { status: 'created' } : { status: 'failed', message },
...(createdTabId ? { hostTabId: createdTabId } : {})
}
}
}
@@ -0,0 +1,65 @@
import type { TuiAgent } from '../../../shared/tui-agent'
import {
deliverLaunchPromptToAgentTab,
seedNativeChatLaunchDraftForAgentTab
} from '../lib/agent-launch-prompt-delivery'
import { createWebRuntimeSessionTerminalResult } from './web-runtime-terminal-create-operation'
import { toWebTerminalSurfaceTabId } from './web-terminal-surface-id'
import type {
CreateWebRuntimeSessionTerminalArgs,
WebRuntimeTerminalCreateOutcome
} from './web-runtime-session-types'
export async function createWebRuntimeSessionTerminal(
args: CreateWebRuntimeSessionTerminalArgs
): Promise<WebRuntimeTerminalCreateOutcome> {
return (await createWebRuntimeSessionTerminalResult(args)).outcome
}
export async function createWebRuntimeAgentSessionTerminal(
args: CreateWebRuntimeSessionTerminalArgs & {
agent: TuiAgent
promptAfterReady: string
submitPrompt: boolean
forcePromptPaste: boolean
}
): Promise<{
outcome: WebRuntimeTerminalCreateOutcome
promptDelivered: boolean
}> {
const created = await createWebRuntimeSessionTerminalResult(args)
if (created.outcome.status === 'failed' || !created.hostTabId) {
return { outcome: created.outcome, promptDelivered: false }
}
const promptDelivered = await deliverLaunchPromptToAgentTab({
tabId: toWebTerminalSurfaceTabId(created.hostTabId),
content: args.promptAfterReady,
agent: args.agent,
submit: args.submitPrompt,
forcePaste: args.forcePromptPaste
})
return { outcome: created.outcome, promptDelivered }
}
/**
* Launch a web-host agent terminal whose draft already rode in on the launch
* command (argv prefill). No post-ready paste runs for that delivery, so seed
* the chat-composer copy here once the mirrored host tab id is known.
*/
export async function createWebRuntimeAgentSessionTerminalWithLaunchDraft(
args: CreateWebRuntimeSessionTerminalArgs & {
agent: TuiAgent
launchDraft: string
}
): Promise<WebRuntimeTerminalCreateOutcome> {
const created = await createWebRuntimeSessionTerminalResult(args)
if (created.outcome.status !== 'failed' && created.hostTabId) {
seedNativeChatLaunchDraftForAgentTab({
tabId: toWebTerminalSurfaceTabId(created.hostTabId),
agent: args.agent,
text: args.launchDraft
})
}
return created.outcome
}
@@ -0,0 +1,7 @@
import { parsePaneKey } from '../../../shared/stable-pane-id'
import type { CreatedAgentTerminalIdentity } from './web-runtime-session-types'
export function createdTerminalLeafId(terminal: CreatedAgentTerminalIdentity): string | undefined {
const pane = parsePaneKey(terminal.paneKey ?? '')
return pane && pane.tabId === terminal.tabId ? pane.leafId : undefined
}
@@ -0,0 +1,38 @@
import { useAppStore } from '../store'
import { forgetWebSessionTerminalPlacement } from './web-session-terminal-placement'
import { toWebTerminalSurfaceTabId } from './web-terminal-surface-id'
/** Settle the placement once the mirrored tab exists (bounded poll), then consume the record. */
export async function settleWebRuntimeTerminalPlacement(
environmentId: string,
worktreeId: string,
hostTabId: string,
placement: { groupId: string; activate: boolean }
): Promise<void> {
const unifiedTabId = toWebTerminalSurfaceTabId(hostTabId)
const findTab = () =>
(useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).find(
(tab) => tab.id === unifiedTabId
)
try {
const deadline = Date.now() + 10_000
while (!findTab() && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 250))
}
const tab = findTab()
const state = useAppStore.getState()
const targetGroupExists = (state.groupsByWorktree[worktreeId] ?? []).some(
(group) => group.id === placement.groupId
)
if (tab && targetGroupExists && tab.groupId !== placement.groupId) {
// Why: a snapshot can adopt the tab before the record exists (the publication races the
// RPC response); repair through the same client-owned move a user drag takes.
state.moveUnifiedTabToGroup(unifiedTabId, placement.groupId, {
activate: placement.activate,
recordInteraction: false
})
}
} finally {
forgetWebSessionTerminalPlacement({ environmentId, worktreeId, hostTabId })
}
}
@@ -0,0 +1,60 @@
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import { useAppStore } from '../store'
import { unwrapRuntimeRpcResult } from './runtime-rpc-client'
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
import {
captureRuntimeEnvironmentCall,
captureWebSessionIntentOwner,
isWebRuntimeSessionActive
} from './web-runtime-session-environment'
import {
refreshWebRuntimeSessionTabsSnapshot,
scheduleRuntimeWorktreeRecoveryRefresh
} from './web-runtime-session-snapshot'
export async function activateWebRuntimeSessionWorktree(args: {
worktreeId: string
environmentId?: string | null
}): Promise<boolean> {
const environmentId =
args.environmentId?.trim() ??
useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim() ??
null
if (!environmentId || !isWebRuntimeSessionActive(environmentId)) {
return false
}
const intentOwner = captureWebSessionIntentOwner(environmentId)
const callEnvironment = captureRuntimeEnvironmentCall(environmentId, intentOwner.pairingRevision)
try {
const response = await callEnvironment({
method: 'worktree.activate',
params: {
worktree: toRuntimeWorktreeSelector(args.worktreeId),
// Why: notifyClients:false keeps navigation local when this client reaches an older host.
notifyClients: false,
navigation: 'caller'
},
timeoutMs: 15_000
})
unwrapRuntimeRpcResult(response as RuntimeRpcResponse<unknown>)
// Why: a restarted HUB can recover its SSH pane after this client's subscription replayed an empty startup snapshot.
await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId, {
expectedEnvironmentPairingRevision: intentOwner.pairingRevision,
acceptCurrentSnapshot: true
})
// Why: HUB reachability can precede its nested SSH relay; bounded owner-scoped re-lists converge without asking the paired client to connect SSH itself.
scheduleRuntimeWorktreeRecoveryRefresh(
environmentId,
args.worktreeId,
intentOwner.pairingRevision
)
return true
} catch (error) {
console.warn(
'[web-runtime-session] failed to activate worktree:',
error instanceof Error ? error.message : String(error)
)
return false
}
}