refactor(renderer): split runtime and store modules

This commit is contained in:
Neil
2026-08-31 05:43:35 -07:00
parent b13c2b1960
commit 207bef0198
176 changed files with 26790 additions and 23608 deletions
@@ -129,15 +129,20 @@ const BROWSER_WORKSPACE_CLOSE_SITES: {
why: 'Rolls back a client-staged row for a create that never reached a host page.'
},
{
path: 'src/renderer/src/store/slices/browser.ts',
path: 'src/renderer/src/store/slices/browser/browser-close-actions.ts',
closeBrowserTabMentions: 3,
reasonCarryingCloseCalls: 0,
planReasonForwardings: 0,
routesThroughPlan: false,
why:
'shutdownWorktreeBrowsers tears the whole worktree down; the slice is the seam itself. Same ' +
'carve-out for parked chrome as the staging rollback, and page ids are unique so a later ' +
"page cannot pick up a dead one's entry."
why: 'Slice-owned shutdown tears down every browser row directly.'
},
{
path: 'src/renderer/src/store/slices/browser/browser-slice-contract.ts',
closeBrowserTabMentions: 1,
reasonCarryingCloseCalls: 0,
planReasonForwardings: 0,
routesThroughPlan: false,
why: 'The extracted slice contract declares the close action consumed by browser controllers.'
}
]
@@ -108,7 +108,7 @@ describe('host-session-mirror settle census', () => {
// empty inventory, a non-empty inventory, and an empty inventory that
// settles only after `terminal.list` says the host has no live PTY —
// `0 === 0` is not host evidence (STA-5377).
'runtime/web-session-tabs-sync.ts': { hydrated: 3, worktreeHydrated: 2 }
'runtime/web-session-tabs-sync/mirror-settle.ts': { hydrated: 3, worktreeHydrated: 2 }
})
})
@@ -140,9 +140,13 @@ describe('host-session-mirror settle census', () => {
}
}
expect(callSites, SETTLE_RULE).toEqual({
// initial listAll, visibility-resume repair, full inventory, global
// singular frame, scoped active frame.
'runtime/web-session-tabs-sync.ts': 5,
// Initial listAll, visibility-resume repair, full inventory, global
// singular frame, and scoped active frame are owned by these extracted
// recovery/event modules.
'runtime/web-session-tabs-sync/active-session-subscription.ts': 1,
'runtime/web-session-tabs-sync/global-session-events.ts': 2,
'runtime/web-session-tabs-sync/load-initial.ts': 1,
'runtime/web-session-tabs-sync/visibility-resume-coordinator.ts': 1,
// The eager post-create session.tabs.list refresh.
'runtime/web-runtime-session-snapshot.ts': 1,
// The local structured-session inventory/subscription frame.
@@ -188,10 +192,14 @@ describe('host-session-mirror settle census', () => {
// Pinning the resolved names keeps the rule from going vacuous: a binding
// this census misreads settles on some other identifier's invocation.
expect(receiptBindings, SETTLE_RULE).toEqual({
// Four hydration receipts (initial listAll, full inventory, scoped active
// 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 },
// Hydration and mirror receipts remain pinned by their extracted owners:
// global events own the three hydration completions, initial loading owns
// one, active subscription owns two mirror settles, and visibility resume
// owns one.
'runtime/web-session-tabs-sync/active-session-subscription.ts': { settle: 2 },
'runtime/web-session-tabs-sync/global-session-events.ts': { settleHydration: 3 },
'runtime/web-session-tabs-sync/load-initial.ts': { settleHydration: 1 },
'runtime/web-session-tabs-sync/visibility-resume-coordinator.ts': { settle: 1 },
'runtime/web-runtime-session-snapshot.ts': { settleMirror: 1 },
'runtime/local-structured-session-tabs-sync.ts': { settleStructuredSessionMirror: 1 }
})
@@ -207,7 +215,9 @@ describe('host-session-mirror settle census', () => {
}
expect(patchlessCounts, SETTLE_RULE).toEqual({
// The definition, the global singular frame, and the scoped active frame.
'runtime/web-session-tabs-sync.ts': 3
'runtime/web-session-tabs-sync/active-session-subscription.ts': 1,
'runtime/web-session-tabs-sync/global-session-events.ts': 1,
'runtime/web-session-tabs-sync/mirror-settle.ts': 1
})
})
@@ -228,11 +238,11 @@ describe('host-session-mirror settle census', () => {
// to say whether the frame is host evidence — the whole point of the pair.
expect(settlingCounts, SETTLE_RULE).toEqual({
// The applied frame (its own patch) and the outranked one (the accepted view).
'runtime/web-session-tabs-sync.ts': 2
'runtime/web-session-tabs-sync/tracking-decisions.ts': 2
})
expect(silentCounts, SETTLE_RULE).toEqual({
// The unmirrored frame: no accepted view of it ever reached the store.
'runtime/web-session-tabs-sync.ts': 1
'runtime/web-session-tabs-sync/tracking-decisions.ts': 1
})
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,76 @@
import type { AppState } from '@/store/types'
import { AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS, graphState } from './graph-state'
import type { AgentStatusProjectionCacheEntry } from './types'
function serializeAgentStatusEntry(
paneKey: string,
entry: AppState['agentStatusByPaneKey'][string]
): string {
return JSON.stringify({
paneKey,
entryPaneKey: entry.paneKey,
state: entry.state,
workingMode: entry.workingMode ?? null,
prompt: entry.prompt,
updatedAtBucket: Math.floor(entry.updatedAt / AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS),
stateStartedAt: entry.stateStartedAt,
agentType: entry.agentType ?? null,
terminalTitle: entry.terminalTitle ?? null,
stateHistory: entry.stateHistory.map((history) => ({
state: history.state,
prompt: history.prompt,
startedAt: history.startedAt,
interrupted: history.interrupted ?? null
})),
toolName: entry.toolName ?? null,
toolInput: entry.toolInput ?? null,
// Include the prompt so a newly captured question re-fires mobile publication.
interactivePrompt: entry.interactivePrompt ?? null,
lastAssistantMessage: entry.lastAssistantMessage ?? null,
interrupted: entry.interrupted ?? null
})
}
export function buildRuntimeMobileAgentStatusProjection(
agentStatusByPaneKey: AppState['agentStatusByPaneKey']
): string {
const cached = graphState.cachedAgentStatusProjection
if (cached?.source === agentStatusByPaneKey) {
return cached.projection
}
// A status ping replaces one entry and re-spreads the map; reuse every other entry.
const entries = new Map<string, AgentStatusProjectionCacheEntry>()
const parts: string[] = []
for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey).sort(([a], [b]) =>
a.localeCompare(b)
)) {
const previous = cached?.entries.get(paneKey)
const entryCache =
previous?.entry === entry
? previous
: { entry, projection: serializeAgentStatusEntry(paneKey, entry) }
entries.set(paneKey, entryCache)
parts.push(entryCache.projection)
}
const projection = `[${parts.join(',')}]`
graphState.cachedAgentStatusProjection = {
source: agentStatusByPaneKey,
entries,
projection
}
return projection
}
export function buildRuntimeMobileAgentStatusProjectionForTests(
agentStatusByPaneKey: AppState['agentStatusByPaneKey']
): string {
return buildRuntimeMobileAgentStatusProjection(agentStatusByPaneKey)
}
export const AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS =
AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS
export function resetRuntimeMobileAgentStatusProjectionCacheForTests(): void {
graphState.cachedAgentStatusProjection = null
}
@@ -0,0 +1,215 @@
import { getEagerPtyBufferHandle } from '@/components/terminal-pane/pty-dispatcher'
import { warnTerminalLifecycleAnomaly } from '@/components/terminal-pane/terminal-lifecycle-diagnostics'
import {
collectParkedTerminalWatcherPtyIds,
getParkedTerminalWatcherPaneIdsByPtyId
} from '@/components/terminal-pane/terminal-parked-watcher-registry'
import { serializePaneTree } from '@/components/terminal-pane/layout-serialization'
import { getSystemPrefersDark } from '@/lib/terminal-theme'
import type {
RuntimeMobileSessionTabsSnapshot,
RuntimeRendererSyncWindowGraph
} from '../../../../shared/runtime-types'
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
import { applyNativeChatLaunchDraftResolved } from '../native-chat-launch-draft-runtime-resolution'
import { resolveTerminalLayoutRoot } from '../remote-terminal-layout-resolution'
import { buildMobileSessionTabSnapshots } from './mobile-session-snapshots'
import { isWebOnlyMirroredTerminalTab } from './mobile-session-tab-helpers'
import { resolveRuntimeTerminalTitle } from './sync-projections'
import { graphState, mobilePublicationEpoch, NO_TRANSPORT_GRACE_MS } from './graph-state'
export async function syncRuntimeGraph(): Promise<void> {
if (!graphState.syncEnabled || !graphState.getStoreState) {
return
}
// The store getter is injected to break the terminal-slice construction cycle.
const state = graphState.getStoreState()
const systemPrefersDark = getSystemPrefersDark()
const terminalTabById = new Map(
Object.values(state.tabsByWorktree)
.flat()
.map((tab) => [tab.id, tab])
)
const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true
const mobileSessionTabs = buildMobileSessionTabSnapshots(state, systemPrefersDark)
const publication = partitionMobileSessionPublication(mobileSessionTabs)
const graph: RuntimeRendererSyncWindowGraph = {
tabs: [],
leaves: [],
rendererGeneration: mobilePublicationEpoch,
mobileSessionTabs: publication.changed,
unchangedMobileSessionWorktrees: publication.unchangedWorktrees
}
for (const [tabId, registeredTab] of graphState.registeredTabs) {
const tab = terminalTabById.get(tabId)
if (!tab) {
continue
}
if (isWebOnlyMirroredTerminalTab(tab, state.terminalLayoutsByTabId[tabId])) {
continue
}
const manager = registeredTab.getManager()
const container = registeredTab.getContainer()
const activePaneId = manager?.getActivePane()?.id ?? null
const root =
container?.firstElementChild instanceof HTMLElement ? container.firstElementChild : null
graph.tabs.push({
tabId,
worktreeId: registeredTab.worktreeId,
title: resolveRuntimeTerminalTitle(tab, generatedTitlesEnabled),
activeLeafId: activePaneId === null ? null : (manager?.getLeafId(activePaneId) ?? null),
layout: serializePaneTree(root)
})
const savedPtyIdsByLeafId = state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {}
for (const pane of manager?.getPanes() ?? []) {
const leafId = pane.leafId
const ptyId = registeredTab.getPtyIdForPane(pane.id)
const savedPtyId = savedPtyIdsByLeafId[leafId] ?? null
const registeredTime = graphState.tabRegisteredAt.get(tabId) ?? 0
if (!ptyId && savedPtyId && Date.now() - registeredTime > NO_TRANSPORT_GRACE_MS) {
warnTerminalLifecycleAnomaly('mounted terminal leaf has saved PTY but no live transport', {
tabId,
worktreeId: registeredTab.worktreeId,
leafId,
paneId: pane.id,
ptyId: savedPtyId
})
}
const paneTitles = state.runtimePaneTitlesByTabId[tabId] ?? {}
graph.leaves.push({
tabId,
worktreeId: registeredTab.worktreeId,
leafId,
paneRuntimeId: pane.id,
ptyId,
paneTitle: paneTitles[pane.id] ?? null,
title: resolveRuntimeTerminalTitle(
tab,
generatedTitlesEnabled,
state.runtimePaneTitlesByTabId[tabId]?.[pane.id] ?? tab.title
)
})
}
}
// Inactive automation/cold-parked tabs do not mount a TerminalPane; publish persisted leaves
// only when a live eager buffer or parked watcher proves the PTY is still owned.
const parkedWatcherPtyIds = collectParkedTerminalWatcherPtyIds()
for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) {
for (const tab of tabs) {
const layout = state.terminalLayoutsByTabId[tab.id]
if (graphState.registeredTabs.has(tab.id) || isWebOnlyMirroredTerminalTab(tab, layout)) {
continue
}
const savedPtyIdsByLeafId = layout?.ptyIdsByLeafId
if (!savedPtyIdsByLeafId) {
continue
}
const liveLeaves = Object.entries(savedPtyIdsByLeafId).filter(
([leafId, ptyId]) =>
typeof ptyId === 'string' &&
ptyId.length > 0 &&
isTerminalLeafId(leafId) &&
(Boolean(getEagerPtyBufferHandle(ptyId)) || parkedWatcherPtyIds.has(ptyId))
)
if (liveLeaves.length === 0) {
continue
}
const title = resolveRuntimeTerminalTitle(tab, generatedTitlesEnabled)
const publishedLeafIds = new Set(liveLeaves.map(([leafId]) => leafId))
const savedActiveLeafId = layout?.activeLeafId
const parkedPaneIdsByPtyId = getParkedTerminalWatcherPaneIdsByPtyId(tab.id)
const parkedPaneTitles = state.runtimePaneTitlesByTabId[tab.id] ?? {}
graph.tabs.push({
tabId: tab.id,
worktreeId,
title,
activeLeafId:
savedActiveLeafId && publishedLeafIds.has(savedActiveLeafId)
? savedActiveLeafId
: liveLeaves[0][0],
layout: resolveTerminalLayoutRoot({
authoritativeRoot: layout?.root,
leafIds: liveLeaves.map(([leafId]) => leafId),
onSynthesize: (leafCount) =>
console.warn(
`[sync-runtime-graph] synthesized layout for ${leafCount} unmounted leaves with no saved tree`
)
})
})
liveLeaves.forEach(([leafId, ptyId], index) => {
const parkedPaneId = parkedPaneIdsByPtyId.get(ptyId)
graph.leaves.push({
tabId: tab.id,
worktreeId,
leafId,
paneRuntimeId: parkedPaneId ?? index + 1,
ptyId,
paneTitle: (parkedPaneId === undefined ? null : parkedPaneTitles[parkedPaneId]) ?? null,
title
})
})
}
}
try {
const result = await window.api.runtime.syncWindowGraph(graph)
commitMobileSessionPublication(mobileSessionTabs, result?.mobileSessionResyncWorktrees)
const currentState = graphState.getStoreState()
currentState?.setRuntimeAgentOrchestrationByPaneKey?.(result?.agentOrchestrationByPaneKey ?? {})
for (const resolution of result?.nativeChatLaunchDraftResolutions ?? []) {
if (currentState) {
applyNativeChatLaunchDraftResolved(currentState, {
type: 'nativeChatLaunchDraftResolved',
...resolution
})
}
}
if (result?.mobileSessionResyncWorktrees?.length) {
scheduleTrailingGraphSync()
}
} catch (error) {
console.error('[runtime] Failed to sync renderer graph:', error)
}
}
let scheduleTrailingGraphSync: () => void = () => undefined
export function setTrailingGraphSyncScheduler(scheduler: () => void): void {
scheduleTrailingGraphSync = scheduler
}
function partitionMobileSessionPublication(snapshots: RuntimeMobileSessionTabsSnapshot[]): {
changed: RuntimeMobileSessionTabsSnapshot[]
unchangedWorktrees: string[]
} {
const changed: RuntimeMobileSessionTabsSnapshot[] = []
const unchangedWorktrees: string[] = []
for (const snapshot of snapshots) {
if (graphState.publishedMobileSessionSnapshotByWorktree.get(snapshot.worktree) === snapshot) {
unchangedWorktrees.push(snapshot.worktree)
} else {
changed.push(snapshot)
}
}
return { changed, unchangedWorktrees }
}
function commitMobileSessionPublication(
snapshots: RuntimeMobileSessionTabsSnapshot[],
resyncWorktrees: string[] | undefined
): void {
const published = new Set<string>()
for (const snapshot of snapshots) {
published.add(snapshot.worktree)
graphState.publishedMobileSessionSnapshotByWorktree.set(snapshot.worktree, snapshot)
}
for (const worktreeId of graphState.publishedMobileSessionSnapshotByWorktree.keys()) {
if (!published.has(worktreeId)) {
graphState.publishedMobileSessionSnapshotByWorktree.delete(worktreeId)
}
}
for (const worktreeId of resyncWorktrees ?? []) {
graphState.publishedMobileSessionSnapshotByWorktree.delete(worktreeId)
}
}
@@ -0,0 +1,101 @@
import { createBrowserUuid } from '@/lib/browser-uuid'
import type { AppState } from '@/store/types'
import type {
RuntimeMobileSessionSnapshotTab,
RuntimeMobileSessionTabsSnapshot,
RuntimeMobileTerminalTheme
} from '../../../../shared/runtime-types'
import type {
AgentStatusProjectionCache,
MobileSessionWorktreeInputs,
OpenFileIndexes,
RegisteredTerminalTab,
TabsProjectionCache
} from './types'
export const NO_TRANSPORT_GRACE_MS = 10_000
export const AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS = 30_000
export const RUNTIME_GRAPH_SYNC_COALESCE_MS = 16
export const EMPTY_ACTIVE_BROWSER_TAB_ID_BY_WORKTREE: AppState['activeBrowserTabIdByWorktree'] = {}
export const EMPTY_BROWSER_TABS_BY_WORKTREE: AppState['browserTabsByWorktree'] = {}
export const EMPTY_BROWSER_PAGES_BY_WORKSPACE: AppState['browserPagesByWorkspace'] = {}
export const EMPTY_LAYOUT_BY_WORKTREE: AppState['layoutByWorktree'] = {}
export const EMPTY_AGENT_STATUS_BY_PANE_KEY: AppState['agentStatusByPaneKey'] = {}
export const EMPTY_NARROWED_BY_KEY: ReadonlyMap<string, never> = new Map<string, never>()
export const EMPTY_WORKTREE_TERMINAL_TABS: AppState['tabsByWorktree'][string] = []
export const EMPTY_WORKTREE_BROWSER_WORKSPACES: AppState['browserTabsByWorktree'][string] = []
export const EMPTY_WORKTREE_UNIFIED_TABS: AppState['unifiedTabsByWorktree'][string] = []
export const EMPTY_WORKTREE_TAB_GROUPS: AppState['groupsByWorktree'][string] = []
export const EMPTY_WORKTREE_OPEN_FILE_IDS: readonly string[] = []
export const mobilePublicationEpoch = `renderer:${createBrowserUuid()}`
export const graphState = {
registeredTabs: new Map<string, RegisteredTerminalTab>(),
tabRegisteredAt: new Map<string, number>(),
syncScheduled: false,
syncInFlight: false,
syncPendingAfterFlight: false,
syncEnabled: false,
syncTimer: null as ReturnType<typeof setTimeout> | null,
getStoreState: null as (() => AppState) | null,
mobileSessionSnapshotVersion: 0,
mobileSessionSnapshotCacheByWorktree: new Map<
string,
{
inputs: MobileSessionWorktreeInputs
content: unknown
snapshot: RuntimeMobileSessionTabsSnapshot
}
>(),
publishedMobileSessionSnapshotByWorktree: new Map<string, RuntimeMobileSessionTabsSnapshot>(),
cachedTabsProjection: null as TabsProjectionCache | null,
cachedAgentStatusProjection: null as AgentStatusProjectionCache | null,
cachedOpenFileIndexesSource: null as AppState['openFiles'] | null,
cachedOpenFileIndexes: null as OpenFileIndexes | null,
cachedEditorDraftsSource: null as AppState['editorDrafts'] | null,
cachedEditorDraftVersionByFileId: null as Map<string, string> | null,
cachedMobileTerminalThemeSettings: null as AppState['settings'] | null,
cachedMobileTerminalThemeSystemPrefersDark: null as boolean | null,
cachedMobileTerminalTheme: undefined as RuntimeMobileTerminalTheme | undefined,
hasCachedMobileTerminalTheme: false
}
// Structural equality under JSON-serialization semantics (undefined-valued keys are absent).
export function jsonContentEquals(a: unknown, b: unknown): boolean {
if (a === b) {
return true
}
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
return false
}
return a.every((item, index) => jsonContentEquals(item, b[index]))
}
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
return false
}
const aRecord = a as Record<string, unknown>
const bRecord = b as Record<string, unknown>
for (const key of Object.keys(aRecord)) {
if (!jsonContentEquals(aRecord[key], bRecord[key])) {
return false
}
}
for (const key of Object.keys(bRecord)) {
if (bRecord[key] !== undefined && aRecord[key] === undefined) {
return false
}
}
return true
}
export type MobileSessionContent = {
activeGroupId: string | null
activeTabId: string | null
activeTabType: RuntimeMobileSessionSnapshotTab['type'] | null
tabGroups?: NonNullable<RuntimeMobileSessionTabsSnapshot['tabGroups']>
tabGroupLayout?: RuntimeMobileSessionTabsSnapshot['tabGroupLayout']
tabs: RuntimeMobileSessionSnapshotTab[]
}
@@ -0,0 +1,38 @@
import type { AppState } from '@/store/types'
import type { RuntimeMobileSessionBrowserTab } from '../../../../shared/runtime-types'
import type { Tab } from '../../../../shared/tab-types'
import type { MobileSessionWorktreeInputs } from './types'
import { isUnifiedTabActiveInActiveGroup } from './mobile-session-tab-helpers'
export function buildMobileBrowserTab(
inputs: MobileSessionWorktreeInputs,
workspace: NonNullable<AppState['browserTabsByWorktree'][string]>[number],
unifiedTab?: Tab
): RuntimeMobileSessionBrowserTab {
const pages = inputs.pagesByBrowserWorkspaceId.get(workspace.id) ?? []
const activePage = pages.find((page) => page.id === workspace.activePageId) ?? pages[0] ?? null
const title =
activePage?.title || workspace.title || activePage?.url || workspace.url || 'Browser'
const unifiedTabId = unifiedTab?.id
return {
type: 'browser',
id: unifiedTabId ?? workspace.id,
title,
browserWorkspaceId: workspace.id,
browserPageId: activePage?.id ?? workspace.activePageId ?? null,
url: activePage?.url ?? workspace.url ?? 'about:blank',
loading: activePage?.loading ?? workspace.loading,
canGoBack: activePage?.canGoBack ?? workspace.canGoBack,
canGoForward: activePage?.canGoForward ?? workspace.canGoForward,
// Null means the active page cleared its failure; do not resurrect a stale workspace error.
loadError: activePage ? activePage.loadError : workspace.loadError,
certificateFailure: activePage
? (inputs.certificateFailureByBrowserPageId.get(activePage.id) ?? null)
: null,
color: unifiedTab?.color ?? null,
isPinned: unifiedTab?.isPinned === true,
isActive: unifiedTabId
? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId)
: inputs.activeBrowserWorkspaceId === workspace.id
}
}
@@ -0,0 +1,189 @@
import {
collectLeafIdsInOrder,
serializePaneTree
} from '@/components/terminal-pane/layout-serialization'
import type { AppState } from '@/store/types'
import type {
RegisteredTerminalTab,
MobileSessionWorktreeInputs,
MountedTerminalSurfaceCapture
} from './types'
import { EMPTY_NARROWED_BY_KEY, graphState, jsonContentEquals } from './graph-state'
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
export function narrowRecordByKeys<T>(
source: Record<string, T> | undefined,
keys: readonly string[]
): ReadonlyMap<string, T> {
if (!source || keys.length === 0) {
return EMPTY_NARROWED_BY_KEY
}
let narrowed: Map<string, T> | null = null
for (const key of keys) {
const value = source[key]
if (value === undefined) {
continue
}
narrowed ??= new Map<string, T>()
narrowed.set(key, value)
}
return narrowed ?? EMPTY_NARROWED_BY_KEY
}
export function narrowMapByKeys<T>(
source: ReadonlyMap<string, T>,
keys: readonly string[]
): ReadonlyMap<string, T> {
if (source.size === 0 || keys.length === 0) {
return EMPTY_NARROWED_BY_KEY
}
let narrowed: Map<string, T> | null = null
for (const key of keys) {
if (!source.has(key)) {
continue
}
narrowed ??= new Map<string, T>()
narrowed.set(key, source.get(key) as T)
}
return narrowed ?? EMPTY_NARROWED_BY_KEY
}
export function captureMountedTerminalSurfaces(
terminalTabs: AppState['tabsByWorktree'][string],
terminalLayoutsByTabId: AppState['terminalLayoutsByTabId']
): ReadonlyMap<string, MountedTerminalSurfaceCapture> {
let captures: Map<string, MountedTerminalSurfaceCapture> | null = null
for (const tab of terminalTabs) {
const registered = graphState.registeredTabs.get(tab.id)
if (!registered) {
continue
}
captures ??= new Map()
captures.set(tab.id, captureMountedTerminalSurface(registered, terminalLayoutsByTabId[tab.id]))
}
return captures ?? EMPTY_NARROWED_BY_KEY
}
function captureMountedTerminalSurface(
registered: RegisteredTerminalTab,
savedLayout: AppState['terminalLayoutsByTabId'][string] | undefined
): MountedTerminalSurfaceCapture {
const manager = registered.getManager()
const paneLeafIds = manager?.getPanes().map((pane) => pane.leafId) ?? []
const activePane = manager?.getActivePane() ?? null
const firstChild = registered.getContainer()?.firstElementChild
// Mirrors getRuntimeLeafIdsForTerminal so captured pane ids cover every resolved leaf.
const effectiveLeafIds =
paneLeafIds.length > 0
? paneLeafIds
: collectLeafIdsInOrder(savedLayout?.root).filter(isTerminalLeafId)
const numericPaneIdByLeafId = new Map<string, number | null>()
const ptyIdByNumericPaneId = new Map<number, string | null>()
for (const leafId of effectiveLeafIds) {
const numericPaneId = manager?.getNumericIdForLeaf(leafId) ?? null
numericPaneIdByLeafId.set(leafId, numericPaneId)
if (numericPaneId !== null) {
ptyIdByNumericPaneId.set(numericPaneId, registered.getPtyIdForPane(numericPaneId))
}
}
return {
paneLeafIds,
hasLiveActivePane: activePane !== null,
liveActiveLeafId: activePane !== null ? (manager?.getLeafId(activePane.id) ?? null) : null,
liveLayoutRoot: serializePaneTree(
typeof HTMLElement !== 'undefined' && firstChild instanceof HTMLElement ? firstChild : null
),
numericPaneIdByLeafId,
ptyIdByNumericPaneId,
tabWideAgentHintLeafId: registered.getTabWideAgentHintLeafId()
}
}
function narrowedEntriesEqual<K, T>(a: ReadonlyMap<K, T>, b: ReadonlyMap<K, T>): boolean {
if (a === b) {
return true
}
if (a.size !== b.size) {
return false
}
for (const [key, value] of a) {
if (b.get(key) !== value) {
return false
}
}
return true
}
function mountedTerminalSurfaceCaptureEquals(
a: MountedTerminalSurfaceCapture,
b: MountedTerminalSurfaceCapture
): boolean {
return (
a.hasLiveActivePane === b.hasLiveActivePane &&
a.liveActiveLeafId === b.liveActiveLeafId &&
a.paneLeafIds.length === b.paneLeafIds.length &&
a.paneLeafIds.every((leafId, index) => b.paneLeafIds[index] === leafId) &&
narrowedEntriesEqual(a.numericPaneIdByLeafId, b.numericPaneIdByLeafId) &&
narrowedEntriesEqual(a.ptyIdByNumericPaneId, b.ptyIdByNumericPaneId) &&
a.tabWideAgentHintLeafId === b.tabWideAgentHintLeafId &&
// Serialization allocates a fresh tree per capture, so compare its content.
jsonContentEquals(a.liveLayoutRoot, b.liveLayoutRoot)
)
}
export function mountedSurfaceCapturesEqual(
a: ReadonlyMap<string, MountedTerminalSurfaceCapture>,
b: ReadonlyMap<string, MountedTerminalSurfaceCapture>
): boolean {
if (a === b) {
return true
}
if (a.size !== b.size) {
return false
}
for (const [tabId, capture] of a) {
const other = b.get(tabId)
if (!other || !mountedTerminalSurfaceCaptureEquals(capture, other)) {
return false
}
}
return true
}
export function canReuseMobileSessionSnapshot(
previous: MobileSessionWorktreeInputs,
next: MobileSessionWorktreeInputs
): boolean {
return (
previous.worktreeId === next.worktreeId &&
previous.terminalTabs === next.terminalTabs &&
previous.browserWorkspaces === next.browserWorkspaces &&
previous.unifiedTabs === next.unifiedTabs &&
previous.groups === next.groups &&
previous.tabBarOrder === next.tabBarOrder &&
previous.activeGroupId === next.activeGroupId &&
previous.tabGroupLayout === next.tabGroupLayout &&
previous.openFilesById === next.openFilesById &&
previous.openFileIds === next.openFileIds &&
previous.activeEditorFileId === next.activeEditorFileId &&
previous.activeEditorTabType === next.activeEditorTabType &&
previous.activeTerminalTabId === next.activeTerminalTabId &&
previous.activeBrowserWorkspaceId === next.activeBrowserWorkspaceId &&
previous.generatedTitlesEnabled === next.generatedTitlesEnabled &&
previous.terminalTheme === next.terminalTheme &&
narrowedEntriesEqual(previous.terminalLayoutByTabId, next.terminalLayoutByTabId) &&
narrowedEntriesEqual(previous.paneTitlesByTabId, next.paneTitlesByTabId) &&
narrowedEntriesEqual(previous.launchDraftByPaneKey, next.launchDraftByPaneKey) &&
narrowedEntriesEqual(previous.agentStatusByPaneKey, next.agentStatusByPaneKey) &&
narrowedEntriesEqual(previous.editorDraftVersionByFileId, next.editorDraftVersionByFileId) &&
narrowedEntriesEqual(previous.pagesByBrowserWorkspaceId, next.pagesByBrowserWorkspaceId) &&
narrowedEntriesEqual(
previous.certificateFailureByBrowserPageId,
next.certificateFailureByBrowserPageId
) &&
mountedSurfaceCapturesEqual(
previous.mountedSurfaceCaptureByTabId,
next.mountedSurfaceCaptureByTabId
)
)
}
@@ -0,0 +1,77 @@
import type { AppState } from '@/store/types'
import type {
RuntimeMobileSessionFileTab,
RuntimeMobileSessionMarkdownTab
} from '../../../../shared/runtime-types'
import type { Tab } from '../../../../shared/tab-types'
import type { MobileSessionWorktreeInputs } from './types'
import {
isFileActiveEditorSurface,
isMobileFileDiffSource,
isUnifiedTabActiveInActiveGroup
} from './mobile-session-tab-helpers'
export function buildMobileMarkdownTab(
inputs: MobileSessionWorktreeInputs,
file: AppState['openFiles'][number],
unifiedTab?: Tab
): RuntimeMobileSessionMarkdownTab | null {
if (file.mode !== 'edit' && file.mode !== 'markdown-preview') {
return null
}
if (file.language !== 'markdown' && file.mode !== 'markdown-preview') {
return null
}
const sourceFile =
file.mode === 'markdown-preview' && file.markdownPreviewSourceFileId
? (inputs.openFilesById?.get(file.markdownPreviewSourceFileId) ?? file)
: file
const draftVersion = inputs.editorDraftVersionByFileId.get(sourceFile.id)
const title = file.relativePath.split(/[\\/]/).pop() || file.relativePath || 'Markdown'
const unifiedTabId = unifiedTab?.id
return {
type: 'markdown',
id: unifiedTabId ?? file.id,
title,
filePath: file.filePath,
relativePath: file.relativePath,
language: 'markdown',
mode: file.mode,
isDirty: file.isDirty || sourceFile.isDirty,
isActive: unifiedTabId
? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId)
: isFileActiveEditorSurface(inputs, file),
sourceFileId: sourceFile.id,
sourceFilePath: sourceFile.filePath,
sourceRelativePath: sourceFile.relativePath,
documentVersion: draftVersion ?? `file:${sourceFile.id}`,
color: unifiedTab?.color ?? null,
isPinned: unifiedTab?.isPinned === true
}
}
export function buildMobileFileTab(
inputs: MobileSessionWorktreeInputs,
file: AppState['openFiles'][number],
unifiedTab?: Tab
): RuntimeMobileSessionFileTab {
const title = file.relativePath.split(/[\\/]/).pop() || file.relativePath || 'File'
const diffSource = isMobileFileDiffSource(file.diffSource) ? file.diffSource : undefined
const unifiedTabId = unifiedTab?.id
return {
type: 'file',
id: unifiedTabId ?? file.id,
title,
filePath: file.filePath,
relativePath: file.relativePath,
language: file.language,
mode: file.mode === 'diff' ? 'diff' : 'edit',
...(diffSource ? { diffSource } : {}),
isDirty: file.isDirty,
color: unifiedTab?.color ?? null,
isPinned: unifiedTab?.isPinned === true,
isActive: unifiedTabId
? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId)
: isFileActiveEditorSurface(inputs, file)
}
}
@@ -0,0 +1,242 @@
import type { RuntimeMobileSessionTabGroup } from '../../../../shared/runtime-types'
import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../../shared/tab-types'
import {
getActiveTabNavOrder,
getGroupVisibleTabOrder,
type VisibleTabRef
} from '../../components/tab-bar/group-tab-order'
import type { MobileSessionWorktreeInputs } from './types'
import { isEditorSurfaceTab } from './mobile-session-tab-helpers'
export function getEditorUnifiedTabsForWorktree(
inputs: Pick<MobileSessionWorktreeInputs, 'unifiedTabs'>
): Tab[] {
return inputs.unifiedTabs.filter(isEditorSurfaceTab)
}
export function applyUnifiedEditorTabIdsToLegacyOrder(
order: readonly VisibleTabRef[],
inputs: Pick<MobileSessionWorktreeInputs, 'unifiedTabs'>
): VisibleTabRef[] {
const unifiedEditorTabs = getEditorUnifiedTabsForWorktree(inputs)
if (unifiedEditorTabs.length === 0) {
return [...order]
}
const firstUnifiedTabByFileId = new Map<string, string>()
for (const tab of unifiedEditorTabs) {
if (!firstUnifiedTabByFileId.has(tab.entityId)) {
firstUnifiedTabByFileId.set(tab.entityId, tab.id)
}
}
return order.map((item) => {
if (item.type !== 'editor' || item.tabId) {
return item
}
const tabId = firstUnifiedTabByFileId.get(item.id)
return tabId ? { ...item, tabId } : item
})
}
export function appendFallbackEditorTabsToGroups(
tabGroups: RuntimeMobileSessionTabGroup[] | undefined,
sourceGroups: readonly TabGroup[],
activeGroupId: string | null,
fallbackTabs: readonly { tabId: string; groupId: string | null }[],
activeTabId: string | null
): RuntimeMobileSessionTabGroup[] | undefined {
if (fallbackTabs.length === 0) {
return tabGroups
}
const result = [...(tabGroups ?? [])]
const sourceGroupsById = new Map(sourceGroups.map((group) => [group.id, group]))
const groupIndexById = new Map(result.map((group, index) => [group.id, index]))
const firstTargetGroupId =
result[0]?.id ??
(activeGroupId && sourceGroupsById.has(activeGroupId) ? activeGroupId : null) ??
sourceGroups[0]?.id ??
null
const fallbackTabIdSet = new Set(fallbackTabs.map((tab) => tab.tabId))
for (const fallback of fallbackTabs) {
const targetGroupId =
fallback.groupId ??
(activeGroupId && (groupIndexById.has(activeGroupId) || sourceGroupsById.has(activeGroupId))
? activeGroupId
: firstTargetGroupId)
if (!targetGroupId) {
continue
}
let targetIndex = groupIndexById.get(targetGroupId)
if (targetIndex === undefined) {
const sourceGroup = sourceGroupsById.get(targetGroupId)
const group: RuntimeMobileSessionTabGroup = {
id: targetGroupId,
activeTabId: sourceGroup?.activeTabId ?? null,
tabOrder: [],
recentTabIds: sourceGroup?.recentTabIds ?? []
}
targetIndex = result.length
groupIndexById.set(targetGroupId, targetIndex)
result.push(group)
}
const group = result[targetIndex]!
if (!group.tabOrder.includes(fallback.tabId)) {
result[targetIndex] = { ...group, tabOrder: [...group.tabOrder, fallback.tabId] }
}
}
if (result.length === 0) {
return tabGroups
}
const activeFallbackTabId = activeTabId && fallbackTabIdSet.has(activeTabId) ? activeTabId : null
return result.map((group) => {
const tabOrder = [...group.tabOrder]
const tabOrderSet = new Set(tabOrder)
const activeFallbackTabIdForGroup =
activeFallbackTabId && tabOrderSet.has(activeFallbackTabId) ? activeFallbackTabId : null
const activeTabIdForGroup =
activeFallbackTabIdForGroup ??
(group.activeTabId && tabOrderSet.has(group.activeTabId) ? group.activeTabId : null)
const recentTabIds = (group.recentTabIds ?? []).filter((tabId) => tabOrderSet.has(tabId))
if (
activeFallbackTabId &&
tabOrderSet.has(activeFallbackTabId) &&
!recentTabIds.includes(activeFallbackTabId)
) {
recentTabIds.push(activeFallbackTabId)
}
return { ...group, activeTabId: activeTabIdForGroup, tabOrder, recentTabIds }
})
}
function getOrderedTabGroups(
groups: readonly TabGroup[],
layout: TabGroupLayoutNode | undefined
): TabGroup[] {
const byId = new Map(groups.map((group) => [group.id, group]))
const seen = new Set<string>()
const ordered: TabGroup[] = []
for (const groupId of collectTabGroupLayoutIds(layout)) {
const group = byId.get(groupId)
if (!group || seen.has(group.id)) {
continue
}
seen.add(group.id)
ordered.push(group)
}
for (const group of groups) {
if (!seen.has(group.id)) {
ordered.push(group)
}
}
return ordered
}
function buildLegacyNavOrderView(
inputs: MobileSessionWorktreeInputs
): Parameters<typeof getActiveTabNavOrder>[0] {
const { worktreeId } = inputs
return {
activeGroupIdByWorktree: inputs.activeGroupId ? { [worktreeId]: inputs.activeGroupId } : {},
groupsByWorktree: { [worktreeId]: inputs.groups },
unifiedTabsByWorktree: { [worktreeId]: inputs.unifiedTabs },
tabBarOrderByWorktree: inputs.tabBarOrder ? { [worktreeId]: inputs.tabBarOrder } : {},
tabsByWorktree: { [worktreeId]: inputs.terminalTabs },
openFiles: inputs.openFilesById ? [...inputs.openFilesById.values()] : [],
browserTabsByWorktree: { [worktreeId]: inputs.browserWorkspaces }
}
}
export function buildMobileSessionGroupProjection(
inputs: MobileSessionWorktreeInputs,
ids: { terminalIds: string[]; editorIds: string[]; browserIds: string[] }
): {
order: VisibleTabRef[]
tabGroups?: RuntimeMobileSessionTabGroup[]
tabGroupLayout?: TabGroupLayoutNode | null
} {
const groups = inputs.groups
if (groups.length === 0) {
return {
order: applyUnifiedEditorTabIdsToLegacyOrder(
getActiveTabNavOrder(buildLegacyNavOrderView(inputs), inputs.worktreeId, {
editorIds: ids.editorIds
}),
inputs
)
}
}
const terminalIds = new Set(ids.terminalIds)
const editorIds = new Set(ids.editorIds)
const browserIds = new Set(ids.browserIds)
const order: VisibleTabRef[] = []
const tabGroups: RuntimeMobileSessionTabGroup[] = []
for (const group of getOrderedTabGroups(groups, inputs.tabGroupLayout)) {
const groupTabs = inputs.unifiedTabs.filter((tab) => tab.groupId === group.id)
const visibleOrder = getGroupVisibleTabOrder(
group,
groupTabs,
terminalIds,
editorIds,
browserIds,
new Set(),
true
)
if (visibleOrder.length === 0) {
continue
}
const tabOrder = visibleOrder.map((item) => item.tabId ?? item.id)
const tabOrderSet = new Set(tabOrder)
for (const item of visibleOrder) {
order.push(item)
}
tabGroups.push({
id: group.id,
activeTabId:
group.activeTabId && tabOrderSet.has(group.activeTabId) ? group.activeTabId : null,
tabOrder,
recentTabIds: group.recentTabIds?.filter((tabId) => tabOrderSet.has(tabId)) ?? []
})
}
const validGroupIds = new Set(tabGroups.map((group) => group.id))
return {
order,
tabGroups,
tabGroupLayout: pruneTabGroupLayout(inputs.tabGroupLayout, validGroupIds)
}
}
export function collectTabGroupLayoutIds(layout: TabGroupLayoutNode | undefined): string[] {
const result: string[] = []
const visit = (node: TabGroupLayoutNode | undefined): void => {
if (!node) {
return
}
if (node.type === 'leaf') {
result.push(node.groupId)
return
}
visit(node.first)
visit(node.second)
}
visit(layout)
return result
}
export function pruneTabGroupLayout(
layout: TabGroupLayoutNode | undefined,
validGroupIds: ReadonlySet<string>
): TabGroupLayoutNode | null {
if (!layout) {
return null
}
if (layout.type === 'leaf') {
return validGroupIds.has(layout.groupId) ? layout : null
}
const first = pruneTabGroupLayout(layout.first, validGroupIds)
const second = pruneTabGroupLayout(layout.second, validGroupIds)
if (first && second) {
return { ...layout, first, second }
}
return first ?? second
}
@@ -0,0 +1,237 @@
import type { AppState } from '@/store/types'
import { parsePaneKey, makePaneKey } from '../../../../shared/stable-pane-id'
import { nativeChatLaunchAgentForLeaf } from '../../components/native-chat/native-chat-leaf-routing'
import {
EMPTY_NARROWED_BY_KEY,
EMPTY_WORKTREE_BROWSER_WORKSPACES,
EMPTY_WORKTREE_OPEN_FILE_IDS,
EMPTY_WORKTREE_TAB_GROUPS,
EMPTY_WORKTREE_TERMINAL_TABS,
EMPTY_WORKTREE_UNIFIED_TABS,
EMPTY_LAYOUT_BY_WORKTREE,
graphState
} from './graph-state'
import type {
MobileSessionAgentStatusByWorktree,
MobileSessionPublicationInputs,
MobileSessionWorktreeInputs,
OpenFileIndexes
} from './types'
import { captureMountedTerminalSurfaces, narrowRecordByKeys } from './mobile-session-capture'
import {
getRuntimeLeafIdsForTerminal,
resolveMobileTabWideAgentHintLeafId
} from './mobile-session-tab-helpers'
export function getOpenFileIndexes(openFiles: AppState['openFiles']): OpenFileIndexes {
if (graphState.cachedOpenFileIndexesSource === openFiles && graphState.cachedOpenFileIndexes) {
return graphState.cachedOpenFileIndexes
}
const byWorktreeAndId = new Map<string, Map<string, AppState['openFiles'][number]>>()
const idsByWorktree = new Map<string, string[]>()
for (const file of openFiles) {
let filesById = byWorktreeAndId.get(file.worktreeId)
if (!filesById) {
filesById = new Map()
byWorktreeAndId.set(file.worktreeId, filesById)
}
let ids = idsByWorktree.get(file.worktreeId)
if (!ids) {
ids = []
idsByWorktree.set(file.worktreeId, ids)
}
if (!filesById.has(file.id)) {
filesById.set(file.id, file)
ids.push(file.id)
}
}
graphState.cachedOpenFileIndexesSource = openFiles
graphState.cachedOpenFileIndexes = { byWorktreeAndId, idsByWorktree }
return graphState.cachedOpenFileIndexes
}
export function getEditorDraftVersionByFileId(
editorDrafts: AppState['editorDrafts']
): Map<string, string> {
if (
graphState.cachedEditorDraftsSource === editorDrafts &&
graphState.cachedEditorDraftVersionByFileId
) {
return graphState.cachedEditorDraftVersionByFileId
}
const versions = new Map<string, string>()
for (const [fileId, content] of Object.entries(editorDrafts)) {
let hash = 2166136261
for (let index = 0; index < content.length; index += 1) {
hash ^= content.charCodeAt(index)
hash = Math.imul(hash, 16777619)
}
versions.set(fileId, `draft:${content.length}:${(hash >>> 0).toString(16)}`)
}
graphState.cachedEditorDraftsSource = editorDrafts
graphState.cachedEditorDraftVersionByFileId = versions
return versions
}
export function buildMobileSessionAgentStatusByWorktree(
agentStatusByPaneKey: AppState['agentStatusByPaneKey'],
tabsByWorktree: AppState['tabsByWorktree']
): MobileSessionAgentStatusByWorktree {
const byWorktreeId = new Map<string, Map<string, AppState['agentStatusByPaneKey'][string]>>()
const paneKeys = Object.keys(agentStatusByPaneKey)
if (paneKeys.length === 0) {
return byWorktreeId
}
const worktreeIdByTabId = new Map<string, string>()
for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) {
for (const tab of tabs) {
worktreeIdByTabId.set(tab.id, worktreeId)
}
}
for (const paneKey of paneKeys) {
const tabId = parsePaneKey(paneKey)?.tabId
const worktreeId = tabId === undefined ? undefined : worktreeIdByTabId.get(tabId)
if (worktreeId === undefined) {
continue
}
let bucket = byWorktreeId.get(worktreeId)
if (!bucket) {
bucket = new Map()
byWorktreeId.set(worktreeId, bucket)
}
bucket.set(paneKey, agentStatusByPaneKey[paneKey])
}
return byWorktreeId
}
export function buildMobileSessionWorktreeInputs(
state: AppState,
worktreeId: string,
publication: MobileSessionPublicationInputs
): MobileSessionWorktreeInputs {
const terminalTabs = state.tabsByWorktree[worktreeId] ?? EMPTY_WORKTREE_TERMINAL_TABS
const terminalTabIds = terminalTabs.map((tab) => tab.id)
const terminalLayoutByTabId = narrowRecordByKeys(state.terminalLayoutsByTabId, terminalTabIds)
const mountedSurfaceCaptureByTabId = captureMountedTerminalSurfaces(
terminalTabs,
state.terminalLayoutsByTabId
)
const browserWorkspaces =
publication.browserTabsByWorktree[worktreeId] ?? EMPTY_WORKTREE_BROWSER_WORKSPACES
const pagesByBrowserWorkspaceId = narrowRecordByKeys(
state.browserPagesByWorkspace,
browserWorkspaces.map((workspace) => workspace.id)
)
const browserPageIds: string[] = []
for (const pages of pagesByBrowserWorkspaceId.values()) {
for (const page of pages) {
browserPageIds.push(page.id)
}
}
const openFilesById = publication.openFileIndexes.byWorktreeAndId.get(worktreeId)
const openFileIds =
publication.openFileIndexes.idsByWorktree.get(worktreeId) ?? EMPTY_WORKTREE_OPEN_FILE_IDS
const resolvedActiveFileId = state.activeFileIdByWorktree?.[worktreeId] ?? state.activeFileId
const activeEditorFileId =
resolvedActiveFileId && openFilesById?.has(resolvedActiveFileId) ? resolvedActiveFileId : null
const activeTabId = state.activeTabId
return {
worktreeId,
terminalTabs,
browserWorkspaces,
unifiedTabs: state.unifiedTabsByWorktree[worktreeId] ?? EMPTY_WORKTREE_UNIFIED_TABS,
groups: state.groupsByWorktree[worktreeId] ?? EMPTY_WORKTREE_TAB_GROUPS,
tabBarOrder: state.tabBarOrderByWorktree[worktreeId],
activeGroupId: state.activeGroupIdByWorktree[worktreeId] ?? null,
tabGroupLayout: (state.layoutByWorktree ?? EMPTY_LAYOUT_BY_WORKTREE)[worktreeId],
openFilesById,
openFileIds,
terminalLayoutByTabId,
paneTitlesByTabId: narrowRecordByKeys(state.runtimePaneTitlesByTabId, terminalTabIds),
launchDraftByPaneKey: buildMobileLaunchDraftsByPaneKey({
terminalTabs,
terminalLayoutByTabId,
mountedSurfaceCaptureByTabId,
launchDraftByTabId: narrowRecordByKeys(state.nativeChatLaunchDraftByTabId, terminalTabIds)
}),
agentStatusByPaneKey:
publication.agentStatusByWorktreeId.get(worktreeId) ?? EMPTY_NARROWED_BY_KEY,
editorDraftVersionByFileId: narrowMapByKeys(
publication.editorDraftVersionByFileId,
openFileIds
),
pagesByBrowserWorkspaceId,
certificateFailureByBrowserPageId: narrowRecordByKeys(
state.browserCertificateFailuresByPageId,
browserPageIds
),
activeEditorFileId,
activeEditorTabType: activeEditorFileId
? (state.activeTabTypeByWorktree?.[worktreeId] ?? state.activeTabType)
: null,
activeTerminalTabId:
activeTabId !== null && terminalTabIds.includes(activeTabId) ? activeTabId : null,
activeBrowserWorkspaceId: state.activeBrowserTabIdByWorktree?.[worktreeId] ?? null,
generatedTitlesEnabled: publication.generatedTitlesEnabled,
terminalTheme: publication.terminalTheme,
mountedSurfaceCaptureByTabId
}
}
function narrowMapByKeys<T>(
source: ReadonlyMap<string, T>,
keys: readonly string[]
): ReadonlyMap<string, T> {
if (source.size === 0 || keys.length === 0) {
return EMPTY_NARROWED_BY_KEY
}
let narrowed: Map<string, T> | null = null
for (const key of keys) {
if (!source.has(key)) {
continue
}
narrowed ??= new Map<string, T>()
narrowed.set(key, source.get(key) as T)
}
return narrowed ?? EMPTY_NARROWED_BY_KEY
}
export function buildMobileLaunchDraftsByPaneKey(args: {
terminalTabs: AppState['tabsByWorktree'][string]
terminalLayoutByTabId: MobileSessionWorktreeInputs['terminalLayoutByTabId']
mountedSurfaceCaptureByTabId: MobileSessionWorktreeInputs['mountedSurfaceCaptureByTabId']
launchDraftByTabId: ReadonlyMap<
string,
NonNullable<AppState['nativeChatLaunchDraftByTabId']>[string]
>
}): MobileSessionWorktreeInputs['launchDraftByPaneKey'] {
if (args.launchDraftByTabId.size === 0) {
return EMPTY_NARROWED_BY_KEY
}
let draftsByPaneKey: Map<
string,
NonNullable<AppState['nativeChatLaunchDraftByTabId']>[string]
> | null = null
for (const terminal of args.terminalTabs) {
const draft = args.launchDraftByTabId.get(terminal.id)
if (!draft || draft.resolved) {
continue
}
const capture = args.mountedSurfaceCaptureByTabId.get(terminal.id)
const savedLayout = args.terminalLayoutByTabId.get(terminal.id)
const leafIds = getRuntimeLeafIdsForTerminal(capture, savedLayout)
const ownerLeafId = resolveMobileTabWideAgentHintLeafId(capture, savedLayout)
const launchAgent = nativeChatLaunchAgentForLeaf({
launchAgent: terminal.launchAgent,
launchAgentLeafId: ownerLeafId,
leafId: ownerLeafId,
leafIds
})
if (!launchAgent || launchAgent !== draft.agent || !ownerLeafId) {
continue
}
draftsByPaneKey ??= new Map()
draftsByPaneKey.set(makePaneKey(terminal.id, ownerLeafId), draft)
}
return draftsByPaneKey ?? EMPTY_NARROWED_BY_KEY
}
@@ -0,0 +1,221 @@
import { getSystemPrefersDark } from '@/lib/terminal-theme'
import type { AppState } from '@/store/types'
import type {
RuntimeMobileSessionSnapshotTab,
RuntimeMobileSessionTabsSnapshot
} from '../../../../shared/runtime-types'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { graphState, jsonContentEquals, mobilePublicationEpoch } from './graph-state'
import {
buildMobileSessionAgentStatusByWorktree,
buildMobileSessionWorktreeInputs,
getEditorDraftVersionByFileId,
getOpenFileIndexes
} from './mobile-session-inputs'
import { getMobileTerminalTheme } from './mobile-terminal-theme'
import {
isMobilePublishableBrowserWorkspace,
isMobilePublishableOpenFile,
isWebOnlyMirroredTerminalTab
} from './mobile-session-tab-helpers'
import {
appendFallbackEditorTabsToGroups,
buildMobileSessionGroupProjection,
pruneTabGroupLayout
} from './mobile-session-group-projection'
import { buildMobileTerminalSurfaceTabs } from './mobile-session-terminal-tabs'
import { buildMobileMarkdownTab, buildMobileFileTab } from './mobile-session-editor-tabs'
import { buildMobileBrowserTab } from './mobile-session-browser-tabs'
import type { MobileSessionPublicationInputs } from './types'
import { canReuseMobileSessionSnapshot } from './mobile-session-capture'
export function buildMobileSessionTabSnapshots(
state: AppState,
systemPrefersDark = getSystemPrefersDark()
): RuntimeMobileSessionTabsSnapshot[] {
const openFileIndexes = getOpenFileIndexes(state.openFiles)
const browserTabsByWorktree = state.browserTabsByWorktree ?? {}
const publicationInputs: MobileSessionPublicationInputs = {
browserTabsByWorktree,
openFileIndexes,
editorDraftVersionByFileId: getEditorDraftVersionByFileId(state.editorDrafts),
agentStatusByWorktreeId: buildMobileSessionAgentStatusByWorktree(
state.agentStatusByPaneKey ?? {},
state.tabsByWorktree
),
generatedTitlesEnabled: state.settings?.tabAutoGenerateTitle === true,
terminalTheme: getMobileTerminalTheme(state, systemPrefersDark)
}
const liveFolderWorkspaceIds = new Set(
(state.folderWorkspaces ?? []).map((workspace) => workspace.id)
)
const worktreeIds = new Set<string>([
...Object.keys(state.tabsByWorktree),
...Object.keys(state.groupsByWorktree),
...Object.keys(state.unifiedTabsByWorktree),
...Object.keys(browserTabsByWorktree),
...state.openFiles.map((file) => file.worktreeId)
])
const snapshots: RuntimeMobileSessionTabsSnapshot[] = []
for (const worktreeId of worktreeIds) {
const workspaceScope = parseWorkspaceKey(worktreeId)
if (
workspaceScope?.type === 'folder' &&
!liveFolderWorkspaceIds.has(workspaceScope.folderWorkspaceId)
) {
graphState.mobileSessionSnapshotCacheByWorktree.delete(worktreeId)
continue
}
const inputs = buildMobileSessionWorktreeInputs(state, worktreeId, publicationInputs)
const cached = graphState.mobileSessionSnapshotCacheByWorktree.get(worktreeId)
if (cached && canReuseMobileSessionSnapshot(cached.inputs, inputs)) {
snapshots.push(cached.snapshot)
continue
}
const terminalTabById = new Map(inputs.terminalTabs.map((tab) => [tab.id, tab]))
const browserWorkspaceById = new Map(
inputs.browserWorkspaces.map((workspace) => [workspace.id, workspace])
)
const unifiedTabById = new Map(inputs.unifiedTabs.map((tab) => [tab.id, tab]))
const openFilesForWorktree = inputs.openFilesById
const editorIds = inputs.openFileIds.filter((fileId) => {
const file = openFilesForWorktree?.get(fileId)
return file ? isMobilePublishableOpenFile(file) : false
})
const terminalIds = [...terminalTabById.values()]
.filter(
(terminal) =>
!isWebOnlyMirroredTerminalTab(terminal, inputs.terminalLayoutByTabId.get(terminal.id))
)
.map((terminal) => terminal.id)
const groupProjection = buildMobileSessionGroupProjection(inputs, {
terminalIds,
editorIds,
browserIds: [...browserWorkspaceById.values()]
.filter(isMobilePublishableBrowserWorkspace)
.map((workspace) => workspace.id)
})
const tabs: RuntimeMobileSessionSnapshotTab[] = []
const emittedEditorFileIds = new Set<string>()
const emittedEditorTabIds = new Set<string>()
for (const item of groupProjection.order) {
if (item.type === 'terminal') {
const terminal = terminalTabById.get(item.id)
if (
!terminal ||
isWebOnlyMirroredTerminalTab(terminal, inputs.terminalLayoutByTabId.get(terminal.id))
) {
continue
}
tabs.push(...buildMobileTerminalSurfaceTabs(inputs, terminal, item.tabId))
} else if (item.type === 'editor') {
const file = openFilesForWorktree?.get(item.id)
if (!file || !isMobilePublishableOpenFile(file)) {
continue
}
const unifiedTab = item.tabId ? unifiedTabById.get(item.tabId) : undefined
tabs.push(
buildMobileMarkdownTab(inputs, file, unifiedTab) ??
buildMobileFileTab(inputs, file, unifiedTab)
)
emittedEditorFileIds.add(file.id)
emittedEditorTabIds.add(item.tabId ?? item.id)
} else if (item.type === 'browser') {
const workspace = browserWorkspaceById.get(item.id)
if (!workspace || !isMobilePublishableBrowserWorkspace(workspace)) {
continue
}
tabs.push(
buildMobileBrowserTab(
inputs,
workspace,
item.tabId ? unifiedTabById.get(item.tabId) : undefined
)
)
}
}
const fallbackEditorTabs: { tabId: string; groupId: string | null }[] = []
if (openFilesForWorktree) {
const unifiedEditorTabs = inputs.unifiedTabs.filter(
(tab) => tab.contentType === 'editor' || tab.contentType === 'diff'
)
const unifiedEditorFileIds = new Set(unifiedEditorTabs.map((tab) => tab.entityId))
for (const unifiedTab of unifiedEditorTabs) {
if (emittedEditorTabIds.has(unifiedTab.id)) {
continue
}
const file = openFilesForWorktree.get(unifiedTab.entityId)
if (!file || !isMobilePublishableOpenFile(file)) {
continue
}
const fallbackTab =
buildMobileMarkdownTab(inputs, file, unifiedTab) ??
buildMobileFileTab(inputs, file, unifiedTab)
tabs.push(fallbackTab)
fallbackEditorTabs.push({ tabId: fallbackTab.id, groupId: unifiedTab.groupId })
emittedEditorTabIds.add(unifiedTab.id)
}
for (const file of openFilesForWorktree.values()) {
if (!isMobilePublishableOpenFile(file) || emittedEditorFileIds.has(file.id)) {
continue
}
if (unifiedEditorFileIds.has(file.id)) {
emittedEditorFileIds.add(file.id)
continue
}
const fallbackTab = buildMobileMarkdownTab(inputs, file) ?? buildMobileFileTab(inputs, file)
tabs.push(fallbackTab)
fallbackEditorTabs.push({ tabId: fallbackTab.id, groupId: null })
emittedEditorFileIds.add(file.id)
}
}
const active = tabs.find((tab) => tab.isActive) ?? null
const tabGroups = appendFallbackEditorTabsToGroups(
groupProjection.tabGroups,
inputs.groups,
inputs.activeGroupId,
fallbackEditorTabs,
active?.id ?? null
)
const tabGroupLayout =
tabGroups && tabGroups.length > 0
? pruneTabGroupLayout(inputs.tabGroupLayout, new Set(tabGroups.map((group) => group.id)))
: groupProjection.tabGroupLayout
const content = {
activeGroupId: inputs.activeGroupId,
activeTabId: active?.id ?? null,
activeTabType: active?.type ?? null,
...(tabGroups && tabGroups.length > 0 ? { tabGroups } : {}),
...(tabGroupLayout ? { tabGroupLayout } : {}),
tabs
}
const candidateVersion = ++graphState.mobileSessionSnapshotVersion
if (cached && jsonContentEquals(cached.content, content)) {
graphState.mobileSessionSnapshotCacheByWorktree.set(worktreeId, {
inputs,
content,
snapshot: cached.snapshot
})
snapshots.push(cached.snapshot)
continue
}
const snapshot: RuntimeMobileSessionTabsSnapshot = {
worktree: worktreeId,
publicationEpoch: mobilePublicationEpoch,
snapshotVersion: candidateVersion,
...content
}
graphState.mobileSessionSnapshotCacheByWorktree.set(worktreeId, { inputs, content, snapshot })
snapshots.push(snapshot)
}
for (const worktreeId of graphState.mobileSessionSnapshotCacheByWorktree.keys()) {
if (!worktreeIds.has(worktreeId)) {
graphState.mobileSessionSnapshotCacheByWorktree.delete(worktreeId)
}
}
return snapshots
}
@@ -0,0 +1,110 @@
import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization'
import type { AppState } from '@/store/types'
import { isClaudeManagementTitle } from '../../../../shared/agent-detection'
import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id'
import { isWebTerminalSurfaceTabId } from '../../../../shared/terminal-surface-id'
import type { Tab } from '../../../../shared/tab-types'
import { parseRemoteRuntimePtyId } from '../runtime-terminal-stream'
import {
isNativeChatTabWideFallbackSafe,
nativeChatLaunchAgentForLeaf,
resolveNativeChatActiveLayoutLeafId
} from '../../components/native-chat/native-chat-leaf-routing'
import type { MobileSessionWorktreeInputs, MountedTerminalSurfaceCapture } from './types'
export function mobileTerminalSurfaceId(parentTabId: string, leafId: string): string {
return `${parentTabId}::${leafId}`
}
export function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean {
return typeof ptyId === 'string' && parseRemoteRuntimePtyId(ptyId) !== null
}
export function isWebOnlyMirroredTerminalTab(
tab: Pick<NonNullable<AppState['tabsByWorktree'][string]>[number], 'id' | 'ptyId'>,
layout: AppState['terminalLayoutsByTabId'][string] | undefined
): boolean {
if (!isWebTerminalSurfaceTabId(tab.id)) {
return false
}
const layoutPtyIds = Object.values(layout?.ptyIdsByLeafId ?? {})
const ptyIds = [tab.ptyId, ...layoutPtyIds].filter(
(ptyId): ptyId is string => typeof ptyId === 'string' && ptyId.length > 0
)
// Only-remote/no-PTY tabs are web mirrors; legacy local-PTY tabs still publish.
return ptyIds.every(isRemoteRuntimePtyId)
}
export function getRuntimeLeafIdsForTerminal(
capture: MountedTerminalSurfaceCapture | undefined,
savedLayout: AppState['terminalLayoutsByTabId'][string] | undefined
): readonly string[] {
const liveLeafIds = capture?.paneLeafIds ?? []
if (liveLeafIds.length > 0) {
return liveLeafIds
}
const persistedLeafIds = collectLeafIdsInOrder(savedLayout?.root).filter(isTerminalLeafId)
if (persistedLeafIds.length > 0) {
return persistedLeafIds
}
// A new tab can predate TerminalPane mount; do not fabricate a stale pane:1.
return []
}
export function resolveMobileTabWideAgentHintLeafId(
capture: MountedTerminalSurfaceCapture | undefined,
savedLayout: AppState['terminalLayoutsByTabId'][string] | undefined
): string | null {
if (capture) {
return capture.tabWideAgentHintLeafId
}
return isNativeChatTabWideFallbackSafe(savedLayout)
? resolveNativeChatActiveLayoutLeafId(savedLayout)
: null
}
export function isEditorSurfaceTab(tab: Pick<Tab, 'contentType'>): boolean {
// Mobile can mirror ordinary edit/diff files; other editor tabs need extra metadata.
return tab.contentType === 'editor' || tab.contentType === 'diff'
}
export function isFileActiveEditorSurface(
inputs: Pick<MobileSessionWorktreeInputs, 'activeEditorFileId' | 'activeEditorTabType'>,
file: Pick<AppState['openFiles'][number], 'id'>
): boolean {
return inputs.activeEditorTabType === 'editor' && inputs.activeEditorFileId === file.id
}
export function isMobileFileDiffSource(
diffSource: AppState['openFiles'][number]['diffSource']
): diffSource is 'staged' | 'unstaged' {
return diffSource === 'staged' || diffSource === 'unstaged'
}
export function isMobilePublishableOpenFile(file: AppState['openFiles'][number]): boolean {
// Combined diff tabs use display labels as paths and need the desktop renderer.
return !(
file.diffSource === 'combined-all' ||
file.diffSource === 'combined-uncommitted' ||
file.diffSource === 'combined-branch' ||
file.diffSource === 'combined-commit'
)
}
export function isMobilePublishableBrowserWorkspace(
workspace: NonNullable<AppState['browserTabsByWorktree'][string]>[number]
): boolean {
// Document previews are served through a desktop-only grant.
return !workspace.docLocation
}
export function isUnifiedTabActiveInActiveGroup(
inputs: Pick<MobileSessionWorktreeInputs, 'groups' | 'activeGroupId'>,
unifiedTabId: string
): boolean {
return inputs.groups.some(
(group) => group.id === inputs.activeGroupId && group.activeTabId === unifiedTabId
)
}
export { isClaudeManagementTitle, isTerminalLeafId, makePaneKey, nativeChatLaunchAgentForLeaf }
@@ -0,0 +1,126 @@
import { normalizeTerminalLayoutSnapshot } from '@/components/terminal-pane/layout-serialization'
import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization'
import type { AppState } from '@/store/types'
import type { RuntimeMobileSessionSnapshotTab } from '../../../../shared/runtime-types'
import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types'
import {
isNativeChatTabWideFallbackSafe,
nativeChatLaunchAgentForLeaf
} from '../../components/native-chat/native-chat-leaf-routing'
import type { MobileSessionWorktreeInputs } from './types'
import {
isClaudeManagementTitle,
isTerminalLeafId,
makePaneKey,
mobileTerminalSurfaceId,
getRuntimeLeafIdsForTerminal,
resolveMobileTabWideAgentHintLeafId,
isUnifiedTabActiveInActiveGroup
} from './mobile-session-tab-helpers'
import { resolveRuntimeTerminalTitle } from './sync-projections'
import { resolveTerminalLayoutRoot } from '../remote-terminal-layout-resolution'
export function buildMobileTerminalSurfaceTabs(
inputs: MobileSessionWorktreeInputs,
terminal: NonNullable<AppState['tabsByWorktree'][string]>[number],
unifiedTabId?: string
): RuntimeMobileSessionSnapshotTab[] {
const capture = inputs.mountedSurfaceCaptureByTabId.get(terminal.id)
const isDesktopTabActive = unifiedTabId
? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId)
: inputs.activeTerminalTabId === terminal.id
const savedLayout = inputs.terminalLayoutByTabId.get(terminal.id)
const leafIds = getRuntimeLeafIdsForTerminal(capture, savedLayout)
const launchAgentLeafId = resolveMobileTabWideAgentHintLeafId(capture, savedLayout)
const activeLeafId = capture?.hasLiveActivePane
? capture.liveActiveLeafId
: (savedLayout?.activeLeafId ?? leafIds[0] ?? null)
const paneTitles = inputs.paneTitlesByTabId.get(terminal.id) ?? {}
const sanitizedSavedLayout = savedLayout
? sanitizeTerminalLayoutPaneTitles(savedLayout, terminal)
: undefined
const savedPtyIdsByLeafId = sanitizedSavedLayout?.ptyIdsByLeafId ?? {}
const liveLayoutRoot = capture?.liveLayoutRoot ?? null
const parentLayout = normalizeTerminalLayoutSnapshot({
// Live DOM is authoritative when mounted; use saved tree otherwise.
root: resolveTerminalLayoutRoot({
authoritativeRoot: liveLayoutRoot,
existingRoot: sanitizedSavedLayout?.root,
leafIds,
onSynthesize: (leafCount) =>
console.warn(
`[sync-runtime-graph] synthesized parentLayout for ${leafCount} leaves with no live or saved tree`
)
}),
activeLeafId,
expandedLeafId: sanitizedSavedLayout?.expandedLeafId ?? null,
...(Object.keys(savedPtyIdsByLeafId).length > 0 ? { ptyIdsByLeafId: savedPtyIdsByLeafId } : {}),
...(sanitizedSavedLayout?.titlesByLeafId
? { titlesByLeafId: sanitizedSavedLayout.titlesByLeafId }
: {})
} satisfies TerminalLayoutSnapshot).snapshot
return leafIds.map((leafId) => {
const numericPaneId = capture?.numericPaneIdByLeafId.get(leafId) ?? null
const ptyId =
numericPaneId === null
? (savedPtyIdsByLeafId[leafId] ?? (leafIds.length === 1 ? terminal.ptyId : null))
: (capture?.ptyIdByNumericPaneId.get(numericPaneId) ?? savedPtyIdsByLeafId[leafId] ?? null)
const legacyPaneId = numericPaneId === null ? /^pane:(\d+)$/.exec(leafId)?.[1] : null
const paneTitle =
numericPaneId !== null
? paneTitles[numericPaneId]
: legacyPaneId
? paneTitles[Number(legacyPaneId)]
: undefined
const leafTitle = paneTitle?.trim() || sanitizedSavedLayout?.titlesByLeafId?.[leafId]?.trim()
const paneKey = isTerminalLeafId(leafId) ? makePaneKey(terminal.id, leafId) : null
const tabWideFallbackSafe =
isNativeChatTabWideFallbackSafe(parentLayout) && launchAgentLeafId === leafId
const title = tabWideFallbackSafe
? resolveRuntimeTerminalTitle(
terminal,
inputs.generatedTitlesEnabled,
leafTitle ?? terminal.title ?? 'Terminal'
)
: (leafTitle ?? 'Terminal')
const agentStatusTitle = leafTitle ?? (tabWideFallbackSafe ? terminal.title : '') ?? ''
const agentStatus =
paneKey && !isClaudeManagementTitle(agentStatusTitle)
? inputs.agentStatusByPaneKey.get(paneKey)
: undefined
const launchAgent = nativeChatLaunchAgentForLeaf({
launchAgent: terminal.launchAgent,
launchAgentLeafId,
leafId,
leafIds
})
const launchDraft = paneKey ? inputs.launchDraftByPaneKey.get(paneKey) : undefined
const publishedLaunchDraft =
launchDraft && launchDraft.agent === launchAgent && launchDraft.text.trim()
? launchDraft
: null
return {
type: 'terminal' as const,
id: mobileTerminalSurfaceId(terminal.id, leafId),
title,
...(tabWideFallbackSafe && terminal.quickCommandLabel?.trim()
? { quickCommandLabel: terminal.quickCommandLabel.trim() }
: {}),
parentTabId: terminal.id,
leafId,
ptyId,
...(inputs.terminalTheme ? { terminalTheme: inputs.terminalTheme } : {}),
...(agentStatus ? { agentStatus } : {}),
...(launchAgent ? { launchAgent } : {}),
...(publishedLaunchDraft
? {
launchDraft: publishedLaunchDraft.text,
launchDraftCreatedAt: publishedLaunchDraft.createdAt
}
: {}),
parentLayout,
isActive: isDesktopTabActive && leafId === activeLeafId
}
})
}
@@ -0,0 +1,74 @@
import { getSystemPrefersDark, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme'
import type { AppState } from '@/store/types'
import type { RuntimeMobileTerminalTheme } from '../../../../shared/runtime-types'
import { graphState } from './graph-state'
function hexToRgba(hex: string, alpha: number): string {
let clean = hex.replace('#', '')
if (clean.length === 3) {
clean = clean
.split('')
.map((character) => character + character)
.join('')
}
const r = Number.parseInt(clean.slice(0, 2), 16)
const g = Number.parseInt(clean.slice(2, 4), 16)
const b = Number.parseInt(clean.slice(4, 6), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
function isHexColor(value: string | undefined): value is string {
return typeof value === 'string' && /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value)
}
export function resolveMobileTerminalTheme(
state: AppState,
systemPrefersDark: boolean
): RuntimeMobileTerminalTheme | undefined {
const settings = state.settings
if (!settings) {
return undefined
}
const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark)
const resolvedTheme = appearance.theme
? { ...appearance.theme, ...settings.terminalColorOverrides }
: undefined
if (!resolvedTheme) {
return undefined
}
if (settings.terminalBackgroundOpacity !== undefined && isHexColor(resolvedTheme.background)) {
resolvedTheme.background = hexToRgba(
resolvedTheme.background,
settings.terminalBackgroundOpacity
)
}
if (settings.terminalCursorOpacity !== undefined && isHexColor(resolvedTheme.cursor)) {
resolvedTheme.cursor = hexToRgba(resolvedTheme.cursor, settings.terminalCursorOpacity)
}
const theme: Record<string, string> = {}
for (const [key, value] of Object.entries(resolvedTheme)) {
if (typeof value === 'string') {
theme[key] = value
}
}
return { mode: appearance.mode, theme: theme as RuntimeMobileTerminalTheme['theme'] }
}
export function getMobileTerminalTheme(
state: AppState,
systemPrefersDark = getSystemPrefersDark()
): RuntimeMobileTerminalTheme | undefined {
if (
graphState.hasCachedMobileTerminalTheme &&
graphState.cachedMobileTerminalThemeSettings === state.settings &&
graphState.cachedMobileTerminalThemeSystemPrefersDark === systemPrefersDark
) {
return graphState.cachedMobileTerminalTheme
}
graphState.cachedMobileTerminalTheme = resolveMobileTerminalTheme(state, systemPrefersDark)
graphState.cachedMobileTerminalThemeSettings = state.settings
graphState.cachedMobileTerminalThemeSystemPrefersDark = systemPrefersDark
graphState.hasCachedMobileTerminalTheme = true
return graphState.cachedMobileTerminalTheme
}
@@ -0,0 +1,182 @@
import { getSystemPrefersDark } from '@/lib/terminal-theme'
import type { AppState } from '@/store/types'
import {
EMPTY_ACTIVE_BROWSER_TAB_ID_BY_WORKTREE,
EMPTY_AGENT_STATUS_BY_PANE_KEY,
EMPTY_BROWSER_PAGES_BY_WORKSPACE,
EMPTY_BROWSER_TABS_BY_WORKTREE,
EMPTY_LAYOUT_BY_WORKTREE
} from './graph-state'
import {
buildRuntimeMobileAgentStatusProjection,
buildRuntimeMobileAgentStatusProjectionForTests,
resetRuntimeMobileAgentStatusProjectionCacheForTests,
AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS
} from './agent-status-projection'
import {
buildRuntimeMobileBrowserProjection,
buildRuntimeMobileEditorDraftsProjection,
buildRuntimeMobileOpenFilesProjection,
buildRuntimeMobileTabsProjection,
getBrowserPagesByWorkspace,
getBrowserTabsByWorktree
} from './sync-projections'
import { resolveMobileTerminalTheme } from './mobile-terminal-theme'
import type { RuntimeMobileSessionSyncKey } from './types'
export {
AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS,
buildRuntimeMobileAgentStatusProjectionForTests,
resetRuntimeMobileAgentStatusProjectionCacheForTests
}
export type { RuntimeMobileSessionSyncKey } from './types'
function getTerminalThemeSystemPrefersDark(
state: Pick<AppState, 'settings'>,
systemPrefersDark: boolean | null | undefined
): boolean | null {
return state.settings?.theme === 'system' ? (systemPrefersDark ?? null) : null
}
export function canSkipRuntimeMobileSessionSyncKeyBuild(
state: AppState,
previousState: AppState,
systemPrefersDark?: boolean,
previousSystemPrefersDark: boolean | null | undefined = systemPrefersDark
): boolean {
const currentThemePreference = getTerminalThemeSystemPrefersDark(state, systemPrefersDark)
const previousThemePreference = getTerminalThemeSystemPrefersDark(
previousState,
previousSystemPrefersDark
)
return (
currentThemePreference === previousThemePreference &&
state.tabsByWorktree === previousState.tabsByWorktree &&
state.groupsByWorktree === previousState.groupsByWorktree &&
state.activeGroupIdByWorktree === previousState.activeGroupIdByWorktree &&
state.layoutByWorktree === previousState.layoutByWorktree &&
state.unifiedTabsByWorktree === previousState.unifiedTabsByWorktree &&
state.tabBarOrderByWorktree === previousState.tabBarOrderByWorktree &&
state.activeFileId === previousState.activeFileId &&
state.activeFileIdByWorktree === previousState.activeFileIdByWorktree &&
state.activeTabType === previousState.activeTabType &&
state.activeTabTypeByWorktree === previousState.activeTabTypeByWorktree &&
getBrowserTabsByWorktree(state) === getBrowserTabsByWorktree(previousState) &&
getBrowserPagesByWorkspace(state) === getBrowserPagesByWorkspace(previousState) &&
(state.activeBrowserTabIdByWorktree ?? EMPTY_ACTIVE_BROWSER_TAB_ID_BY_WORKTREE) ===
(previousState.activeBrowserTabIdByWorktree ?? EMPTY_ACTIVE_BROWSER_TAB_ID_BY_WORKTREE) &&
state.openFiles === previousState.openFiles &&
state.editorDrafts === previousState.editorDrafts &&
state.settings === previousState.settings &&
state.activeTabId === previousState.activeTabId &&
state.terminalLayoutsByTabId === previousState.terminalLayoutsByTabId &&
state.runtimePaneTitlesByTabId === previousState.runtimePaneTitlesByTabId &&
state.nativeChatLaunchDraftByTabId === previousState.nativeChatLaunchDraftByTabId &&
state.folderWorkspaces === previousState.folderWorkspaces &&
state.agentStatusEpoch === previousState.agentStatusEpoch &&
state.agentStatusByPaneKey === previousState.agentStatusByPaneKey
)
}
export function getRuntimeMobileSessionSyncKey(
state: AppState,
previousState?: AppState,
previousKey?: RuntimeMobileSessionSyncKey,
systemPrefersDark = getSystemPrefersDark()
): RuntimeMobileSessionSyncKey {
const canReusePrevious = previousState !== undefined && previousKey !== undefined
const themeSystemPrefersDark = getTerminalThemeSystemPrefersDark(state, systemPrefersDark)
const browserTabsByWorktree = getBrowserTabsByWorktree(state)
const browserPagesByWorkspace = getBrowserPagesByWorkspace(state)
const agentStatusByPaneKey = state.agentStatusByPaneKey ?? EMPTY_AGENT_STATUS_BY_PANE_KEY
const previousBrowserTabsByWorktree = previousState
? getBrowserTabsByWorktree(previousState)
: EMPTY_BROWSER_TABS_BY_WORKTREE
const previousBrowserPagesByWorkspace = previousState
? getBrowserPagesByWorkspace(previousState)
: EMPTY_BROWSER_PAGES_BY_WORKSPACE
const previousAgentStatusByPaneKey = previousState
? (previousState.agentStatusByPaneKey ?? EMPTY_AGENT_STATUS_BY_PANE_KEY)
: EMPTY_AGENT_STATUS_BY_PANE_KEY
return {
terminalLayoutsByTabId: state.terminalLayoutsByTabId,
runtimePaneTitlesByTabId: state.runtimePaneTitlesByTabId,
nativeChatLaunchDraftByTabId: state.nativeChatLaunchDraftByTabId,
folderWorkspaces: state.folderWorkspaces,
groupsByWorktree: state.groupsByWorktree,
activeGroupIdByWorktree: state.activeGroupIdByWorktree,
layoutByWorktree: state.layoutByWorktree ?? EMPTY_LAYOUT_BY_WORKTREE,
unifiedTabsByWorktree: state.unifiedTabsByWorktree,
tabBarOrderByWorktree: state.tabBarOrderByWorktree,
activeFileId: state.activeFileId,
activeFileIdByWorktree: state.activeFileIdByWorktree,
activeTabType: state.activeTabType,
activeTabTypeByWorktree: state.activeTabTypeByWorktree,
activeTabId: state.activeTabId,
activeBrowserTabIdByWorktree:
state.activeBrowserTabIdByWorktree ?? EMPTY_ACTIVE_BROWSER_TAB_ID_BY_WORKTREE,
agentStatusEpoch: state.agentStatusEpoch ?? 0,
agentStatusProjection:
canReusePrevious && agentStatusByPaneKey === previousAgentStatusByPaneKey
? previousKey.agentStatusProjection
: buildRuntimeMobileAgentStatusProjection(agentStatusByPaneKey),
generatedTabTitlesEnabled: state.settings?.tabAutoGenerateTitle === true,
systemPrefersDark: themeSystemPrefersDark,
terminalThemeProjection:
canReusePrevious &&
state.settings === previousState.settings &&
previousKey.systemPrefersDark === themeSystemPrefersDark
? previousKey.terminalThemeProjection
: JSON.stringify(resolveMobileTerminalTheme(state, systemPrefersDark) ?? null),
tabsProjection:
canReusePrevious && state.tabsByWorktree === previousState.tabsByWorktree
? previousKey.tabsProjection
: buildRuntimeMobileTabsProjection(state.tabsByWorktree),
openFilesProjection:
canReusePrevious && state.openFiles === previousState.openFiles
? previousKey.openFilesProjection
: buildRuntimeMobileOpenFilesProjection(state.openFiles),
browserProjection:
canReusePrevious &&
browserTabsByWorktree === previousBrowserTabsByWorktree &&
browserPagesByWorkspace === previousBrowserPagesByWorkspace
? previousKey.browserProjection
: buildRuntimeMobileBrowserProjection(state),
editorDraftsProjection:
canReusePrevious && state.editorDrafts === previousState.editorDrafts
? previousKey.editorDraftsProjection
: buildRuntimeMobileEditorDraftsProjection(state.editorDrafts)
}
}
export function runtimeMobileSessionSyncKeysEqual(
a: RuntimeMobileSessionSyncKey,
b: RuntimeMobileSessionSyncKey
): boolean {
return (
a.terminalLayoutsByTabId === b.terminalLayoutsByTabId &&
a.runtimePaneTitlesByTabId === b.runtimePaneTitlesByTabId &&
a.nativeChatLaunchDraftByTabId === b.nativeChatLaunchDraftByTabId &&
a.folderWorkspaces === b.folderWorkspaces &&
a.groupsByWorktree === b.groupsByWorktree &&
a.activeGroupIdByWorktree === b.activeGroupIdByWorktree &&
a.layoutByWorktree === b.layoutByWorktree &&
a.unifiedTabsByWorktree === b.unifiedTabsByWorktree &&
a.tabBarOrderByWorktree === b.tabBarOrderByWorktree &&
a.activeFileId === b.activeFileId &&
a.activeFileIdByWorktree === b.activeFileIdByWorktree &&
a.activeTabType === b.activeTabType &&
a.activeTabTypeByWorktree === b.activeTabTypeByWorktree &&
a.activeTabId === b.activeTabId &&
a.activeBrowserTabIdByWorktree === b.activeBrowserTabIdByWorktree &&
a.agentStatusEpoch === b.agentStatusEpoch &&
a.agentStatusProjection === b.agentStatusProjection &&
a.generatedTabTitlesEnabled === b.generatedTabTitlesEnabled &&
a.systemPrefersDark === b.systemPrefersDark &&
a.terminalThemeProjection === b.terminalThemeProjection &&
a.tabsProjection === b.tabsProjection &&
a.openFilesProjection === b.openFilesProjection &&
a.browserProjection === b.browserProjection &&
a.editorDraftsProjection === b.editorDraftsProjection
)
}
@@ -0,0 +1,157 @@
import type { AppState } from '@/store/types'
import { resolveTerminalTabTitle } from '../../../../shared/tab-title-resolution'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import {
EMPTY_AGENT_STATUS_BY_PANE_KEY,
EMPTY_BROWSER_PAGES_BY_WORKSPACE,
EMPTY_BROWSER_TABS_BY_WORKTREE,
graphState
} from './graph-state'
import { buildRuntimeMobileAgentStatusProjection } from './agent-status-projection'
export function getBrowserTabsByWorktree(state: AppState): AppState['browserTabsByWorktree'] {
// Some callers/tests build partial pre-browser states; treat missing slices as empty.
return state.browserTabsByWorktree ?? EMPTY_BROWSER_TABS_BY_WORKTREE
}
export function getBrowserPagesByWorkspace(state: AppState): AppState['browserPagesByWorkspace'] {
return state.browserPagesByWorkspace ?? EMPTY_BROWSER_PAGES_BY_WORKSPACE
}
export function resolveRuntimeTerminalTitle(
tab: Pick<
TerminalTab,
'customTitle' | 'quickCommandLabel' | 'aiVaultTitle' | 'generatedTitle' | 'title'
>,
generatedTitlesEnabled: boolean,
liveTitle = tab.title
): string {
return resolveTerminalTabTitle({ ...tab, title: liveTitle }, generatedTitlesEnabled, liveTitle)
}
export function buildRuntimeMobileTabsProjection(
tabsByWorktree: AppState['tabsByWorktree']
): string {
if (graphState.cachedTabsProjection?.source === tabsByWorktree) {
return graphState.cachedTabsProjection.projection
}
const previousEntries = graphState.cachedTabsProjection?.entries
const entries = new Map<
string,
{
tabs: NonNullable<AppState['tabsByWorktree'][string]>
worktreeIdJson: string
projection: string
}
>()
const parts: string[] = []
for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) {
const previous = previousEntries?.get(worktreeId)
const entry =
previous?.tabs === tabs
? previous
: {
tabs,
worktreeIdJson: previous?.worktreeIdJson ?? JSON.stringify(worktreeId),
projection: JSON.stringify(
tabs.map((tab) => ({
id: tab.id,
title: tab.title,
quickCommandLabel: tab.quickCommandLabel,
aiVaultTitle: tab.aiVaultTitle,
generatedTitle: tab.generatedTitle,
customTitle: tab.customTitle,
launchAgent: tab.launchAgent
}))
)
}
entries.set(worktreeId, entry)
parts.push(`${entry.worktreeIdJson}:${entry.projection}`)
}
graphState.cachedTabsProjection = {
source: tabsByWorktree,
entries,
projection: `{${parts.join(',')}}`
}
return graphState.cachedTabsProjection.projection
}
export function buildRuntimeMobileOpenFilesProjection(openFiles: AppState['openFiles']): string {
return JSON.stringify(
openFiles.map((file) => ({
id: file.id,
filePath: file.filePath,
relativePath: file.relativePath,
worktreeId: file.worktreeId,
language: file.language,
mode: file.mode,
diffSource: file.diffSource,
isDirty: file.isDirty,
isUntitled: file.isUntitled,
deleteUntouchedOnClose: file.deleteUntouchedOnClose,
markdownPreviewSourceFileId: file.markdownPreviewSourceFileId
}))
)
}
export function buildRuntimeMobileBrowserProjection(state: AppState): string {
const browserTabsByWorktree = getBrowserTabsByWorktree(state)
const browserPagesByWorkspace = getBrowserPagesByWorkspace(state)
return JSON.stringify({
workspacesByWorktree: Object.fromEntries(
Object.entries(browserTabsByWorktree).map(([worktreeId, workspaces]) => [
worktreeId,
workspaces.map((workspace) => ({
id: workspace.id,
activePageId: workspace.activePageId,
title: workspace.title,
url: workspace.url,
loading: workspace.loading,
canGoBack: workspace.canGoBack,
canGoForward: workspace.canGoForward
}))
])
),
pagesByWorkspace: Object.fromEntries(
Object.entries(browserPagesByWorkspace).map(([workspaceId, pages]) => [
workspaceId,
pages.map((page) => ({
id: page.id,
title: page.title,
url: page.url,
loading: page.loading,
canGoBack: page.canGoBack,
canGoForward: page.canGoForward
}))
])
)
})
}
export function buildRuntimeMobileEditorDraftsProjection(
editorDrafts: AppState['editorDrafts']
): string {
return JSON.stringify(
Object.fromEntries(
Object.entries(editorDrafts).map(([fileId, content]) => [fileId, stableHashString(content)])
)
)
}
export function stableHashString(value: string): string {
let hash = 2166136261
for (let i = 0; i < value.length; i += 1) {
hash ^= value.charCodeAt(i)
hash = Math.imul(hash, 16777619)
}
return `draft:${value.length}:${(hash >>> 0).toString(16)}`
}
export function buildRuntimeMobileAgentStatusProjectionForState(
agentStatusByPaneKey: AppState['agentStatusByPaneKey'] | undefined
): string {
return buildRuntimeMobileAgentStatusProjection(
agentStatusByPaneKey ?? EMPTY_AGENT_STATUS_BY_PANE_KEY
)
}
@@ -0,0 +1,142 @@
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { AppState } from '@/store/types'
import type { RuntimeMobileTerminalTheme } from '../../../../shared/runtime-types'
import type { TabGroupLayoutNode } from '../../../../shared/tab-types'
import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types'
export type RuntimeMobileSessionSyncKey = {
// Reference changes signal layout/title updates without stringifying thousands of tabs.
terminalLayoutsByTabId: AppState['terminalLayoutsByTabId']
runtimePaneTitlesByTabId: AppState['runtimePaneTitlesByTabId']
nativeChatLaunchDraftByTabId: AppState['nativeChatLaunchDraftByTabId']
folderWorkspaces: AppState['folderWorkspaces']
groupsByWorktree: AppState['groupsByWorktree']
activeGroupIdByWorktree: AppState['activeGroupIdByWorktree']
layoutByWorktree: AppState['layoutByWorktree']
unifiedTabsByWorktree: AppState['unifiedTabsByWorktree']
tabBarOrderByWorktree: AppState['tabBarOrderByWorktree']
activeFileId: AppState['activeFileId']
activeFileIdByWorktree: AppState['activeFileIdByWorktree']
activeTabType: AppState['activeTabType']
activeTabTypeByWorktree: AppState['activeTabTypeByWorktree']
activeTabId: AppState['activeTabId']
activeBrowserTabIdByWorktree: AppState['activeBrowserTabIdByWorktree']
agentStatusEpoch: number
agentStatusProjection: string
generatedTabTitlesEnabled: boolean
systemPrefersDark: boolean | null
terminalThemeProjection: string
tabsProjection: string
openFilesProjection: string
browserProjection: string
editorDraftsProjection: string
}
export type RegisteredTerminalTab = {
tabId: string
worktreeId: string
getManager: () => PaneManager | null
getContainer: () => HTMLDivElement | null
getPtyIdForPane: (paneId: number) => string | null
getTabWideAgentHintLeafId: () => string | null
}
export type OpenFileByWorktreeAndId = Map<string, Map<string, AppState['openFiles'][number]>>
export type OpenFileIndexes = {
byWorktreeAndId: OpenFileByWorktreeAndId
idsByWorktree: Map<string, string[]>
}
export type FallbackEditorTabTarget = {
tabId: string
groupId: string | null
}
export type TabsProjectionCacheEntry = {
tabs: NonNullable<AppState['tabsByWorktree'][string]>
worktreeIdJson: string
projection: string
}
export type TabsProjectionCache = {
source: AppState['tabsByWorktree']
entries: Map<string, TabsProjectionCacheEntry>
projection: string
}
export type AgentStatusProjectionCacheEntry = {
entry: AppState['agentStatusByPaneKey'][string]
projection: string
}
export type AgentStatusProjectionCache = {
source: AppState['agentStatusByPaneKey']
entries: Map<string, AgentStatusProjectionCacheEntry>
projection: string
}
export type MobileSessionAgentStatusByWorktree = ReadonlyMap<
string,
ReadonlyMap<string, AppState['agentStatusByPaneKey'][string]>
>
/** Slices shared by every worktree in one publication; derived from `AppState` exactly once. */
export type MobileSessionPublicationInputs = {
browserTabsByWorktree: AppState['browserTabsByWorktree']
openFileIndexes: OpenFileIndexes
editorDraftVersionByFileId: ReadonlyMap<string, string>
agentStatusByWorktreeId: MobileSessionAgentStatusByWorktree
generatedTitlesEnabled: boolean
terminalTheme: RuntimeMobileTerminalTheme | undefined
}
/**
* Live PaneManager/DOM reads for one mounted terminal tab, captured once per
* publication.
*
* Why: builders read this instead of the registry, so live state the memo
* cannot witness is unrepresentable — mounted worktrees memoize like the rest
* instead of rebuilding on every publication.
*/
export type MountedTerminalSurfaceCapture = {
paneLeafIds: readonly string[]
hasLiveActivePane: boolean
liveActiveLeafId: string | null
liveLayoutRoot: TerminalPaneLayoutNode | null
numericPaneIdByLeafId: ReadonlyMap<string, number | null>
ptyIdByNumericPaneId: ReadonlyMap<number, string | null>
tabWideAgentHintLeafId: string | null
}
/**
* One worktree's complete mobile-snapshot input set.
*
* Why: every builder below takes this instead of `AppState`, so the compiler —
* not a reviewer — proves what a worktree's snapshot actually depends on.
*/
export type MobileSessionWorktreeInputs = {
worktreeId: string
terminalTabs: AppState['tabsByWorktree'][string]
browserWorkspaces: AppState['browserTabsByWorktree'][string]
unifiedTabs: AppState['unifiedTabsByWorktree'][string]
groups: AppState['groupsByWorktree'][string]
tabBarOrder: AppState['tabBarOrderByWorktree'][string] | undefined
activeGroupId: string | null
tabGroupLayout: TabGroupLayoutNode | undefined
openFilesById: ReadonlyMap<string, AppState['openFiles'][number]> | undefined
openFileIds: readonly string[]
terminalLayoutByTabId: ReadonlyMap<string, AppState['terminalLayoutsByTabId'][string]>
paneTitlesByTabId: ReadonlyMap<string, AppState['runtimePaneTitlesByTabId'][string]>
launchDraftByPaneKey: ReadonlyMap<
string,
NonNullable<AppState['nativeChatLaunchDraftByTabId']>[string]
>
agentStatusByPaneKey: ReadonlyMap<string, AppState['agentStatusByPaneKey'][string]>
editorDraftVersionByFileId: ReadonlyMap<string, string>
pagesByBrowserWorkspaceId: ReadonlyMap<
string,
NonNullable<AppState['browserPagesByWorkspace']>[string]
>
certificateFailureByBrowserPageId: ReadonlyMap<
string,
NonNullable<AppState['browserCertificateFailuresByPageId']>[string]
>
activeEditorFileId: string | null
activeEditorTabType: AppState['activeTabType'] | null
activeTerminalTabId: string | null
activeBrowserWorkspaceId: string | null
generatedTitlesEnabled: boolean
terminalTheme: RuntimeMobileTerminalTheme | undefined
mountedSurfaceCaptureByTabId: ReadonlyMap<string, MountedTerminalSurfaceCapture>
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,250 @@
import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope'
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import { isRuntimeSubscriptionReplayResponse } from '../../../../shared/runtime-subscription-replay'
import { useAppStore } from '../../store'
import { getRuntimeEnvironmentRevision } from '../runtime-environment-revision'
import { recoverWebSessionTerminalOrphansBeforeApply } from '../web-session-terminal-orphan-recovery'
import { installWindowVisibilitySubscriptionParking } from '../window-visibility-subscription-parking'
import {
beginWebSessionTabsSnapshotRecovery,
recordReceivedWebSessionTabsSnapshot,
shouldApplyRecoveredWebSessionTabsSnapshot
} from './tracking'
import {
decideWebSessionTabsSnapshot,
shouldBootstrapInitialWebRuntimeTerminal,
shouldRespawnWebRuntimeTerminalAfterWake,
shouldSyncRuntimeSessionTabs
} from './tracking-decisions'
import {
acceptReplayedWebSessionTabsSnapshot,
getWebSessionTabsTrackingGeneration
} from './tracking-lifecycle'
import { applyWebSessionTabsSnapshot } from './snapshot-api'
import { applyWebSessionTabsStorePatch } from './store-patch'
import {
hostSessionMirrorSettleForPatchlessFrame,
type HostSessionMirrorSettle
} from './mirror-settle'
import {
beginWebRuntimeWakeTerminalRespawn,
endWebRuntimeWakeTerminalRespawn,
shouldSkipWebRuntimeWakeTerminalRespawn
} from '../web-runtime-wake-terminal-respawn'
import { createWebRuntimeSessionTerminal } from '../web-runtime-session'
import { toRuntimeWorktreeSelector } from '../runtime-worktree-selector'
import type { SessionTabsStreamEvent } from './state'
type Ref<T> = { current: T }
export type ActiveSubscriptionArgs = {
activeWorktreeId: string | null
activeWorktreeRuntimeEnvironmentId: string | null | undefined
activeWorktreeRuntimeConnectionGeneration: number
activeWorktreeRuntimePairingRevision: number | undefined
workspaceSessionReady: boolean
visibilitySnapshotReceipt: Ref<
(environmentId: string, snapshot: RuntimeMobileSessionTabsResult, receivedFrame: number) => void
>
visibilitySnapshotApply: Ref<
(
environmentId: string,
snapshot: RuntimeMobileSessionTabsResult,
receivedFrame: number
) => boolean
>
}
/** Install the selected-worktree stream, which resumes immediately on visibility changes. */
export function installActiveSessionTabsSubscription({
activeWorktreeId,
activeWorktreeRuntimeEnvironmentId,
activeWorktreeRuntimeConnectionGeneration,
activeWorktreeRuntimePairingRevision,
workspaceSessionReady,
visibilitySnapshotReceipt,
visibilitySnapshotApply
}: ActiveSubscriptionArgs): (() => void) | undefined {
const environmentId = activeWorktreeRuntimeEnvironmentId?.trim()
if (
!shouldSyncRuntimeSessionTabs({
activeWorktreeId,
activeWorktreeRuntimeEnvironmentId,
workspaceSessionReady
}) ||
!environmentId ||
!activeWorktreeId
) {
return undefined
}
const expectedTrackingGeneration = getWebSessionTabsTrackingGeneration(environmentId)
let requestedInitialTerminal = false
let requestedRespawnAfterWake = false
const applyActiveSnapshot = async (
event: RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' },
response: RuntimeRpcResponse<unknown>,
isCurrent: () => boolean,
receivedFrame: number
): Promise<HostSessionMirrorSettle | null> => {
const recovered = await recoverWebSessionTerminalOrphansBeforeApply(
useAppStore.getState(),
event,
environmentId
)
if (
!isCurrent() ||
!recovered ||
!shouldApplyRecoveredWebSessionTabsSnapshot(environmentId, recovered, receivedFrame) ||
!visibilitySnapshotApply.current(environmentId, recovered, receivedFrame)
) {
return null
}
if (event.type === 'snapshot' || isRuntimeSubscriptionReplayResponse(response)) {
acceptReplayedWebSessionTabsSnapshot(environmentId, recovered.worktree)
}
const recoveredEvent: SessionTabsStreamEvent = { ...recovered, type: event.type }
const decision = decideWebSessionTabsSnapshot(recovered, environmentId)
const syncState = useAppStore.getState()
const localTabs = syncState.tabsByWorktree[activeWorktreeId] ?? []
const localTerminalCount = localTabs.length
const hasLiveLocalPty = localTabs.some(
(tab) => (syncState.ptyIdsByTabId[tab.id] ?? []).length > 0
)
const bootstrap = shouldBootstrapInitialWebRuntimeTerminal({
event: recoveredEvent,
activeWorktreeId,
requestedInitialTerminal,
snapshotIsFresh: decision.apply,
localTerminalCount
})
const respawn = shouldRespawnWebRuntimeTerminalAfterWake({
event: recoveredEvent,
activeWorktreeId,
requestedRespawnAfterWake,
snapshotIsFresh: decision.apply,
localTerminalCount,
hasLiveLocalPty,
skipWakeRespawn: shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId)
})
let settle: HostSessionMirrorSettle | null = decision.apply
? null
: hostSessionMirrorSettleForPatchlessFrame(decision, environmentId, recovered.worktree, {
connectionGeneration: activeWorktreeRuntimeConnectionGeneration,
pairingRevision: activeWorktreeRuntimePairingRevision,
trackingGeneration: expectedTrackingGeneration
})
if (decision.apply) {
const replayed = isRuntimeSubscriptionReplayResponse(response)
settle = applyWebSessionTabsStorePatch(
(state) => applyWebSessionTabsSnapshot(state, recovered, environmentId),
{
frames: [
{
environmentId,
worktreeId: recovered.worktree,
decision,
expectedEnvironmentConnectionGeneration: activeWorktreeRuntimeConnectionGeneration,
expectedEnvironmentPairingRevision: activeWorktreeRuntimePairingRevision,
expectedTrackingGeneration
}
]
},
recovered,
event.type === 'updated' && !replayed
)
visibilitySnapshotReceipt.current(environmentId, recovered, receivedFrame)
}
try {
if (isCurrent() && bootstrap) {
requestedInitialTerminal = true
await createWebRuntimeSessionTerminal({
worktreeId: activeWorktreeId,
environmentId,
activate: true
})
} else if (isCurrent() && respawn && beginWebRuntimeWakeTerminalRespawn(activeWorktreeId)) {
requestedRespawnAfterWake = true
await createWebRuntimeSessionTerminal({
worktreeId: activeWorktreeId,
environmentId,
activate: true,
selectWorktree: false
}).finally(() => endWebRuntimeWakeTerminalRespawn(activeWorktreeId))
}
} catch (error) {
if (isCurrent()) {
console.warn('[web-session-tabs-sync] snapshot follow-up failed:', error)
}
}
return settle
}
return installWindowVisibilitySubscriptionParking([
{
subscribe: (isCurrent) =>
window.api.runtimeEnvironments.subscribe(
{
selector: environmentId,
method: 'session.tabs.subscribe',
params: { worktree: toRuntimeWorktreeSelector(activeWorktreeId) },
timeoutMs: 15_000,
expectedEnvironmentPairingRevision: activeWorktreeRuntimePairingRevision
},
{
onResponse: (response) => {
if (
!isCurrent() ||
getRuntimeEnvironmentRevision(environmentId) !==
activeWorktreeRuntimePairingRevision
) {
return
}
if (response.ok === false) {
console.warn('[web-session-tabs-sync] subscription failed:', response.error.message)
return
}
const event = response.result as SessionTabsStreamEvent
if (event.type !== 'snapshot' && event.type !== 'updated') {
return
}
const frame = recordReceivedWebSessionTabsSnapshot(environmentId, event)
visibilitySnapshotReceipt.current(environmentId, event, frame)
const finish = beginWebSessionTabsSnapshotRecovery(
environmentId,
event.worktree,
frame
)
void applyActiveSnapshot(event, response, isCurrent, frame)
.catch((error) => {
if (isCurrent()) {
console.warn('[web-session-tabs-sync] active snapshot recovery failed:', error)
}
return null
})
.then((settle) => {
finish()
if (isCurrent()) {
settle?.()
}
})
},
onError: (error) => {
if (isCurrent()) {
console.warn('[web-session-tabs-sync] subscription error:', error.message)
}
}
}
),
onSubscribeError: (error) => {
console.warn(
'[web-session-tabs-sync] failed to subscribe:',
error instanceof Error ? error.message : String(error)
)
},
onUnsubscribeError: (error) => {
console.warn('[web-session-tabs-sync] failed to unsubscribe:', error)
}
}
])
}
@@ -0,0 +1,191 @@
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time'
import { normalizeCompatibleAgentStatusEntryForOwner } from '../../../../shared/agent-title-owner'
import { isWebTerminalSurfaceTabId, toWebTerminalSurfaceTabId } from '../web-runtime-session'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import type {
MirroredTerminalTab,
TerminalSurface,
WebSessionTabsBatchContext,
WebSessionTabsSyncState
} from './state'
import {
isClientOwnedAgentStatus,
isFencedClientAgentStatus,
hostAgentStatusPiercesClientAuthority,
isMirroredAgentPaneKeyForTabs,
batchAgentPaneKeysForTabs,
remapHostAgentStatus,
updateBatchAgentPaneKey
} from './agent-status-primitives'
import {
agentStatusEntryEqual,
isAgentStatusFresh,
isMirroredCommandCodeTurnBump,
writableWebSessionTabsRecord
} from './state-equality-core'
export function buildMirroredAgentStatusPatch(
state: WebSessionTabsSyncState,
currentTerminalTabs: readonly TerminalTab[],
terminalSurfaceTabs: readonly TerminalSurface[],
mirroredTerminalTabs: readonly MirroredTerminalTab[],
now: number,
batchContext?: WebSessionTabsBatchContext
): Pick<WebSessionTabsSyncState, 'agentStatusByPaneKey' | 'agentStatusEpoch' | 'sortEpoch'> | null {
const mirroredTabIds = new Set<string>()
for (const tab of currentTerminalTabs) {
if (isWebTerminalSurfaceTabId(tab.id)) {
mirroredTabIds.add(tab.id)
}
}
for (const surface of terminalSurfaceTabs) {
mirroredTabIds.add(toWebTerminalSurfaceTabId(surface.parentTabId))
}
if (mirroredTabIds.size === 0) {
return null
}
let retainedSurfaceByHostTabAndPrunedLeafId:
| Map<string, ReadonlyMap<string, TerminalSurface>>
| undefined
for (const entry of mirroredTerminalTabs) {
if (entry.retainedSurfaceByPrunedLeafId) {
retainedSurfaceByHostTabAndPrunedLeafId ??= new Map()
retainedSurfaceByHostTabAndPrunedLeafId.set(
entry.hostTabId,
entry.retainedSurfaceByPrunedLeafId
)
}
}
const nextByPaneKey = new Map<string, AgentStatusEntry>()
for (const surface of terminalSurfaceTabs) {
const retainedSurface = retainedSurfaceByHostTabAndPrunedLeafId
?.get(surface.parentTabId)
?.get(surface.leafId)
const entry = remapHostAgentStatus(surface, retainedSurface)
if (!entry) {
continue
}
const existing = nextByPaneKey.get(entry.paneKey) ?? state.agentStatusByPaneKey[entry.paneKey]
// Why: keep fresher OSC state while taking remapped ownership metadata from the authoritative host snapshot.
const hostIdentityPredatesCurrentTurn =
existing !== undefined &&
entry.state === 'done' &&
existing.state !== 'done' &&
existing.stateStartedAt > entry.stateStartedAt
// Why: cross-machine wall clocks are not comparable, so the host frame could
// outrank live client status forever; a proven client writer keeps its own
// state (still adopting the host's identity fields below) unless the host
// carries a state class the client's bytes can never see.
const clientOwnsEntry =
isFencedClientAgentStatus(entry.paneKey, existing, now) &&
!hostAgentStatusPiercesClientAuthority(entry)
const nextEntry =
existing && (clientOwnsEntry || existing.updatedAt > entry.updatedAt)
? {
...normalizeCompatibleAgentStatusEntryForOwner(existing, entry.agentType),
...(clientOwnsEntry && existing.state === 'working' && entry.state === 'working'
? { workingMode: entry.workingMode }
: {}),
paneKey: entry.paneKey,
worktreeId: entry.worktreeId ?? existing.worktreeId,
tabId: entry.tabId,
providerSession:
existing.providerSession ??
(hostIdentityPredatesCurrentTurn ? undefined : entry.providerSession),
// Why: hook-only content the byte pipeline can never see, and every OSC
// write blanks it, so a fenced pane's message line stayed empty forever
// (#12906). Host-first unlike providerSession: only the host can mint one.
lastAssistantMessage:
(hostIdentityPredatesCurrentTurn ? undefined : entry.lastAssistantMessage) ??
existing.lastAssistantMessage
}
: entry
nextByPaneKey.set(entry.paneKey, nextEntry)
}
let nextAgentStatusByPaneKey = state.agentStatusByPaneKey
let changed = false
let aggregateRelevantChange = false
let sortRelevantChange = false
for (const paneKey of batchAgentPaneKeysForTabs(state, mirroredTabIds, batchContext)) {
if (!isMirroredAgentPaneKeyForTabs(paneKey, mirroredTabIds)) {
continue
}
if (nextByPaneKey.has(paneKey)) {
continue
}
// Why: the host surface carrying no status is not proof the agent stopped —
// hook-only hosts publish nothing for OSC-driven panes. Keep a live entry
// this renderer owns; it decays through the normal freshness boundary.
// Ownership, not freshness, is the gate here: with no competing host value
// there is nothing to arbitrate, and a client asleep past the stale
// boundary would otherwise erase every pane it owns on the first snapshot
// after wake (STA-3107) instead of decaying it like a local pane.
if (isClientOwnedAgentStatus(paneKey, state.agentStatusByPaneKey[paneKey])) {
continue
}
if (nextAgentStatusByPaneKey === state.agentStatusByPaneKey) {
nextAgentStatusByPaneKey = writableWebSessionTabsRecord(
state,
'agentStatusByPaneKey',
batchContext
)
}
delete nextAgentStatusByPaneKey[paneKey]
updateBatchAgentPaneKey(paneKey, false, batchContext)
changed = true
aggregateRelevantChange = true
sortRelevantChange = true
}
for (const [paneKey, entry] of nextByPaneKey) {
const existing = nextAgentStatusByPaneKey[paneKey]
if (agentStatusEntryEqual(existing, entry)) {
continue
}
if (nextAgentStatusByPaneKey === state.agentStatusByPaneKey) {
nextAgentStatusByPaneKey = writableWebSessionTabsRecord(
state,
'agentStatusByPaneKey',
batchContext
)
}
nextAgentStatusByPaneKey[paneKey] = entry
updateBatchAgentPaneKey(paneKey, true, batchContext)
changed = true
const entryAttributionChanged =
existing?.worktreeId !== entry.worktreeId || existing?.tabId !== entry.tabId
const entryFreshnessChanged =
!!existing && isAgentStatusFresh(existing, now) !== isAgentStatusFresh(entry, now)
const doneAttentionChanged =
existing?.state === 'done' &&
entry.state === 'done' &&
agentEntryCompletionAt(existing) !== agentEntryCompletionAt(entry)
const workingModeChanged = existing?.workingMode !== entry.workingMode
const entrySortRelevantChange =
!existing ||
existing.state !== entry.state ||
!isAgentStatusFresh(existing, now) ||
entryFreshnessChanged ||
entryAttributionChanged ||
doneAttentionChanged ||
isMirroredCommandCodeTurnBump(existing, entry)
aggregateRelevantChange =
aggregateRelevantChange || entrySortRelevantChange || workingModeChanged
sortRelevantChange = sortRelevantChange || entrySortRelevantChange
}
if (!changed) {
return null
}
return {
agentStatusByPaneKey: nextAgentStatusByPaneKey,
agentStatusEpoch: aggregateRelevantChange ? state.agentStatusEpoch + 1 : state.agentStatusEpoch,
sortEpoch: sortRelevantChange ? state.sortEpoch + 1 : state.sortEpoch
}
}
@@ -0,0 +1,198 @@
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { parsePaneKey, makePaneKey, isTerminalLeafId } from '../../../../shared/stable-pane-id'
import { isClientAuthoritativeAgentStatusPane } from '@/components/terminal-pane/renderer-owned-agent-status-registry'
import { normalizeCompatibleAgentStatusEntryForOwner } from '../../../../shared/agent-title-owner'
import { resolvePaneAgentOwnerRecord } from '../../../../shared/pane-agent-owner'
import { toWebTerminalSurfaceTabId } from '../web-runtime-session'
import type { TerminalSurface, WebSessionTabsBatchContext, WebSessionTabsSyncState } from './state'
import type { RetiredTerminalTabSweepState } from '../../store/slices/retired-terminal-tab-state-sweep'
import { buildRetiredTerminalTabStateSweepPatch } from '../../store/slices/retired-terminal-tab-state-sweep'
import { isMirroredTerminalSurfaceId } from './terminal-surfaces'
import { isAgentStatusFresh } from './state-equality-core'
export function toMirroredPaneKey(
surface: TerminalSurface,
leafId = surface.leafId
): string | null {
if (!isTerminalLeafId(leafId)) {
return null
}
return makePaneKey(toWebTerminalSurfaceTabId(surface.parentTabId), leafId)
}
/** Normalises and mirrors agent status updates from the host payload, preserving ownership metadata. */
export function remapHostAgentStatus(
surface: TerminalSurface,
retainedSurface?: TerminalSurface
): AgentStatusEntry | null {
if (!surface.agentStatus) {
return null
}
const paneKey = toMirroredPaneKey(surface, retainedSurface?.leafId)
if (!paneKey) {
return null
}
const ownerRecord = resolvePaneAgentOwnerRecord({
launchAgent: retainedSurface?.launchAgent ?? surface.launchAgent,
hookAgent: surface.agentStatus.agentType
})
return {
...normalizeCompatibleAgentStatusEntryForOwner(surface.agentStatus, ownerRecord?.agent, {
ownerIsLaunch: ownerRecord?.ownerIsLaunch === true
}),
paneKey,
tabId: toWebTerminalSurfaceTabId(surface.parentTabId)
}
}
export function isMirroredAgentPaneKeyForTabs(
paneKey: string,
tabIds: ReadonlySet<string>
): boolean {
const parsed = parsePaneKey(paneKey)
return parsed !== null && tabIds.has(parsed.tabId)
}
/** Host states the client's byte pipeline cannot observe: permission blocks and
* interactive question cards reach the host over its HTTP agent hook, never
* through PTY bytes, so they must pierce the client-authority fence. */
export function hostAgentStatusPiercesClientAuthority(entry: AgentStatusEntry): boolean {
return entry.state === 'blocked' || entry.interactivePrompt != null
}
/** True while this renderer's own byte-derived status owns the pane: it claimed
* the pane at transport creation and wrote status from bytes. The claim is
* released on pane teardown, which is how the host takes the pane back. */
export function isClientOwnedAgentStatus(
paneKey: string,
existing: AgentStatusEntry | undefined
): existing is AgentStatusEntry {
return existing !== undefined && isClientAuthoritativeAgentStatusPane(paneKey)
}
/** Owned AND still fresh — the arbitration rule for a pane the host also has an
* opinion about: an OSC-silent dead agent hands that contest back to the host. */
export function isFencedClientAgentStatus(
paneKey: string,
existing: AgentStatusEntry | undefined,
now: number
): existing is AgentStatusEntry {
return isClientOwnedAgentStatus(paneKey, existing) && isAgentStatusFresh(existing, now)
}
export function batchAgentPaneKeysForTabs(
state: WebSessionTabsSyncState,
tabIds: ReadonlySet<string>,
batchContext?: WebSessionTabsBatchContext
): string[] {
if (!batchContext) {
return Object.keys(state.agentStatusByPaneKey)
}
if (!batchContext.agentPaneKeysByTabId) {
batchContext.agentPaneKeysByTabId = new Map()
for (const paneKey of Object.keys(state.agentStatusByPaneKey)) {
const tabId = parsePaneKey(paneKey)?.tabId
if (!tabId) {
continue
}
const paneKeys = batchContext.agentPaneKeysByTabId.get(tabId) ?? new Set<string>()
paneKeys.add(paneKey)
batchContext.agentPaneKeysByTabId.set(tabId, paneKeys)
}
}
return [...tabIds].flatMap((tabId) => [...(batchContext.agentPaneKeysByTabId?.get(tabId) ?? [])])
}
export function updateBatchAgentPaneKey(
paneKey: string,
present: boolean,
batchContext?: WebSessionTabsBatchContext
): void {
const tabId = parsePaneKey(paneKey)?.tabId
const index = batchContext?.agentPaneKeysByTabId
if (!tabId || !index) {
return
}
if (present) {
const paneKeys = index.get(tabId) ?? new Set<string>()
paneKeys.add(paneKey)
index.set(tabId, paneKeys)
return
}
const paneKeys = index.get(tabId)
paneKeys?.delete(paneKey)
if (paneKeys?.size === 0) {
index.delete(tabId)
}
}
// Why: the closed-tab marker has no TTL, and setAgentStatus hard-drops writes for a
// marked tab id — for a stable mirrored id that returns, that is a permanent silent
// blackhole unless presence in a snapshot lifts it.
export function buildRemirroredClosedTabMarkerLiftPatch(
recentlyClosedAgentStatusTabIds: WebSessionTabsSyncState['recentlyClosedAgentStatusTabIds'],
mirroredTerminalIds: ReadonlySet<string>
): Partial<WebSessionTabsSyncState> | null {
let next: WebSessionTabsSyncState['recentlyClosedAgentStatusTabIds'] | null = null
for (const tabId of mirroredTerminalIds) {
if (tabId in (recentlyClosedAgentStatusTabIds ?? {})) {
next ??= { ...recentlyClosedAgentStatusTabIds }
delete next[tabId]
}
}
return next ? { recentlyClosedAgentStatusTabIds: next } : null
}
/**
* A host retraction owes the retracted tab closeTab's renderer-state sweep: without it,
* client-owned rows (STA-3107-exempt in the mirror's delete loop) and retention promotions
* outlive the tab forever (STA-4593).
*/
export function buildRetractedMirroredTabSweepPatch(
state: WebSessionTabsSyncState,
worktreeId: string,
nextTabsByWorktree: WebSessionTabsSyncState['tabsByWorktree'],
agentStatusPatch: Pick<
WebSessionTabsSyncState,
'agentStatusByPaneKey' | 'agentStatusEpoch' | 'sortEpoch'
> | null,
removedTerminalResourceIds: readonly string[],
batchContext?: WebSessionTabsBatchContext
): Partial<WebSessionTabsSyncState> | null {
// Why: only a mirrored id the host stopped publishing is a retraction — a local or provisional
// tab in this list is being renamed into its mirror, and a rename must keep its rows.
const retractedTabIds = removedTerminalResourceIds.filter(isMirroredTerminalSurfaceId)
if (retractedTabIds.length === 0) {
return null
}
const sweepState: RetiredTerminalTabSweepState = {
acknowledgedAgentsByPaneKey: state.acknowledgedAgentsByPaneKey ?? {},
agentLaunchConfigByPaneKey: state.agentLaunchConfigByPaneKey ?? {},
agentStatusByPaneKey: agentStatusPatch?.agentStatusByPaneKey ?? state.agentStatusByPaneKey,
agentStatusEpoch: agentStatusPatch?.agentStatusEpoch ?? state.agentStatusEpoch,
migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId ?? {},
paneForegroundAgentByPaneKey: state.paneForegroundAgentByPaneKey ?? {},
recentlyClosedAgentStatusTabIds: state.recentlyClosedAgentStatusTabIds ?? {},
recentlyRetiredAgentStatusPaneKeys: state.recentlyRetiredAgentStatusPaneKeys ?? {},
retainedAgentsByPaneKey: state.retainedAgentsByPaneKey ?? {},
retentionSuppressedPaneKeys: state.retentionSuppressedPaneKeys ?? {},
sortEpoch: agentStatusPatch?.sortEpoch ?? state.sortEpoch,
// Why: the drop's completed-orphan rule reads "keyed under a tab this worktree no longer has",
// so it must see the post-removal tab list, not the one the snapshot replaced.
tabsByWorktree: nextTabsByWorktree
}
const sweep = buildRetiredTerminalTabStateSweepPatch(sweepState, retractedTabIds, worktreeId)
if (!sweep?.agentStatusByPaneKey || !batchContext) {
return sweep ?? null
}
// Why: the batch republishes its own record copy at the end, which would undo the sweep.
const mutableState = state as unknown as Record<string, unknown>
mutableState.agentStatusByPaneKey = sweep.agentStatusByPaneKey
batchContext.changedRecords.add('agentStatusByPaneKey')
for (const paneKey of Object.keys(sweepState.agentStatusByPaneKey)) {
if (!(paneKey in sweep.agentStatusByPaneKey)) {
updateBatchAgentPaneKey(paneKey, false, batchContext)
}
}
return sweep
}
@@ -0,0 +1,288 @@
import type { applyWorktreeRecordUpdates } from './apply-worktree-records'
import { withWorktreeEntry, sameStringArray } from './state-equality-core'
import { toVisibleTabType } from './state-equality-files'
import {
collectLayoutGroupIds,
appendTabGroupLayout,
pruneTabGroupLayout,
tabGroupLayoutEqual
} from './layout-helpers'
type ActiveStateContext = ReturnType<typeof applyWorktreeRecordUpdates>
/** Resolve active ids, visible type, layout, and tab-bar order after reconciliation. */
export function applyActiveStateUpdates(context: ActiveStateContext) {
const {
state,
snapshot,
options,
worktreeId,
targetGroupId,
nextGroups,
clientOwnedPlacement,
nextUnifiedTabs,
nextTerminalTabs,
nextWorktreeOpenFileIds,
nextActiveUnifiedTabId,
nextTabBarOrder,
navigationIntentTab,
honorSnapshotActiveFocus,
intentMirroredAgent,
activeMirroredAgentTabId,
currentVisibleUnifiedTabId,
currentVisibleStructuredTabId,
currentActiveTerminalStillExists,
currentActiveBrowserStillExists,
currentActiveEditorStillExists,
intentEditorFileId,
nextActiveTerminalId,
nextActiveBrowserWorkspaceId,
nextActiveEditorFileId,
intentTerminalId,
intentBrowserWorkspaceId
} = context
const nextActiveGroupId = clientOwnedPlacement
? clientOwnedPlacement.activeGroupId
: (nextGroups?.find((group) => group.activeTabId === nextActiveUnifiedTabId)?.id ??
nextGroups?.find((group) => group.id === snapshot.activeGroupId)?.id ??
nextGroups?.[0]?.id ??
null)
const nextActiveGroupIdByWorktree =
nextGroups && state.activeGroupIdByWorktree[worktreeId] !== nextActiveGroupId
? withWorktreeEntry(
state,
'activeGroupIdByWorktree',
worktreeId,
nextActiveGroupId ?? targetGroupId,
(current, next) => current === next,
context.batchContext
)
: state.activeGroupIdByWorktree
const nextLayoutByWorktree = (() => {
if (!nextGroups) {
return state.layoutByWorktree
}
if (clientOwnedPlacement) {
const clientLayout =
clientOwnedPlacement.layout ??
(nextActiveGroupId ? { type: 'leaf' as const, groupId: nextActiveGroupId } : null)
if (!clientLayout || tabGroupLayoutEqual(state.layoutByWorktree[worktreeId], clientLayout)) {
return state.layoutByWorktree
}
return withWorktreeEntry(
state,
'layoutByWorktree',
worktreeId,
clientLayout,
(current, next) => current === next,
context.batchContext
)
}
if (options?.preserveLocalLayout) {
return state.layoutByWorktree
}
const validGroupIds = new Set(nextGroups.map((group) => group.id))
const hostLayout = pruneTabGroupLayout(snapshot.tabGroupLayout, validGroupIds)
const defaultLeafLayout = { type: 'leaf' as const, groupId: nextActiveGroupId ?? targetGroupId }
const hostLayoutGroupIds = collectLayoutGroupIds(hostLayout ?? undefined)
const hostGroupIds = new Set(snapshot.tabGroups?.map((group) => group.id) ?? [])
const extraGroupIds = new Set(
nextGroups
.map((group) => group.id)
.filter((groupId) =>
hostLayout
? !hostLayoutGroupIds.has(groupId)
: snapshot.tabGroups && snapshot.tabGroups.length > 0
? !hostGroupIds.has(groupId)
: false
)
)
const localExtraLayout = pruneTabGroupLayout(state.layoutByWorktree[worktreeId], extraGroupIds)
const hostBaseLayout =
hostLayout ?? (snapshot.tabGroups && snapshot.tabGroups.length > 0 ? defaultLeafLayout : null)
const fallbackLayout =
appendTabGroupLayout(hostBaseLayout, localExtraLayout) ??
(snapshot.tabGroups && snapshot.tabGroups.length > 0
? defaultLeafLayout
: state.layoutByWorktree[worktreeId]
? null
: defaultLeafLayout)
if (
!fallbackLayout ||
tabGroupLayoutEqual(state.layoutByWorktree[worktreeId], fallbackLayout)
) {
return state.layoutByWorktree
}
return withWorktreeEntry(
state,
'layoutByWorktree',
worktreeId,
fallbackLayout,
(current, next) => current === next,
context.batchContext
)
})()
const nextTabBarOrderByWorktree = withWorktreeEntry(
state,
'tabBarOrderByWorktree',
worktreeId,
nextTabBarOrder.length > 0 ? nextTabBarOrder : null,
(a, b) => sameStringArray(a ?? [], b ?? []),
context.batchContext
)
const nextActiveTabIdByWorktree =
(state.activeTabIdByWorktree[worktreeId] ?? null) !==
(intentMirroredAgent?.unifiedTab.id ?? currentVisibleStructuredTabId ?? nextActiveTerminalId)
? withWorktreeEntry(
state,
'activeTabIdByWorktree',
worktreeId,
intentMirroredAgent?.unifiedTab.id ??
currentVisibleStructuredTabId ??
nextActiveTerminalId,
(current, next) => (current ?? null) === next,
context.batchContext,
false
)
: state.activeTabIdByWorktree
const nextActiveBrowserTabIdByWorktree =
(state.activeBrowserTabIdByWorktree[worktreeId] ?? null) !== nextActiveBrowserWorkspaceId
? withWorktreeEntry(
state,
'activeBrowserTabIdByWorktree',
worktreeId,
nextActiveBrowserWorkspaceId,
(current, next) => (current ?? null) === next,
context.batchContext,
false
)
: state.activeBrowserTabIdByWorktree
const nextActiveFileIdByWorktree =
(state.activeFileIdByWorktree[worktreeId] ?? null) !== nextActiveEditorFileId
? withWorktreeEntry(
state,
'activeFileIdByWorktree',
worktreeId,
nextActiveEditorFileId,
(current, next) => (current ?? null) === next,
context.batchContext,
false
)
: state.activeFileIdByWorktree
const isActiveWorktree = state.activeWorktreeId === worktreeId
const focusIntentVisibleTabType =
navigationIntentTab?.type === 'agent-session' && intentMirroredAgent
? ('agent-session' as const)
: navigationIntentTab?.type === 'browser' && intentBrowserWorkspaceId
? ('browser' as const)
: navigationIntentTab?.type === 'terminal' && intentTerminalId
? ('terminal' as const)
: intentEditorFileId
? ('editor' as const)
: null
const snapshotVisibleTabType =
snapshot.activeTabType === 'agent-session' && activeMirroredAgentTabId
? ('agent-session' as const)
: snapshot.activeTabType === 'browser' && nextActiveBrowserWorkspaceId
? ('browser' as const)
: snapshot.activeTabType === 'terminal' && nextActiveTerminalId
? ('terminal' as const)
: (snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file') &&
nextActiveEditorFileId
? ('editor' as const)
: null
const currentVisibleTabType =
state.activeTabTypeByWorktree[worktreeId] ?? (isActiveWorktree ? state.activeTabType : null)
const currentVisibleTabTypeStillValid =
currentVisibleStructuredTabId !== null
? ('agent-session' as const)
: currentVisibleTabType === 'agent-session' &&
currentVisibleUnifiedTabId &&
nextUnifiedTabs?.some(
(tab) => tab.id === currentVisibleUnifiedTabId && tab.contentType === 'agent-session'
)
? ('agent-session' as const)
: currentVisibleTabType === 'browser' && currentActiveBrowserStillExists
? ('browser' as const)
: currentVisibleTabType === 'editor' && currentActiveEditorStillExists
? ('editor' as const)
: currentVisibleTabType === 'terminal' && currentActiveTerminalStillExists
? ('terminal' as const)
: null
const activeUnifiedTab =
nextActiveUnifiedTabId && nextUnifiedTabs
? (nextUnifiedTabs.find((tab) => tab.id === nextActiveUnifiedTabId) ?? null)
: null
const fallbackVisibleTabType =
activeUnifiedTab !== null
? toVisibleTabType(activeUnifiedTab)
: nextActiveTerminalId
? ('terminal' as const)
: nextActiveBrowserWorkspaceId
? ('browser' as const)
: nextActiveEditorFileId
? ('editor' as const)
: ('terminal' as const)
const nextVisibleTabType = honorSnapshotActiveFocus
? (focusIntentVisibleTabType ??
currentVisibleTabTypeStillValid ??
snapshotVisibleTabType ??
fallbackVisibleTabType)
: (currentVisibleTabTypeStillValid ?? snapshotVisibleTabType ?? fallbackVisibleTabType)
const currentActiveTerminalStillValid =
state.activeTabId && (nextTerminalTabs ?? []).some((tab) => tab.id === state.activeTabId)
? state.activeTabId
: null
const currentActiveEditorStillValid =
state.activeFileId && nextWorktreeOpenFileIds.has(state.activeFileId)
? state.activeFileId
: null
const nextActiveTabId = isActiveWorktree
? (intentMirroredAgent?.unifiedTab.id ??
(snapshot.activeTabType === 'terminal'
? nextActiveTerminalId
: (currentActiveTerminalStillValid ?? nextActiveTerminalId)))
: state.activeTabId
const nextActiveBrowserTabId = isActiveWorktree
? nextActiveBrowserWorkspaceId
: state.activeBrowserTabId
const nextActiveFileId = isActiveWorktree
? snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file'
? nextActiveEditorFileId
: (currentActiveEditorStillValid ?? nextActiveEditorFileId)
: state.activeFileId
const nextActiveTabType = isActiveWorktree ? nextVisibleTabType : state.activeTabType
const nextActiveTabTypeByWorktree =
state.activeTabTypeByWorktree[worktreeId] !== nextVisibleTabType
? withWorktreeEntry(
state,
'activeTabTypeByWorktree',
worktreeId,
nextVisibleTabType,
(current, next) => current === next,
context.batchContext
)
: state.activeTabTypeByWorktree
return {
...context,
nextActiveGroupId,
nextActiveGroupIdByWorktree,
nextLayoutByWorktree,
nextTabBarOrderByWorktree,
nextActiveTabIdByWorktree,
nextActiveBrowserTabIdByWorktree,
nextActiveFileIdByWorktree,
nextVisibleTabType,
nextActiveTabId,
nextActiveBrowserTabId,
nextActiveFileId,
nextActiveTabType,
nextActiveTabTypeByWorktree
}
}
@@ -0,0 +1,134 @@
import type { applyTerminalRecordUpdates } from './apply-terminal-records'
import {
browserCertificateFailureEqual,
optionalRuntimeBrowserPlacementsEqual,
sameBrowserPages
} from './state-equality-tabs'
import { writableWebSessionTabsRecord } from './state-equality-core'
import { markWebSessionBrowserPlacementAdopted } from '../web-session-browser-placement'
type BrowserRecordContext = ReturnType<typeof applyTerminalRecordUpdates>
/** Reconcile browser page records, remote handles, and certificate failures. */
export function applyBrowserRecordUpdates(context: BrowserRecordContext) {
const {
state,
batchContext,
environmentId,
worktreeId,
mirroredBrowserTabs,
removedBrowserWorkspaceIds,
retainedBrowserTabs,
nextBrowserTabs
} = context
let nextBrowserPagesByWorkspace = state.browserPagesByWorkspace
let nextRemoteBrowserPageHandlesByPageId = state.remoteBrowserPageHandlesByPageId
let nextBrowserCertificateFailuresByPageId = state.browserCertificateFailuresByPageId
if (removedBrowserWorkspaceIds.size > 0) {
const nextBrowserWorkspaceIds = new Set(nextBrowserTabs?.map((tab) => tab.id) ?? [])
const nextBrowserPageIds = new Set(mirroredBrowserTabs.map((entry) => entry.page.id))
for (const workspace of retainedBrowserTabs) {
for (const page of state.browserPagesByWorkspace[workspace.id] ?? []) {
nextBrowserPageIds.add(page.id)
}
}
for (const removedWorkspaceId of removedBrowserWorkspaceIds) {
const pages = nextBrowserPagesByWorkspace[removedWorkspaceId] ?? []
if (
!nextBrowserWorkspaceIds.has(removedWorkspaceId) &&
nextBrowserPagesByWorkspace[removedWorkspaceId]
) {
nextBrowserPagesByWorkspace =
nextBrowserPagesByWorkspace === state.browserPagesByWorkspace
? writableWebSessionTabsRecord(state, 'browserPagesByWorkspace', batchContext)
: nextBrowserPagesByWorkspace
delete nextBrowserPagesByWorkspace[removedWorkspaceId]
}
for (const page of pages) {
if (nextBrowserPageIds.has(page.id)) {
continue
}
if (nextBrowserCertificateFailuresByPageId[page.id]) {
nextBrowserCertificateFailuresByPageId =
nextBrowserCertificateFailuresByPageId === state.browserCertificateFailuresByPageId
? writableWebSessionTabsRecord(
state,
'browserCertificateFailuresByPageId',
batchContext
)
: nextBrowserCertificateFailuresByPageId
delete nextBrowserCertificateFailuresByPageId[page.id]
}
if (nextRemoteBrowserPageHandlesByPageId[page.id]) {
nextRemoteBrowserPageHandlesByPageId =
nextRemoteBrowserPageHandlesByPageId === state.remoteBrowserPageHandlesByPageId
? writableWebSessionTabsRecord(
state,
'remoteBrowserPageHandlesByPageId',
batchContext
)
: nextRemoteBrowserPageHandlesByPageId
delete nextRemoteBrowserPageHandlesByPageId[page.id]
}
}
}
}
for (const { page, certificateFailure, remotePageId, placement } of mirroredBrowserTabs) {
const current = nextBrowserPagesByWorkspace[page.workspaceId] ?? []
if (!sameBrowserPages(current, [page])) {
nextBrowserPagesByWorkspace =
nextBrowserPagesByWorkspace === state.browserPagesByWorkspace
? writableWebSessionTabsRecord(state, 'browserPagesByWorkspace', batchContext)
: nextBrowserPagesByWorkspace
nextBrowserPagesByWorkspace[page.workspaceId] = [page]
}
const currentHandle = nextRemoteBrowserPageHandlesByPageId[page.id]
if (
currentHandle?.environmentId !== environmentId ||
currentHandle.remotePageId !== remotePageId ||
// A host snapshot is the adoption boundary for a staged handle.
currentHandle.staged === true ||
// Restored markers are spent when the host republishes the page.
currentHandle.restoredFromSession === true ||
!optionalRuntimeBrowserPlacementsEqual(currentHandle.placement, placement)
) {
nextRemoteBrowserPageHandlesByPageId =
nextRemoteBrowserPageHandlesByPageId === state.remoteBrowserPageHandlesByPageId
? writableWebSessionTabsRecord(state, 'remoteBrowserPageHandlesByPageId', batchContext)
: nextRemoteBrowserPageHandlesByPageId
nextRemoteBrowserPageHandlesByPageId[page.id] = {
environmentId,
remotePageId,
...(placement ? { placement } : {})
}
}
markWebSessionBrowserPlacementAdopted({ environmentId, worktreeId, remotePageId })
if (
placement?.kind !== 'client' &&
!browserCertificateFailureEqual(
nextBrowserCertificateFailuresByPageId[page.id],
certificateFailure
)
) {
nextBrowserCertificateFailuresByPageId =
nextBrowserCertificateFailuresByPageId === state.browserCertificateFailuresByPageId
? writableWebSessionTabsRecord(state, 'browserCertificateFailuresByPageId', batchContext)
: nextBrowserCertificateFailuresByPageId
if (certificateFailure) {
nextBrowserCertificateFailuresByPageId[page.id] = certificateFailure
} else {
delete nextBrowserCertificateFailuresByPageId[page.id]
}
}
}
return {
...context,
nextBrowserPagesByWorkspace,
nextRemoteBrowserPageHandlesByPageId,
nextBrowserCertificateFailuresByPageId
}
}
@@ -0,0 +1,146 @@
import type { WebSessionTabsSyncState } from './state'
import type { applyActiveStateUpdates } from './apply-active-state'
import { buildMirroredAgentStatusPatch } from './agent-status-patch'
import {
buildRemirroredClosedTabMarkerLiftPatch,
buildRetractedMirroredTabSweepPatch
} from './agent-status-primitives'
import { isWebSessionTabsWorktreeRemovalFrame } from './tracking'
type FinalPatchContext = ReturnType<typeof applyActiveStateUpdates>
/** Build the minimal store patch after all reconciliation stages have completed. */
export function buildWebSessionTabsFinalPatch(
context: FinalPatchContext
): WebSessionTabsSyncState | Partial<WebSessionTabsSyncState> {
const {
state,
snapshot,
worktreeId,
now,
batchContext,
currentTerminalTabs,
terminalSurfaceTabs,
mirroredTerminalTabs,
nextTabsByWorktree,
removedTerminalResourceIds,
mirroredTerminalIds,
nextOpenFiles,
nextBrowserTabsByWorktree,
nextUnifiedTabsByWorktree,
nextGroupsByWorktree,
nextActiveGroupIdByWorktree,
nextLayoutByWorktree,
nextTabBarOrderByWorktree,
nextPtyIdsByTabId,
nextTerminalLayoutsByTabId,
nextUnreadTerminalTabs,
pendingStartupByTabId,
nextPendingStartupByTabId,
automaticAgentResumeClaimsByTabId,
nextAutomaticAgentResumeClaimsByTabId,
nextBrowserPagesByWorkspace,
nextRemoteBrowserPageHandlesByPageId,
nextBrowserCertificateFailuresByPageId,
nextActiveTabIdByWorktree,
nextActiveBrowserTabIdByWorktree,
nextActiveFileIdByWorktree,
nextActiveTabId,
nextActiveBrowserTabId,
nextActiveFileId,
nextActiveTabType,
nextActiveTabTypeByWorktree
} = context
const agentStatusPatch = buildMirroredAgentStatusPatch(
state,
currentTerminalTabs,
terminalSurfaceTabs,
mirroredTerminalTabs,
now,
batchContext
)
// A tombstone clears all environments' view of a worktree; it is not a terminal retraction.
const retractedTabSweepPatch = isWebSessionTabsWorktreeRemovalFrame(snapshot)
? null
: buildRetractedMirroredTabSweepPatch(
state,
worktreeId,
nextTabsByWorktree,
agentStatusPatch,
removedTerminalResourceIds,
batchContext
)
const remirroredClosedTabLiftPatch = buildRemirroredClosedTabMarkerLiftPatch(
retractedTabSweepPatch?.recentlyClosedAgentStatusTabIds ??
state.recentlyClosedAgentStatusTabIds,
mirroredTerminalIds
)
const patch: Partial<WebSessionTabsSyncState> = {
...agentStatusPatch,
...retractedTabSweepPatch,
...remirroredClosedTabLiftPatch,
...(nextOpenFiles !== state.openFiles ? { openFiles: nextOpenFiles } : {}),
...(nextTabsByWorktree !== state.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}),
...(nextBrowserTabsByWorktree !== state.browserTabsByWorktree
? { browserTabsByWorktree: nextBrowserTabsByWorktree }
: {}),
...(nextUnifiedTabsByWorktree !== state.unifiedTabsByWorktree
? { unifiedTabsByWorktree: nextUnifiedTabsByWorktree }
: {}),
...(nextGroupsByWorktree !== state.groupsByWorktree
? { groupsByWorktree: nextGroupsByWorktree }
: {}),
...(nextActiveGroupIdByWorktree !== state.activeGroupIdByWorktree
? { activeGroupIdByWorktree: nextActiveGroupIdByWorktree }
: {}),
...(nextLayoutByWorktree !== state.layoutByWorktree
? { layoutByWorktree: nextLayoutByWorktree }
: {}),
...(nextTabBarOrderByWorktree !== state.tabBarOrderByWorktree
? { tabBarOrderByWorktree: nextTabBarOrderByWorktree }
: {}),
...(nextPtyIdsByTabId !== state.ptyIdsByTabId ? { ptyIdsByTabId: nextPtyIdsByTabId } : {}),
...(nextTerminalLayoutsByTabId !== state.terminalLayoutsByTabId
? { terminalLayoutsByTabId: nextTerminalLayoutsByTabId }
: {}),
...(nextUnreadTerminalTabs !== state.unreadTerminalTabs
? { unreadTerminalTabs: nextUnreadTerminalTabs }
: {}),
...(nextPendingStartupByTabId !== pendingStartupByTabId
? { pendingStartupByTabId: nextPendingStartupByTabId }
: {}),
...(nextAutomaticAgentResumeClaimsByTabId !== automaticAgentResumeClaimsByTabId
? { automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId }
: {}),
...(nextBrowserPagesByWorkspace !== state.browserPagesByWorkspace
? { browserPagesByWorkspace: nextBrowserPagesByWorkspace }
: {}),
...(nextRemoteBrowserPageHandlesByPageId !== state.remoteBrowserPageHandlesByPageId
? { remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId }
: {}),
...(nextBrowserCertificateFailuresByPageId !== state.browserCertificateFailuresByPageId
? { browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId }
: {}),
...(nextActiveTabIdByWorktree !== state.activeTabIdByWorktree
? { activeTabIdByWorktree: nextActiveTabIdByWorktree }
: {}),
...(nextActiveBrowserTabIdByWorktree !== state.activeBrowserTabIdByWorktree
? { activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree }
: {}),
...(nextActiveFileIdByWorktree !== state.activeFileIdByWorktree
? { activeFileIdByWorktree: nextActiveFileIdByWorktree }
: {}),
...(nextActiveTabId !== state.activeTabId ? { activeTabId: nextActiveTabId } : {}),
...(nextActiveBrowserTabId !== state.activeBrowserTabId
? { activeBrowserTabId: nextActiveBrowserTabId }
: {}),
...(nextActiveFileId !== state.activeFileId ? { activeFileId: nextActiveFileId } : {}),
...(nextActiveTabType !== state.activeTabType ? { activeTabType: nextActiveTabType } : {}),
...(nextActiveTabTypeByWorktree !== state.activeTabTypeByWorktree
? { activeTabTypeByWorktree: nextActiveTabTypeByWorktree }
: {})
}
return Object.keys(patch).length === 0 ? state : patch
}
@@ -0,0 +1,212 @@
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import type {
WebSessionTabsBatchContext,
WebSessionTabsSnapshotApplyOptions,
WebSessionTabsSyncState
} from './state'
import {
reconcileWebSessionCloseIntents,
isWebSessionCloseIntentPending
} from '../web-session-close-intent'
import {
peekWebSessionFocusIntent,
resolveWebSessionVisibleTabId,
clearWebSessionFocusIntent
} from '../web-session-focus-intent'
import {
resolveWebAgentSessionHandoff,
isWebAgentSessionHandoffPostCreateSnapshotConfirmed,
clearWebAgentSessionHandoff
} from '../web-agent-session-handoff'
import { toRemoteRuntimePtyId } from '../runtime-terminal-stream'
import { toWebTerminalSurfaceTabId } from '../web-runtime-session'
import {
isReadyTerminalTab,
isTerminalSurfaceTab,
isMirroredTerminalSurfaceId,
shouldReplaceTerminalTab
} from './terminal-surfaces'
import { buildMirroredTerminalTabs } from './terminal-build'
export function prepareWebSessionTabsSnapshotBase(
state: WebSessionTabsSyncState,
rawSnapshot: RuntimeMobileSessionTabsResult,
environmentId: string,
worktreeId: string,
now: number,
batchContext: WebSessionTabsBatchContext | undefined,
options: WebSessionTabsSnapshotApplyOptions | undefined
) {
const snapshotHostTabId = (tab: RuntimeMobileSessionTabsResult['tabs'][number]): string =>
tab.type === 'terminal' ? tab.parentTabId : tab.id
reconcileWebSessionCloseIntents(
{ environmentId },
worktreeId,
new Set(rawSnapshot.tabs.map((tab) => snapshotHostTabId(tab)))
)
const snapshot: RuntimeMobileSessionTabsResult = rawSnapshot.tabs.some((tab) =>
isWebSessionCloseIntentPending({ environmentId }, worktreeId, snapshotHostTabId(tab), now)
)
? {
...rawSnapshot,
tabs: rawSnapshot.tabs.filter(
(tab) =>
!isWebSessionCloseIntentPending(
{ environmentId },
worktreeId,
snapshotHostTabId(tab),
now
)
)
}
: rawSnapshot
// Why: only a caller-recorded create intent may focus its arriving tab; unsolicited server-active must not steal focus (#5435).
const focusIntent = peekWebSessionFocusIntent({ environmentId }, worktreeId)
const focusIntentHostTabId = focusIntent?.hostTabId ?? null
const matchingFocusIntentTab =
focusIntentHostTabId === null
? null
: focusIntent?.leafId
? (snapshot.tabs.find(
(tab) =>
tab.type === 'terminal' &&
tab.leafId === focusIntent.leafId &&
(tab.id === focusIntentHostTabId || tab.parentTabId === focusIntentHostTabId)
) ?? null)
: (snapshot.tabs.find(
(tab) =>
tab.id === focusIntentHostTabId ||
(tab.type === 'terminal' && tab.parentTabId === focusIntentHostTabId) ||
(tab.type === 'browser' && tab.browserPageId === focusIntentHostTabId)
) ?? null)
const expectedCurrentLocalTabId = focusIntent?.expectedCurrentLocalTabId
const currentVisibleLocalTabId = resolveWebSessionVisibleTabId(state, worktreeId)
const callerFocusIntentTab =
matchingFocusIntentTab &&
(expectedCurrentLocalTabId === undefined ||
expectedCurrentLocalTabId === currentVisibleLocalTabId)
? matchingFocusIntentTab
: null
const followIntentTab =
snapshot.navigationIntent === 'follow'
? (snapshot.tabs.find((tab) => tab.id === snapshot.activeTabId) ?? null)
: null
const navigationIntentTab = callerFocusIntentTab ?? followIntentTab
const honorSnapshotActiveFocus = navigationIntentTab !== null
if (matchingFocusIntentTab) {
clearWebSessionFocusIntent({ environmentId }, worktreeId)
}
const currentTerminalTabs = state.tabsByWorktree[worktreeId] ?? []
const existingTerminalById = new Map(currentTerminalTabs.map((tab) => [tab.id, tab]))
const reconcilesNonAgentTabs = options?.contentScope !== 'agent-session'
const terminalSurfaceTabs = reconcilesNonAgentTabs
? snapshot.tabs.filter(isTerminalSurfaceTab)
: []
const readyTerminalTabs = terminalSurfaceTabs.filter(isReadyTerminalTab)
const nextRemotePtyIds = new Set(
readyTerminalTabs.map((tab) => toRemoteRuntimePtyId(tab.terminal, environmentId))
)
const nextMirroredTerminalIds = new Set(
terminalSurfaceTabs.map((tab) => toWebTerminalSurfaceTabId(tab.parentTabId))
)
const nextHostTerminalTabIds = new Set(terminalSurfaceTabs.map((tab) => tab.parentTabId))
const provisionalHandoffHostTabIds = new Map<string, string>()
for (const tab of currentTerminalTabs) {
if (isMirroredTerminalSurfaceId(tab.id)) {
continue
}
if (nextHostTerminalTabIds.has(tab.id)) {
provisionalHandoffHostTabIds.set(tab.id, tab.id)
continue
}
const handoff = {
environmentId,
worktreeId,
provisionalTabId: tab.id
}
const hostTabId = resolveWebAgentSessionHandoff(handoff)
if (
hostTabId !== null &&
(nextHostTerminalTabIds.has(hostTabId) ||
isWebAgentSessionHandoffPostCreateSnapshotConfirmed(handoff))
) {
provisionalHandoffHostTabIds.set(tab.id, hostTabId)
}
}
const exactProvisionalHandoffs = new Set(provisionalHandoffHostTabIds.keys())
const retainedTerminalTabs = reconcilesNonAgentTabs
? currentTerminalTabs.filter(
(tab) =>
!shouldReplaceTerminalTab(
tab,
environmentId,
nextRemotePtyIds,
nextMirroredTerminalIds,
exactProvisionalHandoffs
)
)
: currentTerminalTabs
const mirroredTerminalTabs = buildMirroredTerminalTabs(
snapshot,
environmentId,
existingTerminalById,
state.terminalLayoutsByTabId,
retainedTerminalTabs.length,
now,
callerFocusIntentTab?.type === 'terminal'
? {
parentTabId: callerFocusIntentTab.parentTabId,
leafId: callerFocusIntentTab.leafId
}
: undefined,
options?.terminalPtyMode
)
const mirroredTerminalTabEntries = mirroredTerminalTabs.map((entry) => entry.tab)
const retainedTerminalIds = new Set(retainedTerminalTabs.map((tab) => tab.id))
const nextTerminalTabs =
retainedTerminalTabs.length + mirroredTerminalTabEntries.length > 0
? [...retainedTerminalTabs, ...mirroredTerminalTabEntries]
: null
const mirroredTerminalIds = new Set(mirroredTerminalTabEntries.map((tab) => tab.id))
const removedTerminalIds = new Set(
currentTerminalTabs.filter((tab) => !retainedTerminalIds.has(tab.id)).map((tab) => tab.id)
)
const removedTerminalResourceIds = [...removedTerminalIds].filter(
(tabId) => !mirroredTerminalIds.has(tabId)
)
for (const provisionalTabId of exactProvisionalHandoffs) {
clearWebAgentSessionHandoff({ environmentId, worktreeId, provisionalTabId })
}
return {
state,
rawSnapshot,
snapshot,
environmentId,
worktreeId,
now,
batchContext,
options,
focusIntent,
matchingFocusIntentTab,
callerFocusIntentTab,
navigationIntentTab,
honorSnapshotActiveFocus,
currentTerminalTabs,
existingTerminalById,
reconcilesNonAgentTabs,
terminalSurfaceTabs,
readyTerminalTabs,
nextRemotePtyIds,
nextMirroredTerminalIds,
provisionalHandoffHostTabIds,
exactProvisionalHandoffs,
retainedTerminalTabs,
mirroredTerminalTabs,
mirroredTerminalTabEntries,
nextTerminalTabs,
mirroredTerminalIds,
removedTerminalIds,
removedTerminalResourceIds
}
}
@@ -0,0 +1,227 @@
import { buildWebSessionExistingTabIndex } from '../web-session-existing-tab-index'
import { chooseTargetGroupId, buildHostGroupIdByTabId } from './layout-helpers'
import {
buildMirroredBrowserTabs,
browserWorkspaceHasRemoteEnvironmentPage,
browserWorkspaceHasClientHostedEnvironmentPage
} from './browser-helpers'
import { buildMirroredEditorTabs } from './tab-builders'
import { buildMirroredAgentTabs, isReadyBrowserTab, isReadyEditorTab } from './terminal-surfaces'
import { hostSnapshotAffirmsClientHostedPages } from '../host-session-snapshot-authority'
import type { prepareWebSessionTabsSnapshotBase } from './apply-preparation-base'
import type { OpenFile } from '../../store/slices/editor'
import {
advanceWebSessionOpenFilesIndex,
firstOpenFileByIdForWorktree,
sameOpenFiles,
webSessionOpenFilesForWorktree
} from './state-equality-files'
export function prepareWebSessionTabsSnapshotBrowser(
base: ReturnType<typeof prepareWebSessionTabsSnapshotBase>
) {
const {
state,
snapshot,
environmentId,
worktreeId,
now,
mirroredTerminalTabEntries,
mirroredTerminalIds,
removedTerminalIds,
reconcilesNonAgentTabs,
batchContext
} = base
const targetGroupId = chooseTargetGroupId(state, snapshot)
const hostGroupIdByTabId = buildHostGroupIdByTabId(snapshot.tabGroups)
const currentUnifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? []
const existingTabIndex = buildWebSessionExistingTabIndex({ unifiedTabs: currentUnifiedTabs })
const readyBrowserTabs = reconcilesNonAgentTabs ? snapshot.tabs.filter(isReadyBrowserTab) : []
const nextRemoteBrowserPageIds = new Set(readyBrowserTabs.map((tab) => tab.browserPageId))
const mirroredBrowserTabs = buildMirroredBrowserTabs(
snapshot,
environmentId,
state,
hostGroupIdByTabId,
targetGroupId,
mirroredTerminalTabEntries.length,
now
)
const mirroredBrowserWorkspaceIds = new Set(
mirroredBrowserTabs.map((entry) => entry.workspace.id)
)
const currentBrowserTabs = state.browserTabsByWorktree[worktreeId] ?? []
const removedBrowserWorkspaceIds = new Set(
(reconcilesNonAgentTabs ? currentBrowserTabs : [])
.filter((tab) => {
if (mirroredBrowserWorkspaceIds.has(tab.id)) {
return true
}
if (!browserWorkspaceHasRemoteEnvironmentPage(state, tab, environmentId)) {
return false
}
// Why: a staged tab holds a handle before its create RPC answers, and a restored tab holds
// one rebuilt from disk before the relaunched host has republished the page. Neither
// handle is evidence the host ever saw this snapshot's worktree, so absence proves
// nothing — culling here would erase an optimistic tab mid-create or a restored tab a
// recovering host is still about to hand back.
if (
(state.browserPagesByWorkspace[tab.id] ?? []).some((page) => {
const handle = state.remoteBrowserPageHandlesByPageId[page.id]
return handle?.staged === true || handle?.restoredFromSession === true
})
) {
return false
}
// Why: a runtime with nothing published for this worktree, or one that has restarted and not
// yet taken its client-hosted pages back, answers with a frame that looks exactly like
// "everything was closed". A page this desktop is still hosting outlives the runtime
// process, so its own guest is the better evidence — hold the row and let adoption publish
// it, rather than deleting a tab that is still rendering. The unreconciled flag is
// host-bounded; the unpublished-worktree frame is not, and its hold lasts until the runtime
// publishes that worktree at all.
if (
!hostSnapshotAffirmsClientHostedPages(snapshot) &&
browserWorkspaceHasClientHostedEnvironmentPage(state, tab, environmentId)
) {
return false
}
return !(state.browserPagesByWorkspace[tab.id] ?? []).some((page) => {
const handle = state.remoteBrowserPageHandlesByPageId[page.id]
return (
handle?.environmentId === environmentId &&
nextRemoteBrowserPageIds.has(handle.remotePageId)
)
})
})
.map((tab) => tab.id)
)
const retainedBrowserTabs = currentBrowserTabs.filter(
(tab) => !removedBrowserWorkspaceIds.has(tab.id)
)
const nextBrowserTabs =
retainedBrowserTabs.length + mirroredBrowserTabs.length > 0
? [...retainedBrowserTabs, ...mirroredBrowserTabs.map((entry) => entry.workspace)]
: null
const readyEditorTabs = reconcilesNonAgentTabs ? snapshot.tabs.filter(isReadyEditorTab) : []
const worktreeOpenFiles = webSessionOpenFilesForWorktree(state, worktreeId, batchContext)
const mirroredEditorTabs = buildMirroredEditorTabs(
snapshot,
environmentId,
firstOpenFileByIdForWorktree(worktreeOpenFiles),
existingTabIndex,
hostGroupIdByTabId,
targetGroupId,
mirroredTerminalTabEntries.length + mirroredBrowserTabs.length,
now
)
const mirroredAgentTabs = buildMirroredAgentTabs(
snapshot,
hostGroupIdByTabId,
targetGroupId,
mirroredTerminalTabEntries.length + mirroredBrowserTabs.length + mirroredEditorTabs.length,
currentUnifiedTabs,
now
)
const mirroredEditorFileIds = new Set(mirroredEditorTabs.map((entry) => entry.file.id))
const mirroredEditorHostTabIds = new Set(mirroredEditorTabs.map((entry) => entry.hostTabId))
const removedEditorFileIds = new Set(
(reconcilesNonAgentTabs ? worktreeOpenFiles : [])
.filter(
(file) =>
file.runtimeEnvironmentId === environmentId &&
(file.mode === 'edit' || file.mode === 'markdown-preview') &&
// Why: only cull host-mirrored tabs; locally opened files have no host counterpart, so their omission isn't a close signal.
file.mirroredFromRuntimeSession === true &&
!mirroredEditorFileIds.has(file.id)
)
.map((file) => file.id)
)
const isReplacedOpenFile = (file: OpenFile): boolean =>
file.runtimeEnvironmentId === environmentId &&
(removedEditorFileIds.has(file.id) || mirroredEditorFileIds.has(file.id))
const replacedOpenFileCount = worktreeOpenFiles.filter(isReplacedOpenFile).length
// Why: both consumers below ask only about this worktree, so the surviving ids answer
// them in worktree scope instead of walking every open file in the app.
const nextWorktreeOpenFileIds = new Set<string>(
worktreeOpenFiles.filter((file) => !isReplacedOpenFile(file)).map((file) => file.id)
)
for (const fileId of mirroredEditorFileIds) {
nextWorktreeOpenFileIds.add(fileId)
}
const mirroredOpenFiles = mirroredEditorTabs.map((entry) => entry.file)
const nextOpenFiles = (() => {
// Why: with nothing to drop or mirror, rebuilding reproduces the array exactly, so
// skip the global rebuild the equality check below would have thrown away anyway.
if (replacedOpenFileCount === 0 && mirroredOpenFiles.length === 0) {
return state.openFiles
}
const retained = state.openFiles.filter(
(file) =>
!(
file.worktreeId === worktreeId &&
file.runtimeEnvironmentId === environmentId &&
(removedEditorFileIds.has(file.id) || mirroredEditorFileIds.has(file.id))
)
)
const next = [...retained, ...mirroredOpenFiles]
return sameOpenFiles(state.openFiles, next) ? state.openFiles : next
})()
advanceWebSessionOpenFilesIndex(batchContext, nextOpenFiles, worktreeId)
const retainedUnifiedTabs = currentUnifiedTabs.filter((tab) => {
if (tab.contentType === 'agent-session') {
return false
}
if (tab.contentType === 'browser') {
return (
!removedBrowserWorkspaceIds.has(tab.entityId) &&
!mirroredBrowserWorkspaceIds.has(tab.entityId)
)
}
if (tab.contentType === 'editor') {
return (
!removedEditorFileIds.has(tab.entityId) &&
!mirroredEditorFileIds.has(tab.entityId) &&
!mirroredEditorHostTabIds.has(tab.id)
)
}
if (tab.contentType !== 'terminal') {
return true
}
if (removedTerminalIds.has(tab.entityId) || removedTerminalIds.has(tab.id)) {
return false
}
return !mirroredTerminalIds.has(tab.entityId) && !mirroredTerminalIds.has(tab.id)
})
const existingViewModeByTabId = new Map(
currentUnifiedTabs
.filter((tab) => tab.contentType === 'terminal' && tab.viewMode)
.map((tab) => [tab.id, tab.viewMode] as const)
)
return {
...base,
targetGroupId,
hostGroupIdByTabId,
currentUnifiedTabs,
existingTabIndex,
readyBrowserTabs,
nextRemoteBrowserPageIds,
mirroredBrowserTabs,
mirroredBrowserWorkspaceIds,
currentBrowserTabs,
removedBrowserWorkspaceIds,
retainedBrowserTabs,
nextBrowserTabs,
readyEditorTabs,
worktreeOpenFiles,
mirroredEditorTabs,
mirroredAgentTabs,
mirroredEditorFileIds,
mirroredEditorHostTabIds,
removedEditorFileIds,
nextWorktreeOpenFileIds,
nextOpenFiles,
retainedUnifiedTabs,
existingViewModeByTabId
}
}
@@ -0,0 +1,233 @@
import type { TabGroup } from '../../../../shared/tab-types'
import type { prepareWebSessionTabsSnapshotUnified } from './apply-preparation-unified'
import { toWebTerminalSurfaceTabId } from '../web-runtime-session'
import { peekWebSessionBrowserPlacementGroup } from '../web-session-browser-placement'
import { peekWebSessionTerminalPlacementGroup } from '../web-session-terminal-placement'
import { isWebSessionBrowserPlacementGroupReserved } from '../web-session-browser-placement'
import { reconcileClientOwnedTabPlacement } from '../web-session-client-owned-tab-placement'
import { buildMirroredHostGroups, retainClientPlacedMirroredTabs } from './layout-groups'
import { sanitizeRecentTabIds, pushRecentTabId } from './state-equality-core'
export function prepareWebSessionTabsSnapshotGroups(
base: ReturnType<typeof prepareWebSessionTabsSnapshotUnified>
) {
const {
state,
snapshot,
environmentId,
worktreeId,
now,
options,
terminalSurfaceTabs,
mirroredBrowserTabs,
mirroredEditorTabs,
mirroredUnifiedIds,
mirroredUnifiedTabs,
nextUnifiedTabs,
validUnifiedTabIds,
nextActiveUnifiedTabId,
retainedUnifiedTabs,
targetGroupId,
hostToLocalTabId,
provisionalHandoffHostTabIds,
existingTabIndex,
honorSnapshotActiveFocus,
intentUnifiedTabId,
reservedEmptyPreviewFallbackTabId
} = base
const currentGroups = state.groupsByWorktree[worktreeId] ?? []
const clientGroupIdByLocalTabId = new Map(
mirroredBrowserTabs.flatMap((entry) =>
entry.clientGroupId ? [[entry.unifiedTab.id, entry.clientGroupId]] : []
)
)
// Why: once this worktree has client groups, placement is client-owned — snapshots may only
// append never-seen tabs, drop vanished ones, and honor explicit focus intent. Host order,
// host actives, and host layout apply only on first adoption (no client groups yet).
const clientOwnedPlacement = (() => {
// Why: a preserveLocalLayout owner keeps the local layout authoritative, so placement
// is client-owned even before any local group record exists — first adoption on an
// empty worktree must repair a rendered-leaf-without-record or materialize a rendered
// group instead of publishing the tab into a group no local leaf will ever show.
if (!nextUnifiedTabs || (currentGroups.length === 0 && !options?.preserveLocalLayout)) {
return null
}
// Why: an entity-identical replacement (provisional terminal → mirrored surface, local
// editor → host editor tab) is a rename — its position and focus must carry over.
const rekeyedTabIds = new Map<string, string>()
for (const [provisionalTabId, hostTabId] of provisionalHandoffHostTabIds) {
const mirroredId = toWebTerminalSurfaceTabId(hostTabId)
if (mirroredId !== provisionalTabId) {
rekeyedTabIds.set(provisionalTabId, mirroredId)
}
}
for (const entry of mirroredEditorTabs) {
const existing = existingTabIndex.getEditorUnifiedTab(entry.file.id, entry.hostTabId)
if (existing && existing.id !== entry.unifiedTab.id) {
rekeyedTabIds.set(existing.id, entry.unifiedTab.id)
}
}
const knownGroupTabIds = new Set(
currentGroups.flatMap((group) =>
group.tabOrder.map((tabId) => rekeyedTabIds.get(tabId) ?? tabId)
)
)
// Why: a pending record is this client's own create intent — authoritative even when the
// provisional tab was provisionally adopted elsewhere or the target group record lags its leaf.
// The entry's own clientGroupId comes first, so a group the user moved the row into after the
// create was recorded wins over the group the create asked for.
const placementMoves = mirroredBrowserTabs.flatMap((entry) => {
const recordedGroupId = peekWebSessionBrowserPlacementGroup({
environmentId,
worktreeId,
remotePageId: entry.remotePageId
})
if (!recordedGroupId) {
return []
}
return [{ tabId: entry.unifiedTab.id, groupId: entry.clientGroupId ?? recordedGroupId }]
})
for (const parentTabId of new Set(terminalSurfaceTabs.map((tab) => tab.parentTabId))) {
const recordedGroupId = peekWebSessionTerminalPlacementGroup({
environmentId,
worktreeId,
hostTabId: parentTabId
})
if (recordedGroupId) {
placementMoves.push({
tabId: toWebTerminalSurfaceTabId(parentTabId),
groupId: recordedGroupId
})
}
}
const adoptedTabs = mirroredUnifiedTabs
.filter((tab) => !knownGroupTabIds.has(tab.id))
.map((tab) => ({
tabId: tab.id,
groupId: clientGroupIdByLocalTabId.get(tab.id) ?? tab.groupId
}))
return reconcileClientOwnedTabPlacement({
currentGroups,
worktreeId,
validUnifiedTabIds,
adoptedTabs,
placementMoves,
rekeyedTabIds,
intentTabId: honorSnapshotActiveFocus ? (intentUnifiedTabId ?? null) : null,
reservedEmptyGroupFallbackTabId: reservedEmptyPreviewFallbackTabId,
currentActiveGroupId: state.activeGroupIdByWorktree[worktreeId] ?? null,
currentLayout: state.layoutByWorktree[worktreeId] ?? null,
isGroupReserved: (groupId) =>
isWebSessionBrowserPlacementGroupReserved({ worktreeId, groupId })
})
})()
const nextGroups = (() => {
if (clientOwnedPlacement) {
return clientOwnedPlacement.groups
}
if (!nextUnifiedTabs || nextUnifiedTabs.length === 0) {
return null
}
if (snapshot.tabGroups && snapshot.tabGroups.length > 0) {
return buildMirroredHostGroups({
currentGroups,
hostGroups: snapshot.tabGroups,
hostToLocalTabId,
mirroredUnifiedIds,
nextActiveUnifiedTabId,
now,
validUnifiedTabIds,
environmentId,
worktreeId,
clientGroupIdByLocalTabId
})
}
const strippedGroups = retainClientPlacedMirroredTabs({
groups: currentGroups,
mirroredUnifiedIds,
validUnifiedTabIds,
clientGroupIdByLocalTabId,
nextActiveUnifiedTabId
})
const target = strippedGroups.find((group) => group.id === targetGroupId) ?? {
id: targetGroupId,
worktreeId,
activeTabId: null,
tabOrder: [],
recentTabIds: []
}
const targetOrder = [
...target.tabOrder.filter((tabId) => validUnifiedTabIds.has(tabId)),
...mirroredUnifiedTabs
.filter((tab) => !clientGroupIdByLocalTabId.has(tab.id))
.map((tab) => tab.id)
]
const targetActiveTabId =
nextActiveUnifiedTabId && targetOrder.includes(nextActiveUnifiedTabId)
? nextActiveUnifiedTabId
: target.activeTabId && targetOrder.includes(target.activeTabId)
? target.activeTabId
: (targetOrder[0] ?? null)
const updatedTarget: TabGroup = {
...target,
worktreeId,
tabOrder: targetOrder,
activeTabId: targetActiveTabId,
recentTabIds: targetActiveTabId
? pushRecentTabId(sanitizeRecentTabIds(target.recentTabIds, targetOrder), targetActiveTabId)
: []
}
const merged = strippedGroups.some((group) => group.id === targetGroupId)
? strippedGroups.map((group) => (group.id === targetGroupId ? updatedTarget : group))
: [...strippedGroups, updatedTarget]
return merged.filter(
(group) =>
group.id === targetGroupId ||
group.tabOrder.length > 0 ||
isWebSessionBrowserPlacementGroupReserved({
worktreeId,
groupId: group.id
})
)
})()
const nextTabBarOrder = (() => {
const current = state.tabBarOrderByWorktree[worktreeId] ?? []
const validTabBarIds = new Set([
...retainedUnifiedTabs.map((tab) => tab.id),
...mirroredUnifiedTabs.map((tab) => tab.id)
])
const hostTabBarOrder =
snapshot.tabGroups?.flatMap((group) =>
group.tabOrder
.map((tabId) => hostToLocalTabId.get(tabId))
.filter((tabId): tabId is string => tabId !== undefined && validTabBarIds.has(tabId))
) ?? []
const next: string[] = []
const seen = new Set<string>()
const push = (tabId: string): void => {
if (validTabBarIds.has(tabId) && !seen.has(tabId)) {
seen.add(tabId)
next.push(tabId)
}
}
// Why: snapshots can arrive after the client staged local browser tabs, so preserve visible order and only append new host tabs.
for (const tabId of current) {
push(tabId)
}
const hostOrMirroredOrder =
hostTabBarOrder.length > 0 ? hostTabBarOrder : mirroredUnifiedTabs.map((tab) => tab.id)
for (const tabId of hostOrMirroredOrder) {
push(tabId)
}
return next
})()
return {
...base,
currentGroups,
clientGroupIdByLocalTabId,
clientOwnedPlacement,
nextGroups,
nextTabBarOrder
}
}
@@ -0,0 +1,278 @@
import type { prepareWebSessionTabsSnapshotBrowser } from './apply-preparation-browser'
import { buildTerminalUnifiedTab } from './tab-builders'
import { isAgentSessionTab } from './terminal-surfaces'
import { toWebTerminalSurfaceTabId } from '../web-runtime-session'
import {
resolveWebSessionSiblingVisibleTabId,
resolveWebSessionVisibleTabId
} from '../web-session-focus-intent'
import { isWebSessionBrowserPlacementGroupReserved } from '../web-session-browser-placement'
import { buildHostToLocalTabIdMap, updateHostSessionTabIdMappings } from './layout-groups'
export function prepareWebSessionTabsSnapshotUnified(
base: ReturnType<typeof prepareWebSessionTabsSnapshotBrowser>
) {
const {
state,
snapshot,
environmentId,
worktreeId,
navigationIntentTab,
honorSnapshotActiveFocus,
terminalSurfaceTabs,
mirroredTerminalTabs,
mirroredTerminalTabEntries,
nextTerminalTabs,
mirroredBrowserTabs,
mirroredEditorTabs,
mirroredAgentTabs,
readyBrowserTabs,
readyEditorTabs,
nextBrowserTabs,
nextWorktreeOpenFileIds,
retainedUnifiedTabs,
existingViewModeByTabId,
hostGroupIdByTabId,
targetGroupId
} = base
const mirroredTerminalUnifiedTabs = mirroredTerminalTabs.map((entry) =>
buildTerminalUnifiedTab(
entry.tab,
hostGroupIdByTabId.get(entry.hostTabId) ?? targetGroupId,
environmentId,
entry.tab.viewMode ?? existingViewModeByTabId.get(entry.tab.id)
)
)
const mirroredBrowserUnifiedTabs = mirroredBrowserTabs.map((entry) => entry.unifiedTab)
const mirroredEditorUnifiedTabs = mirroredEditorTabs.map((entry) => entry.unifiedTab)
const mirroredAgentUnifiedTabs = mirroredAgentTabs.map((entry) => entry.unifiedTab)
const mirroredUnifiedTabs = [
...mirroredTerminalUnifiedTabs,
...mirroredBrowserUnifiedTabs,
...mirroredEditorUnifiedTabs,
...mirroredAgentUnifiedTabs
]
const nextUnifiedTabs =
retainedUnifiedTabs.length + mirroredUnifiedTabs.length > 0
? [...retainedUnifiedTabs, ...mirroredUnifiedTabs]
: null
const validUnifiedTabIds = new Set(nextUnifiedTabs?.map((tab) => tab.id) ?? [])
const activeHostTerminalId =
terminalSurfaceTabs.find((tab) => tab.id === snapshot.activeTabId)?.id ??
terminalSurfaceTabs.find((tab) => tab.isActive)?.id ??
null
const activeHostTerminalParentId =
terminalSurfaceTabs.find((tab) => tab.id === activeHostTerminalId)?.parentTabId ??
terminalSurfaceTabs.find((tab) => tab.isActive)?.parentTabId ??
null
const activeMirroredTerminalId = activeHostTerminalId
? toWebTerminalSurfaceTabId(activeHostTerminalParentId ?? activeHostTerminalId)
: null
const activeHostBrowser =
readyBrowserTabs.find((tab) => tab.id === snapshot.activeTabId) ??
readyBrowserTabs.find((tab) => tab.isActive) ??
null
const activeMirroredBrowser = activeHostBrowser
? (mirroredBrowserTabs.find(
(entry) => entry.remotePageId === activeHostBrowser.browserPageId
) ?? null)
: null
const activeMirroredBrowserTabId = activeMirroredBrowser?.unifiedTab.id ?? null
const activeMirroredBrowserWorkspaceId = activeMirroredBrowser?.workspace.id ?? null
const activeHostEditor =
readyEditorTabs.find((tab) => tab.id === snapshot.activeTabId) ??
readyEditorTabs.find((tab) => tab.isActive) ??
null
const activeMirroredEditor = activeHostEditor
? (mirroredEditorTabs.find((entry) => entry.hostTabId === activeHostEditor.id) ?? null)
: null
const activeMirroredEditorFileId = activeMirroredEditor?.file.id ?? null
const activeMirroredEditorTabId = activeMirroredEditor?.unifiedTab.id ?? null
const activeHostAgent =
snapshot.tabs
.filter(isAgentSessionTab)
.find((tab) => tab.id === snapshot.activeTabId || tab.isActive) ?? null
const activeMirroredAgentTabId = activeHostAgent
? (mirroredAgentTabs.find((entry) => entry.hostTabId === activeHostAgent.id)?.unifiedTab.id ??
null)
: null
const intentMirroredTerminalId =
navigationIntentTab?.type === 'terminal'
? toWebTerminalSurfaceTabId(navigationIntentTab.parentTabId)
: null
const intentMirroredBrowser =
navigationIntentTab?.type === 'browser'
? (mirroredBrowserTabs.find(
(entry) =>
entry.hostTabId === navigationIntentTab.id ||
entry.remotePageId === navigationIntentTab.browserPageId
) ?? null)
: null
const intentMirroredEditor =
navigationIntentTab?.type === 'markdown' || navigationIntentTab?.type === 'file'
? (mirroredEditorTabs.find((entry) => entry.hostTabId === navigationIntentTab.id) ?? null)
: null
const intentMirroredAgent =
navigationIntentTab?.type === 'agent-session'
? (mirroredAgentTabs.find((entry) => entry.hostTabId === navigationIntentTab.id) ?? null)
: null
const currentActiveTerminalStillExists =
state.activeTabIdByWorktree[worktreeId] &&
(nextTerminalTabs ?? []).some((tab) => tab.id === state.activeTabIdByWorktree[worktreeId])
? state.activeTabIdByWorktree[worktreeId]
: null
// Why: caller intent targets the requested tab even when an older host leaves its own active tab unchanged.
const intentTerminalId =
honorSnapshotActiveFocus && navigationIntentTab?.type === 'terminal'
? intentMirroredTerminalId
: null
const nextActiveTerminalId =
intentTerminalId ??
currentActiveTerminalStillExists ??
(snapshot.activeTabType === 'terminal'
? (activeMirroredTerminalId ?? mirroredTerminalTabEntries[0]?.id)
: mirroredTerminalTabEntries[0]?.id) ??
null
const currentActiveBrowserStillExists =
state.activeBrowserTabIdByWorktree[worktreeId] &&
(nextBrowserTabs ?? []).some((tab) => tab.id === state.activeBrowserTabIdByWorktree[worktreeId])
? state.activeBrowserTabIdByWorktree[worktreeId]
: null
const intentBrowserWorkspaceId =
honorSnapshotActiveFocus && navigationIntentTab?.type === 'browser'
? (intentMirroredBrowser?.workspace.id ?? null)
: null
const nextActiveBrowserWorkspaceId =
intentBrowserWorkspaceId ??
currentActiveBrowserStillExists ??
(snapshot.activeTabType === 'browser'
? (activeMirroredBrowserWorkspaceId ?? mirroredBrowserTabs[0]?.workspace.id)
: mirroredBrowserTabs[0]?.workspace.id) ??
null
const activeEditorFileIdForWorktree = state.activeFileIdByWorktree[worktreeId]
const currentActiveEditorStillExists =
activeEditorFileIdForWorktree && nextWorktreeOpenFileIds.has(activeEditorFileIdForWorktree)
? activeEditorFileIdForWorktree
: null
const intentEditorFileId = honorSnapshotActiveFocus
? (intentMirroredEditor?.file.id ?? null)
: null
const nextActiveEditorFileId =
intentEditorFileId ??
currentActiveEditorStillExists ??
(snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file'
? (activeMirroredEditorFileId ?? mirroredEditorTabs[0]?.file.id)
: mirroredEditorTabs[0]?.file.id) ??
null
const currentVisibleUnifiedTabId = resolveWebSessionVisibleTabId(
state,
worktreeId,
nextUnifiedTabs ?? []
)
const currentVisibleStructuredTabId =
currentVisibleUnifiedTabId &&
nextUnifiedTabs?.find(
(tab) => tab.id === currentVisibleUnifiedTabId && tab.contentType === 'agent-session'
)
? currentVisibleUnifiedTabId
: null
const activeGroupId = state.activeGroupIdByWorktree[worktreeId]
// Why: Open Preview to the Side can activate an empty reserved group before the host
// browser lands. A snapshot that still has the host terminal active must not treat
// that emptiness as a terminal focus change.
const reservedEmptyPreviewFallbackTabId =
currentVisibleUnifiedTabId == null &&
activeGroupId != null &&
isWebSessionBrowserPlacementGroupReserved({ worktreeId, groupId: activeGroupId })
? resolveWebSessionSiblingVisibleTabId(state, worktreeId, nextUnifiedTabs ?? [])
: null
// Why: a client-initiated activation also drives the visible unified tab, overriding the sticky current-visible tab.
const intentUnifiedTabId = honorSnapshotActiveFocus
? navigationIntentTab?.type === 'browser'
? (intentMirroredBrowser?.unifiedTab.id ?? null)
: navigationIntentTab?.type === 'terminal'
? intentTerminalId
: navigationIntentTab?.type === 'agent-session'
? (intentMirroredAgent?.unifiedTab.id ?? null)
: navigationIntentTab?.type === 'markdown' || navigationIntentTab?.type === 'file'
? (intentMirroredEditor?.unifiedTab.id ?? null)
: null
: null
const nextActiveUnifiedTabId =
intentUnifiedTabId ??
currentVisibleUnifiedTabId ??
reservedEmptyPreviewFallbackTabId ??
(snapshot.activeTabType === 'agent-session'
? (activeMirroredAgentTabId ?? mirroredAgentUnifiedTabs[0]?.id ?? nextActiveTerminalId)
: snapshot.activeTabType === 'browser'
? (activeMirroredBrowserTabId ??
mirroredBrowserTabs[0]?.unifiedTab.id ??
state.activeTabIdByWorktree[worktreeId] ??
nextActiveTerminalId)
: snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file'
? (activeMirroredEditorTabId ??
mirroredEditorTabs[0]?.unifiedTab.id ??
state.activeTabIdByWorktree[worktreeId] ??
nextActiveTerminalId)
: nextActiveTerminalId)
const mirroredUnifiedIds = new Set(mirroredUnifiedTabs.map((tab) => tab.id))
const hostToLocalTabId = buildHostToLocalTabIdMap({
terminalSurfaces: terminalSurfaceTabs,
terminalTabs: mirroredTerminalTabEntries,
browserTabs: mirroredBrowserTabs,
editorTabs: mirroredEditorTabs,
agentTabs: mirroredAgentTabs
})
updateHostSessionTabIdMappings({
environmentId,
worktreeId,
terminalSurfaces: terminalSurfaceTabs,
terminalTabs: mirroredTerminalTabEntries,
browserTabs: mirroredBrowserTabs,
editorTabs: mirroredEditorTabs,
agentTabs: mirroredAgentTabs
})
return {
...base,
mirroredTerminalUnifiedTabs,
mirroredBrowserUnifiedTabs,
mirroredEditorUnifiedTabs,
mirroredAgentUnifiedTabs,
mirroredUnifiedTabs,
nextUnifiedTabs,
validUnifiedTabIds,
activeHostTerminalId,
activeMirroredTerminalId,
activeHostBrowser,
activeMirroredBrowser,
activeMirroredBrowserTabId,
activeMirroredBrowserWorkspaceId,
activeHostEditor,
activeMirroredEditor,
activeMirroredEditorFileId,
activeMirroredEditorTabId,
activeHostAgent,
activeMirroredAgentTabId,
intentMirroredTerminalId,
intentMirroredBrowser,
intentMirroredEditor,
intentMirroredAgent,
currentActiveTerminalStillExists,
intentTerminalId,
nextActiveTerminalId,
currentActiveBrowserStillExists,
intentBrowserWorkspaceId,
nextActiveBrowserWorkspaceId,
currentActiveEditorStillExists,
intentEditorFileId,
nextActiveEditorFileId,
currentVisibleUnifiedTabId,
currentVisibleStructuredTabId,
reservedEmptyPreviewFallbackTabId,
intentUnifiedTabId,
nextActiveUnifiedTabId,
mirroredUnifiedIds,
hostToLocalTabId
}
}
@@ -0,0 +1,52 @@
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import type {
WebSessionTabsBatchContext,
WebSessionTabsSnapshotApplyOptions,
WebSessionTabsSyncState
} from './state'
import { suppressE2eWebRuntimeBrowserSnapshot } from '../web-runtime-browser-creation-e2e-fault'
import { prepareWebSessionTabsSnapshotBase } from './apply-preparation-base'
import { prepareWebSessionTabsSnapshotBrowser } from './apply-preparation-browser'
import { prepareWebSessionTabsSnapshotUnified } from './apply-preparation-unified'
import { prepareWebSessionTabsSnapshotGroups } from './apply-preparation-groups'
import { applyTerminalRecordUpdates } from './apply-terminal-records'
import { applyBrowserRecordUpdates } from './apply-browser-records'
import { applyWorktreeRecordUpdates } from './apply-worktree-records'
import { applyActiveStateUpdates } from './apply-active-state'
import { buildWebSessionTabsFinalPatch } from './apply-final-patch'
/** Reconcile one host frame through the staged terminal/browser/layout pipeline. */
export function applyWebSessionTabsSnapshotWithContext(
state: WebSessionTabsSyncState,
rawSnapshot: RuntimeMobileSessionTabsResult,
environmentId: string,
now = Date.now(),
batchContext?: WebSessionTabsBatchContext,
options?: WebSessionTabsSnapshotApplyOptions
): WebSessionTabsSyncState | Partial<WebSessionTabsSyncState> {
if (
suppressE2eWebRuntimeBrowserSnapshot(rawSnapshot) ||
rawSnapshot.worktree === FLOATING_TERMINAL_WORKTREE_ID
) {
return state
}
const worktreeId = rawSnapshot.worktree
const base = prepareWebSessionTabsSnapshotBase(
state,
rawSnapshot,
environmentId,
worktreeId,
now,
batchContext,
options
)
const browser = prepareWebSessionTabsSnapshotBrowser(base)
const unified = prepareWebSessionTabsSnapshotUnified(browser)
const groups = prepareWebSessionTabsSnapshotGroups(unified)
const terminalRecords = applyTerminalRecordUpdates(groups)
const browserRecords = applyBrowserRecordUpdates(terminalRecords)
const worktreeRecords = applyWorktreeRecordUpdates(browserRecords)
const activeState = applyActiveStateUpdates(worktreeRecords)
return buildWebSessionTabsFinalPatch(activeState)
}
@@ -0,0 +1,116 @@
import { terminalLayoutEqual } from '@/lib/terminal-layout-equality'
import type { prepareWebSessionTabsSnapshotGroups } from './apply-preparation-groups'
import { sameStringArray, writableWebSessionTabsRecord } from './state-equality-core'
type TerminalRecordContext = ReturnType<typeof prepareWebSessionTabsSnapshotGroups>
/** Reconcile PTY ownership, layouts, unread markers, and provisional startup records. */
export function applyTerminalRecordUpdates(context: TerminalRecordContext) {
const {
state,
batchContext,
mirroredTerminalTabs,
removedTerminalResourceIds,
removedTerminalIds,
exactProvisionalHandoffs
} = context
let nextPtyIdsByTabId = state.ptyIdsByTabId
for (const removedId of removedTerminalResourceIds) {
if (!nextPtyIdsByTabId[removedId]) {
continue
}
nextPtyIdsByTabId =
nextPtyIdsByTabId === state.ptyIdsByTabId
? writableWebSessionTabsRecord(state, 'ptyIdsByTabId', batchContext)
: nextPtyIdsByTabId
delete nextPtyIdsByTabId[removedId]
}
for (const { tab, ptyIds } of mirroredTerminalTabs) {
if (ptyIds.length === 0) {
if (nextPtyIdsByTabId[tab.id]) {
nextPtyIdsByTabId =
nextPtyIdsByTabId === state.ptyIdsByTabId
? writableWebSessionTabsRecord(state, 'ptyIdsByTabId', batchContext)
: nextPtyIdsByTabId
delete nextPtyIdsByTabId[tab.id]
}
continue
}
const current = nextPtyIdsByTabId[tab.id] ?? []
if (sameStringArray(current, ptyIds)) {
continue
}
nextPtyIdsByTabId =
nextPtyIdsByTabId === state.ptyIdsByTabId
? writableWebSessionTabsRecord(state, 'ptyIdsByTabId', batchContext)
: nextPtyIdsByTabId
nextPtyIdsByTabId[tab.id] = ptyIds
}
let nextTerminalLayoutsByTabId = state.terminalLayoutsByTabId
for (const removedId of removedTerminalResourceIds) {
if (!nextTerminalLayoutsByTabId[removedId]) {
continue
}
nextTerminalLayoutsByTabId =
nextTerminalLayoutsByTabId === state.terminalLayoutsByTabId
? writableWebSessionTabsRecord(state, 'terminalLayoutsByTabId', batchContext)
: nextTerminalLayoutsByTabId
delete nextTerminalLayoutsByTabId[removedId]
}
for (const { tab, layout } of mirroredTerminalTabs) {
if (terminalLayoutEqual(nextTerminalLayoutsByTabId[tab.id], layout)) {
continue
}
nextTerminalLayoutsByTabId =
nextTerminalLayoutsByTabId === state.terminalLayoutsByTabId
? writableWebSessionTabsRecord(state, 'terminalLayoutsByTabId', batchContext)
: nextTerminalLayoutsByTabId
nextTerminalLayoutsByTabId[tab.id] = layout
}
let nextUnreadTerminalTabs = state.unreadTerminalTabs
for (const removedId of removedTerminalIds) {
if (!nextUnreadTerminalTabs[removedId]) {
continue
}
nextUnreadTerminalTabs =
nextUnreadTerminalTabs === state.unreadTerminalTabs
? writableWebSessionTabsRecord(state, 'unreadTerminalTabs', batchContext)
: nextUnreadTerminalTabs
delete nextUnreadTerminalTabs[removedId]
}
const pendingStartupByTabId = state.pendingStartupByTabId ?? {}
let nextPendingStartupByTabId = pendingStartupByTabId
const automaticAgentResumeClaimsByTabId = state.automaticAgentResumeClaimsByTabId ?? {}
let nextAutomaticAgentResumeClaimsByTabId = automaticAgentResumeClaimsByTabId
for (const removedId of exactProvisionalHandoffs) {
if (nextPendingStartupByTabId[removedId]) {
nextPendingStartupByTabId =
nextPendingStartupByTabId === pendingStartupByTabId
? writableWebSessionTabsRecord(state, 'pendingStartupByTabId', batchContext)
: nextPendingStartupByTabId
delete nextPendingStartupByTabId[removedId]
}
if (nextAutomaticAgentResumeClaimsByTabId[removedId]) {
nextAutomaticAgentResumeClaimsByTabId =
nextAutomaticAgentResumeClaimsByTabId === automaticAgentResumeClaimsByTabId
? writableWebSessionTabsRecord(state, 'automaticAgentResumeClaimsByTabId', batchContext)
: nextAutomaticAgentResumeClaimsByTabId
delete nextAutomaticAgentResumeClaimsByTabId[removedId]
}
}
return {
...context,
nextPtyIdsByTabId,
nextTerminalLayoutsByTabId,
nextUnreadTerminalTabs,
pendingStartupByTabId,
nextPendingStartupByTabId,
automaticAgentResumeClaimsByTabId,
nextAutomaticAgentResumeClaimsByTabId
}
}
@@ -0,0 +1,92 @@
import type { applyBrowserRecordUpdates } from './apply-browser-records'
import { withWorktreeEntry } from './state-equality-core'
import { sameGroups, sameUnifiedTabs } from './state-equality-files'
import { sameBrowserTabs as sameBrowserWorkspaces, sameTerminalTabs } from './state-equality-tabs'
type WorktreeRecordContext = ReturnType<typeof applyBrowserRecordUpdates>
/** Commit worktree-scoped tab collections and retain client-owned placement metadata. */
export function applyWorktreeRecordUpdates(context: WorktreeRecordContext) {
const {
state,
batchContext,
worktreeId,
nextTerminalTabs,
nextBrowserTabs,
nextUnifiedTabs,
nextGroups,
clientOwnedPlacement,
retainedUnifiedTabs,
mirroredUnifiedTabs,
mirroredTerminalIds,
nextTabBarOrder
} = context
const nextTabsByWorktree = withWorktreeEntry(
state,
'tabsByWorktree',
worktreeId,
nextTerminalTabs,
sameTerminalTabs,
batchContext
)
const nextBrowserTabsByWorktree = withWorktreeEntry(
state,
'browserTabsByWorktree',
worktreeId,
nextBrowserTabs,
sameBrowserWorkspaces,
batchContext
)
// Group membership is the source of truth for a client-owned placement.
const placedUnifiedTabs = (() => {
if (!clientOwnedPlacement?.groups || !nextUnifiedTabs) {
return nextUnifiedTabs
}
const groupIdByTabId = new Map(
clientOwnedPlacement.groups.flatMap((group) =>
group.tabOrder.map((tabId) => [tabId, group.id] as const)
)
)
let changed = false
const placed = nextUnifiedTabs.map((tab) => {
const groupId = groupIdByTabId.get(tab.id)
if (!groupId || groupId === tab.groupId) {
return tab
}
changed = true
return { ...tab, groupId }
})
return changed ? placed : nextUnifiedTabs
})()
const nextUnifiedTabsByWorktree = withWorktreeEntry(
state,
'unifiedTabsByWorktree',
worktreeId,
placedUnifiedTabs,
sameUnifiedTabs,
batchContext
)
const nextGroupsByWorktree = withWorktreeEntry(
state,
'groupsByWorktree',
worktreeId,
nextGroups,
sameGroups,
batchContext
)
return {
...context,
nextTabsByWorktree,
nextBrowserTabsByWorktree,
placedUnifiedTabs,
nextUnifiedTabsByWorktree,
nextGroupsByWorktree,
mirroredTerminalIds,
retainedUnifiedTabs,
mirroredUnifiedTabs,
nextTabBarOrder
}
}
@@ -0,0 +1,237 @@
import type {
RuntimeMobileSessionBrowserTab,
RuntimeMobileSessionTabsResult
} from '../../../../shared/runtime-types'
import type { BrowserPage, BrowserWorkspace } from '../../../../shared/browser-workspace-types'
import type { Tab } from '../../../../shared/tab-types'
import type { WebSessionTabsSyncState, MirroredBrowserTab } from './state'
import { readBrowserClientHostId } from '../browser-client-host-identity'
import { peekWebSessionBrowserPlacementGroup } from '../web-session-browser-placement'
import { browserPageEqual } from './state-equality-tabs'
import { collectLayoutGroupIds } from './layout-helpers'
import { buildBrowserUnifiedTab } from './tab-builders'
import { isReadyBrowserTab } from './terminal-surfaces'
export function findBrowserWorkspaceForRemotePage(
state: WebSessionTabsSyncState,
worktreeId: string,
environmentId: string,
remotePageId: string
): { workspace: BrowserWorkspace; page: BrowserPage; unifiedTab: Tab | null } | null {
const workspaces = state.browserTabsByWorktree[worktreeId] ?? []
for (const workspace of workspaces) {
const pages = state.browserPagesByWorkspace[workspace.id] ?? []
for (const page of pages) {
const handle = state.remoteBrowserPageHandlesByPageId[page.id]
if (handle?.environmentId === environmentId && handle.remotePageId === remotePageId) {
return {
workspace,
page,
unifiedTab:
(state.unifiedTabsByWorktree[worktreeId] ?? []).find(
(tab) => tab.contentType === 'browser' && tab.entityId === workspace.id
) ?? null
}
}
}
}
return null
}
export function browserWorkspaceHasRemoteEnvironmentPage(
state: WebSessionTabsSyncState,
workspace: BrowserWorkspace,
environmentId: string
): boolean {
return (state.browserPagesByWorkspace[workspace.id] ?? []).some(
(page) => state.remoteBrowserPageHandlesByPageId[page.id]?.environmentId === environmentId
)
}
/** A page of this environment whose guest runs on this desktop, so it survives the host process. */
export function browserWorkspaceHasClientHostedEnvironmentPage(
state: WebSessionTabsSyncState,
workspace: BrowserWorkspace,
environmentId: string
): boolean {
return (state.browserPagesByWorkspace[workspace.id] ?? []).some((page) => {
const handle = state.remoteBrowserPageHandlesByPageId[page.id]
return (
handle?.environmentId === environmentId &&
(handle.placement?.kind === 'client' ||
handle.stagedClientHosted === true ||
handle.restoredClientHosted === true)
)
})
}
/**
* The host publishes `title || url || 'Browser'` (see the runtime's browser tab projection), so a
* page that has not produced a real title yet arrives as its own url — or as the bare default.
* Neither says anything the local row does not already say better, so while the page still sits at
* the url this client gave it, the local title stands and only a real navigation replaces it.
*
* Scope: the pre-adoption/staged window, every placement the host actually drives, and the mirror
* of a page some other client hosts. Rows whose guest is ours never reach here — see
* resolveMirroredBrowserPageContent.
*/
export function resolveMirroredBrowserTitle(
tab: RuntimeMobileSessionBrowserTab,
existingPage: BrowserPage | undefined
): string {
const published = tab.title.trim()
const publishedIsHostFallback =
published === '' || published === tab.url.trim() || published === 'Browser'
if (existingPage && publishedIsHostFallback && tab.url === existingPage.url) {
return existingPage.title
}
return published || 'Browser'
}
/**
* Whether the guest running this page is one of ours. `placement.kind === 'client'` says only that
* *some* client hosts it: a second desktop, or the web client that installs no page renderer at
* all, mirrors the same row with nothing of its own to see it with. The placement names the lease
* holder, so the comparison is exact, and it holds from the moment the host mints the placement —
* before our guest has attached — which is the window the title would otherwise flicker through.
*/
export function clientHostsMirroredBrowserPage(tab: RuntimeMobileSessionBrowserTab): boolean {
if (tab.placement?.kind !== 'client') {
return false
}
const hostClientId = readBrowserClientHostId()
return hostClientId !== null && tab.placement.browserHostClientId === hostClientId
}
/**
* A page hosted by this client lives in our own guest webview; the host only ever learns its
* content second-hand through a fire-and-forget metadata publish, so its snapshot is a stale echo
* at best and the registry's `'Browser'`/create-time defaults at worst. Once a local row exists it
* is the truth.
*
* The url is part of the payload and not just a symptom: rewinding it to the host's create-time
* value is what breaks the staged-title hold's url-equality arm on the following snapshot.
*
* Every other row keeps taking the host values — a client that hosts no guest has no second
* opinion to offer, and freezing its mirror would strand it on whatever its first snapshot said.
*/
export function resolveMirroredBrowserPageContent(
tab: RuntimeMobileSessionBrowserTab,
existingPage: BrowserPage | undefined
): Pick<BrowserPage, 'url' | 'title' | 'loading' | 'canGoBack' | 'canGoForward'> {
if (clientHostsMirroredBrowserPage(tab) && existingPage) {
return {
url: existingPage.url,
title: existingPage.title,
loading: existingPage.loading,
canGoBack: existingPage.canGoBack,
canGoForward: existingPage.canGoForward
}
}
return {
url: tab.url,
title: resolveMirroredBrowserTitle(tab, existingPage),
loading: tab.loading,
canGoBack: tab.canGoBack,
canGoForward: tab.canGoForward
}
}
export function buildMirroredBrowserTabs(
snapshot: RuntimeMobileSessionTabsResult,
environmentId: string,
state: WebSessionTabsSyncState,
hostGroupIdByTabId: ReadonlyMap<string, string>,
fallbackGroupId: string,
sortOffset: number,
now: number
): MirroredBrowserTab[] {
const renderedGroupIds = collectLayoutGroupIds(state.layoutByWorktree[snapshot.worktree])
const clientGroupIds = new Set(
(state.groupsByWorktree[snapshot.worktree] ?? []).map((group) => group.id)
)
return snapshot.tabs.filter(isReadyBrowserTab).map((tab, index) => {
const existing = findBrowserWorkspaceForRemotePage(
state,
snapshot.worktree,
environmentId,
tab.browserPageId
)
const workspaceId = existing?.workspace.id ?? tab.browserWorkspaceId
const pageId = existing?.page.id ?? tab.browserPageId
const createdAt = existing?.page.createdAt ?? now + sortOffset + index
const recordedClientGroupId = peekWebSessionBrowserPlacementGroup({
environmentId,
worktreeId: snapshot.worktree,
remotePageId: tab.browserPageId
})
const hostGroupId = hostGroupIdByTabId.get(tab.id) ?? fallbackGroupId
const existingClientGroupId =
existing?.unifiedTab?.groupId !== hostGroupId ? existing?.unifiedTab?.groupId : undefined
// Why: a staged row was placed by this client, so wherever it sits now is the user's own
// choice — including a split made after the create recorded its intent. Rows the client never
// staged carry no such truth: a pre-response snapshot may have parked them in the host group,
// and there the record is what repairs them.
const preferredClientGroupId =
existing && state.remoteBrowserPageHandlesByPageId[existing.page.id]?.staged === true
? (existing.unifiedTab?.groupId ?? recordedClientGroupId)
: (recordedClientGroupId ?? existingClientGroupId)
const clientGroupId =
preferredClientGroupId &&
clientGroupIds.has(preferredClientGroupId) &&
(renderedGroupIds.size === 0 || renderedGroupIds.has(preferredClientGroupId))
? preferredClientGroupId
: undefined
const groupId = clientGroupId ?? hostGroupId
const content = resolveMirroredBrowserPageContent(tab, existing?.page)
const nextPage: BrowserPage = {
id: pageId,
workspaceId,
worktreeId: snapshot.worktree,
...content,
faviconUrl: existing?.page.faviconUrl ?? null,
// Why: a client-hosted page's load failure is observed by the local guest webview and the
// host publishes no loadError for it, so the host snapshot must not clear the local record.
loadError:
(tab.placement?.kind === 'client' ? existing?.page.loadError : tab.loadError) ?? null,
createdAt,
browserRuntimeEnvironmentId: environmentId,
viewportPresetId: existing?.page.viewportPresetId ?? null
}
// Why: reuse hinges on browserPageEqual comparing workspaceId — the removed-workspace
// page-list cleanup gates on page.workspaceId matching this entry's workspace.id.
const page = existing && browserPageEqual(existing.page, nextPage) ? existing.page : nextPage
const workspace: BrowserWorkspace = {
id: workspaceId,
worktreeId: snapshot.worktree,
label: existing?.workspace.label,
sessionProfileId: existing?.workspace.sessionProfileId ?? null,
activePageId: page.id,
pageIds: [page.id],
url: page.url,
title: page.title,
loading: page.loading,
faviconUrl: page.faviconUrl,
canGoBack: page.canGoBack,
canGoForward: page.canGoForward,
loadError: page.loadError,
createdAt
}
return {
workspace,
page,
certificateFailure: tab.certificateFailure ?? null,
remotePageId: tab.browserPageId,
...(tab.placement ? { placement: tab.placement } : {}),
unifiedTab: buildBrowserUnifiedTab(
workspace,
tab,
existing?.unifiedTab ?? null,
groupId,
environmentId
),
hostTabId: tab.id,
...(clientGroupId ? { clientGroupId } : {})
}
})
}
@@ -0,0 +1,272 @@
import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope'
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import { isRuntimeSubscriptionReplayResponse } from '../../../../shared/runtime-subscription-replay'
import { useAppStore } from '../../store'
import { recoverWebSessionTerminalOrphansBeforeApply } from '../web-session-terminal-orphan-recovery'
import {
beginWebSessionTabsSnapshotRecovery,
recordReceivedWebSessionTabsInventory,
recordReceivedWebSessionTabsSnapshot,
shouldApplyRecoveredWebSessionTabsSnapshot
} from './tracking'
import {
decideWebSessionTabsSnapshot,
WEB_SESSION_TABS_FRAME_OUTRANKED
} from './tracking-decisions'
import { acceptReplayedWebSessionTabsSnapshot } from './tracking-lifecycle'
import { applyWebSessionTabsSnapshot, applyWebSessionTabsSnapshots } from './snapshot-api'
import {
latestSessionTabsSnapshotByWorktree,
replayableSessionTabsSnapshotByWorktree
} from './state'
import { applyWebSessionTabsStorePatch } from './store-patch'
import {
hostSessionMirrorSettleForPatchlessFrame,
type HostSessionMirrorSettle
} from './mirror-settle'
import { queueAcceptedWebSessionTerminalSnapshot } from '../web-session-terminal-handle-events'
import { isHostMirroredWorktree } from './visibility-types'
import type { VisibilityResumeCoordinator } from './visibility-resume-coordinator'
export type GlobalSessionEventArgs = {
environmentId: string
expectedEnvironmentConnectionGeneration: number
expectedEnvironmentPairingRevision?: number
expectedTrackingGeneration: number
visibilityGeneration: number
isCurrent: () => boolean
event: unknown
response: RuntimeRpcResponse<unknown>
awaitingVisibilityResumeInventory: { value: boolean }
coordinator: VisibilityResumeCoordinator
}
/** Apply one event from the all-worktrees stream, including visibility inventory fencing. */
export function handleGlobalSessionEvent(args: GlobalSessionEventArgs): void {
const {
environmentId,
expectedEnvironmentConnectionGeneration,
expectedEnvironmentPairingRevision,
expectedTrackingGeneration,
visibilityGeneration,
isCurrent,
response,
awaitingVisibilityResumeInventory,
coordinator
} = args
if (!isCurrent()) {
return
}
if (response.ok === false) {
console.warn('[web-session-tabs-sync] global subscription failed:', response.error.message)
return
}
const event = args.event as
| (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' })
| { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[]; authoritative?: boolean }
| { type: 'end' }
const replayed = isRuntimeSubscriptionReplayResponse(response)
if (event.type === 'snapshots') {
const skipUnchangedResumeWork = awaitingVisibilityResumeInventory.value && !replayed
awaitingVisibilityResumeInventory.value = false
const unchanged = event.snapshots.map((snapshot) => {
const key = `${environmentId}:${snapshot.worktree}`
const freshness = getLatestFreshness(key)
return Boolean(
skipUnchangedResumeWork &&
!hasReplayAllowance(key) &&
freshness?.publicationEpoch === snapshot.publicationEpoch &&
freshness.snapshotVersion === snapshot.snapshotVersion
)
})
const receivedFrames = event.snapshots.map((snapshot) => {
const frame = recordReceivedWebSessionTabsSnapshot(environmentId, snapshot)
coordinator.recordSnapshotReceipt(environmentId, snapshot, frame)
return frame
})
const inventoryFrame = recordReceivedWebSessionTabsInventory()
const missing = coordinator.recordInventoryReceipt(
environmentId,
visibilityGeneration,
inventoryFrame,
event.snapshots
)
const finishRecoveries = event.snapshots.map((snapshot, index) =>
unchanged[index]
? null
: beginWebSessionTabsSnapshotRecovery(
environmentId,
snapshot.worktree,
receivedFrames[index]!
)
)
let settleHydration: (() => void) | null = null
void Promise.all(
event.snapshots.map((snapshot, index) =>
unchanged[index]
? Promise.resolve(snapshot)
: recoverWebSessionTerminalOrphansBeforeApply(
useAppStore.getState(),
snapshot,
environmentId
)
)
)
.then((recovered) => {
if (!isCurrent()) {
return
}
const applicable = recovered.flatMap((snapshot, index) =>
snapshot !== null &&
shouldApplyRecoveredWebSessionTabsSnapshot(
environmentId,
snapshot,
receivedFrames[index]!
) &&
coordinator.shouldApplySnapshot(environmentId, snapshot, receivedFrames[index]!)
? [{ index, snapshot }]
: []
)
if (visibilityGeneration > 0 || replayed) {
for (const { index, snapshot } of applicable) {
if (!unchanged[index]) {
acceptReplayedWebSessionTabsSnapshot(environmentId, snapshot.worktree)
}
}
}
const decisions = applicable.map(({ index, snapshot }) =>
unchanged[index]
? WEB_SESSION_TABS_FRAME_OUTRANKED
: decideWebSessionTabsSnapshot(snapshot, environmentId)
)
const freshSnapshots = applicable.flatMap(({ snapshot }, index) =>
decisions[index]!.apply ? [snapshot] : []
)
settleHydration = applyWebSessionTabsStorePatch(
(state) => applyWebSessionTabsSnapshots(state, freshSnapshots, environmentId),
{
frames: applicable.map(({ snapshot }, index) => ({
environmentId,
worktreeId: snapshot.worktree,
decision: decisions[index]!,
expectedEnvironmentConnectionGeneration,
expectedEnvironmentPairingRevision,
expectedTrackingGeneration
})),
fullInventory: {
environmentId,
authoritative: event.authoritative === true,
expectedEnvironmentConnectionGeneration,
expectedEnvironmentPairingRevision,
expectedTrackingGeneration,
publishedSnapshotCount: event.snapshots.filter((snapshot) =>
isHostMirroredWorktree(snapshot.worktree)
).length
}
},
freshSnapshots
)
const freshSet = new Set(freshSnapshots)
for (const { index, snapshot } of applicable) {
if (unchanged[index]) {
queueAcceptedWebSessionTerminalSnapshot(snapshot, environmentId)
}
if (unchanged[index] || freshSet.has(snapshot)) {
coordinator.recordSnapshot(environmentId, snapshot, receivedFrames[index]!)
}
}
coordinator.recordInventory(environmentId, visibilityGeneration, inventoryFrame, missing)
})
.catch((error) => {
if (isCurrent()) {
console.warn('[web-session-tabs-sync] snapshot recovery failed:', error)
}
})
.finally(() => {
for (const finishRecovery of finishRecoveries) {
finishRecovery?.()
}
if (isCurrent()) {
settleHydration?.()
}
})
return
}
if (event.type !== 'snapshot' && event.type !== 'updated') {
return
}
const receivedFrame = recordReceivedWebSessionTabsSnapshot(environmentId, event)
coordinator.recordSnapshotReceipt(environmentId, event, receivedFrame)
const finishRecovery = beginWebSessionTabsSnapshotRecovery(
environmentId,
event.worktree,
receivedFrame
)
let settleHydration: HostSessionMirrorSettle | null = null
void recoverWebSessionTerminalOrphansBeforeApply(useAppStore.getState(), event, environmentId)
.then((recovered) => {
if (
!isCurrent() ||
!recovered ||
!shouldApplyRecoveredWebSessionTabsSnapshot(environmentId, recovered, receivedFrame) ||
!coordinator.shouldApplySnapshot(environmentId, recovered, receivedFrame)
) {
return
}
if (replayed) {
acceptReplayedWebSessionTabsSnapshot(environmentId, recovered.worktree)
}
const decision = decideWebSessionTabsSnapshot(recovered, environmentId)
if (decision.apply) {
settleHydration = applyWebSessionTabsStorePatch(
(state) => applyWebSessionTabsSnapshot(state, recovered, environmentId),
{
frames: [
{
environmentId,
worktreeId: recovered.worktree,
decision,
expectedEnvironmentConnectionGeneration,
expectedEnvironmentPairingRevision,
expectedTrackingGeneration
}
]
},
recovered,
event.type === 'updated' && !replayed
)
coordinator.recordSnapshot(environmentId, recovered, receivedFrame)
} else {
settleHydration = hostSessionMirrorSettleForPatchlessFrame(
decision,
environmentId,
recovered.worktree,
{
connectionGeneration: expectedEnvironmentConnectionGeneration,
pairingRevision: expectedEnvironmentPairingRevision,
trackingGeneration: expectedTrackingGeneration
}
)
}
})
.catch((error) => {
if (isCurrent()) {
console.warn('[web-session-tabs-sync] snapshot recovery failed:', error)
}
})
.finally(() => {
finishRecovery()
if (isCurrent()) {
settleHydration?.()
}
})
}
function getLatestFreshness(
key: string
): { publicationEpoch: string; snapshotVersion: number } | undefined {
return latestSessionTabsSnapshotByWorktree.get(key)
}
function hasReplayAllowance(key: string): boolean {
return replayableSessionTabsSnapshotByWorktree.has(key)
}
@@ -0,0 +1,211 @@
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import { getRuntimeEnvironmentRevision } from '../runtime-environment-revision'
import { clearWebSessionCloseIntentsForOwner } from '../web-session-close-intent'
import { clearWebSessionFocusIntentsForOwner } from '../web-session-focus-intent'
import { clearWebSessionReorderIntentsForOwner } from '../web-session-reorder-intent'
import {
installWindowVisibilitySubscriptionParking,
type WindowVisibilitySubscriptionSpec
} from '../window-visibility-subscription-parking'
import { shouldSyncAllRuntimeSessionTabs } from './tracking-decisions'
import {
getWebSessionTabsTrackingGeneration,
clearWebSessionTabsTrackingForEnvironment
} from './tracking-lifecycle'
import { loadInitialWebSessionTabs } from './load-initial'
import { handleGlobalSessionEvent } from './global-session-events'
import { VisibilityResumeCoordinator } from './visibility-resume-coordinator'
import type { VisibilityResumeOmission, SessionTabsStreamEvent } from './state'
import type { MirroredRuntimeEnvironment } from './visibility-resume-types'
import { WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS } from './state'
type Ref<T> = { current: T }
export type GlobalSubscriptionRefs = {
activeRuntimeEnvironmentId: Ref<string | null>
activeRuntimeWorktreeKey: Ref<string | null>
visibilityResumeOmissions: Ref<Map<string, VisibilityResumeOmission>>
snapshotReceipt: Ref<
(environmentId: string, snapshot: RuntimeMobileSessionTabsResult, receivedFrame: number) => void
>
snapshotApply: Ref<
(
environmentId: string,
snapshot: RuntimeMobileSessionTabsResult,
receivedFrame: number
) => boolean
>
ownerRevisions: Ref<Map<string, number | undefined>>
}
export type GlobalSubscriptionInstallArgs = {
runtimeSessionMirrorEnvironmentKey: string | null
workspaceSessionReady: boolean
refs: GlobalSubscriptionRefs
}
function parseEnvironments(value: string | null): MirroredRuntimeEnvironment[] {
if (!value) {
return []
}
return value
.split('\u0000')
.map((entry) => {
const [environmentId = '', , rawGeneration = '0', rawRevision = ''] = entry.split('\u0001')
return {
environmentId,
expectedEnvironmentConnectionGeneration: Number(rawGeneration),
expectedEnvironmentPairingRevision: rawRevision === '' ? undefined : Number(rawRevision)
}
})
.filter(({ environmentId }) => environmentId.trim())
}
/** Install all-worktree subscriptions and their visibility-resume coordinator. */
export function installGlobalSessionTabsSubscriptions({
runtimeSessionMirrorEnvironmentKey,
workspaceSessionReady,
refs
}: GlobalSubscriptionInstallArgs): (() => void) | undefined {
const environments = parseEnvironments(runtimeSessionMirrorEnvironmentKey)
const ownerRevisions = new Map(
(workspaceSessionReady ? environments : []).map(
({ environmentId, expectedEnvironmentPairingRevision }) =>
[environmentId, expectedEnvironmentPairingRevision] as const
)
)
const previousOwnerRevisions = refs.ownerRevisions.current
for (const [environmentId, previousRevision] of previousOwnerRevisions) {
if (
!ownerRevisions.has(environmentId) ||
ownerRevisions.get(environmentId) !== previousRevision
) {
clearWebSessionTabsTrackingForEnvironment(environmentId)
}
}
refs.ownerRevisions.current = ownerRevisions
for (const [key, omission] of refs.visibilityResumeOmissions.current) {
const previousRevision = previousOwnerRevisions.get(omission.environmentId)
if (
!ownerRevisions.has(omission.environmentId) ||
(previousOwnerRevisions.has(omission.environmentId) &&
ownerRevisions.get(omission.environmentId) !== previousRevision)
) {
refs.visibilityResumeOmissions.current.delete(key)
}
}
if (!workspaceSessionReady || environments.length === 0) {
return undefined
}
const subscriptionSpecs: WindowVisibilitySubscriptionSpec[] = []
const environmentIdBySpec: string[] = []
const coordinator = new VisibilityResumeCoordinator({
environments,
environmentIdBySubscriptionSpec: environmentIdBySpec,
omissions: refs.visibilityResumeOmissions.current,
activeRuntimeWorktreeKey: () => refs.activeRuntimeWorktreeKey.current
})
refs.snapshotReceipt.current = coordinator.recordSnapshotReceipt.bind(coordinator)
refs.snapshotApply.current = coordinator.shouldApplySnapshot.bind(coordinator)
for (const environment of environments) {
const {
environmentId,
expectedEnvironmentConnectionGeneration,
expectedEnvironmentPairingRevision
} = environment
if (
!shouldSyncAllRuntimeSessionTabs({
activeRuntimeEnvironmentId: environmentId,
workspaceSessionReady
})
) {
continue
}
let requestedInitialLoad = false
const expectedTrackingGeneration = getWebSessionTabsTrackingGeneration(environmentId)
environmentIdBySpec.push(environmentId)
subscriptionSpecs.push({
subscribe: (isCurrent, { visibilityGeneration }) => {
const awaitingVisibilityResumeInventory = { value: visibilityGeneration > 0 }
if (!requestedInitialLoad) {
requestedInitialLoad = true
loadInitialWebSessionTabs({
environmentId,
expectedEnvironmentConnectionGeneration,
expectedEnvironmentPairingRevision,
expectedTrackingGeneration,
isCurrent
})
}
return window.api.runtimeEnvironments.subscribe(
{
selector: environmentId,
method: 'session.tabs.subscribeAll',
params: {},
timeoutMs: 15_000,
expectedEnvironmentPairingRevision
},
{
onResponse: (response) => {
if (
!isCurrent() ||
getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision
) {
return
}
handleGlobalSessionEvent({
environmentId,
expectedEnvironmentConnectionGeneration,
expectedEnvironmentPairingRevision,
expectedTrackingGeneration,
visibilityGeneration,
isCurrent,
event: response.ok
? response.result
: ({ type: 'end' } satisfies SessionTabsStreamEvent),
response,
awaitingVisibilityResumeInventory,
coordinator
})
},
onError: (error) => {
if (isCurrent()) {
console.warn('[web-session-tabs-sync] global subscription error:', error.message)
}
}
}
)
},
onSubscribeError: (error) => {
console.warn(
'[web-session-tabs-sync] failed to subscribe globally:',
error instanceof Error ? error.message : String(error)
)
},
onUnsubscribeError: (error) => {
console.warn('[web-session-tabs-sync] failed to unsubscribe globally:', error)
}
})
}
const dispose = installWindowVisibilitySubscriptionParking(subscriptionSpecs, {
getVisibilityResumePriority: (index) =>
environmentIdBySpec[index] === refs.activeRuntimeEnvironmentId.current ? 0 : 1,
visibilityResumeStaggerMs: WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS,
onVisibilityResume: ({ visibilityGeneration, restartingSpecIndexes }) =>
coordinator.beginVisibilityResume(visibilityGeneration, restartingSpecIndexes)
})
return () => {
refs.snapshotReceipt.current = () => {}
refs.snapshotApply.current = () => true
dispose()
for (const { environmentId, expectedEnvironmentPairingRevision } of environments) {
const owner = { environmentId, pairingRevision: expectedEnvironmentPairingRevision }
clearWebSessionCloseIntentsForOwner(owner)
clearWebSessionFocusIntentsForOwner(owner)
clearWebSessionReorderIntentsForOwner(owner)
}
}
}
@@ -0,0 +1,217 @@
import type { RuntimeMobileSessionTabGroup } from '../../../../shared/runtime-types'
import type { TabGroup } from '../../../../shared/tab-types'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import type {
MirroredAgentTab,
MirroredBrowserTab,
MirroredEditorTab,
TerminalSurface
} from './state'
import { toWebTerminalSurfaceTabId } from '../web-runtime-session'
import { clearHostSessionTabIdMappings, setHostSessionTabIdMapping } from './tracking-mappings'
import { isWebSessionBrowserPlacementGroupReserved } from '../web-session-browser-placement'
import { resolveWebSessionReorderedOrder } from '../web-session-reorder-intent'
import { mapHostRecentTabIds } from './layout-helpers'
import { pushRecentTabId, sanitizeRecentTabIds } from './state-equality-core'
export function buildHostToLocalTabIdMap({
terminalSurfaces,
terminalTabs,
browserTabs,
editorTabs,
agentTabs
}: {
terminalSurfaces: readonly TerminalSurface[]
terminalTabs: readonly TerminalTab[]
browserTabs: readonly MirroredBrowserTab[]
editorTabs: readonly MirroredEditorTab[]
agentTabs: readonly MirroredAgentTab[]
}): Map<string, string> {
const hostToLocal = new Map<string, string>()
const terminalIds = new Set(terminalTabs.map((tab) => tab.id))
for (const surface of terminalSurfaces) {
const localId = toWebTerminalSurfaceTabId(surface.parentTabId)
if (terminalIds.has(localId)) {
hostToLocal.set(surface.parentTabId, localId)
hostToLocal.set(surface.id, localId)
}
}
for (const entry of browserTabs) {
hostToLocal.set(entry.hostTabId, entry.unifiedTab.id)
hostToLocal.set(entry.unifiedTab.id, entry.unifiedTab.id)
}
for (const entry of editorTabs) {
hostToLocal.set(entry.hostTabId, entry.unifiedTab.id)
}
for (const entry of agentTabs) {
hostToLocal.set(entry.hostTabId, entry.unifiedTab.id)
}
return hostToLocal
}
export function updateHostSessionTabIdMappings(args: {
environmentId: string
worktreeId: string
terminalSurfaces: readonly TerminalSurface[]
terminalTabs: readonly TerminalTab[]
browserTabs: readonly MirroredBrowserTab[]
editorTabs: readonly MirroredEditorTab[]
agentTabs: readonly MirroredAgentTab[]
}): void {
clearHostSessionTabIdMappings(args.environmentId, args.worktreeId)
const mirroredTerminalIds = new Set(args.terminalTabs.map((tab) => tab.id))
for (const surface of args.terminalSurfaces) {
const localId = toWebTerminalSurfaceTabId(surface.parentTabId)
if (mirroredTerminalIds.has(localId)) {
setHostSessionTabIdMapping({ ...args, tabId: localId }, surface.parentTabId)
}
}
for (const entry of args.browserTabs) {
setHostSessionTabIdMapping({ ...args, tabId: entry.unifiedTab.id }, entry.hostTabId)
}
for (const entry of args.editorTabs) {
setHostSessionTabIdMapping({ ...args, tabId: entry.unifiedTab.id }, entry.hostTabId)
}
for (const entry of args.agentTabs) {
setHostSessionTabIdMapping({ ...args, tabId: entry.unifiedTab.id }, entry.hostTabId)
}
}
export function retainClientPlacedMirroredTabs(args: {
groups: readonly TabGroup[]
mirroredUnifiedIds: ReadonlySet<string>
validUnifiedTabIds: ReadonlySet<string>
clientGroupIdByLocalTabId: ReadonlyMap<string, string>
nextActiveUnifiedTabId: string | null
}): TabGroup[] {
return args.groups.map((group) => {
const retainedTabOrder = group.tabOrder.filter(
(tabId) =>
args.validUnifiedTabIds.has(tabId) &&
(!args.mirroredUnifiedIds.has(tabId) ||
args.clientGroupIdByLocalTabId.get(tabId) === group.id)
)
const placedTabIds = [...args.clientGroupIdByLocalTabId]
.filter(
([tabId, groupId]) =>
groupId === group.id &&
args.validUnifiedTabIds.has(tabId) &&
!retainedTabOrder.includes(tabId)
)
.map(([tabId]) => tabId)
const tabOrder = [...retainedTabOrder, ...placedTabIds]
const activeTabId =
args.nextActiveUnifiedTabId && tabOrder.includes(args.nextActiveUnifiedTabId)
? args.nextActiveUnifiedTabId
: group.activeTabId && tabOrder.includes(group.activeTabId)
? group.activeTabId
: (tabOrder[0] ?? null)
return {
...group,
tabOrder,
activeTabId,
recentTabIds: activeTabId
? pushRecentTabId(sanitizeRecentTabIds(group.recentTabIds, tabOrder), activeTabId)
: []
}
})
}
export function buildMirroredHostGroups({
currentGroups,
hostGroups,
hostToLocalTabId,
mirroredUnifiedIds,
nextActiveUnifiedTabId,
now,
validUnifiedTabIds,
environmentId,
worktreeId,
clientGroupIdByLocalTabId
}: {
currentGroups: readonly TabGroup[]
hostGroups: readonly RuntimeMobileSessionTabGroup[]
hostToLocalTabId: ReadonlyMap<string, string>
mirroredUnifiedIds: ReadonlySet<string>
nextActiveUnifiedTabId: string | null
now: number
validUnifiedTabIds: ReadonlySet<string>
environmentId: string
worktreeId: string
clientGroupIdByLocalTabId: ReadonlyMap<string, string>
}): TabGroup[] | null {
const strippedGroups = retainClientPlacedMirroredTabs({
groups: currentGroups,
mirroredUnifiedIds,
validUnifiedTabIds,
clientGroupIdByLocalTabId,
nextActiveUnifiedTabId
})
const groupsById = new Map(strippedGroups.map((group) => [group.id, group]))
const orderedGroups: TabGroup[] = []
const seen = new Set<string>()
for (const hostGroup of hostGroups) {
const existing = groupsById.get(hostGroup.id)
const localHostOrder = hostGroup.tabOrder
.map((tabId) => hostToLocalTabId.get(tabId))
.filter(
(tabId): tabId is string =>
tabId !== undefined &&
validUnifiedTabIds.has(tabId) &&
!clientGroupIdByLocalTabId.has(tabId)
)
const localHostOrderIds = new Set(localHostOrder)
const hostTabOrder = [
...(existing?.tabOrder.filter((tabId) => !localHostOrderIds.has(tabId)) ?? []),
...localHostOrder
]
// Why: a pending client reorder wins over a stale pre-move host order until the host echoes the move (or membership changes).
const tabOrder = resolveWebSessionReorderedOrder(
{ environmentId },
worktreeId,
hostGroup.id,
hostTabOrder,
now
)
if (tabOrder.length === 0) {
continue
}
const activeFromHost =
hostGroup.activeTabId !== null ? (hostToLocalTabId.get(hostGroup.activeTabId) ?? null) : null
const activeTabId =
nextActiveUnifiedTabId && tabOrder.includes(nextActiveUnifiedTabId)
? nextActiveUnifiedTabId
: activeFromHost && tabOrder.includes(activeFromHost)
? activeFromHost
: existing?.activeTabId && tabOrder.includes(existing.activeTabId)
? existing.activeTabId
: (tabOrder[0] ?? null)
orderedGroups.push({
id: hostGroup.id,
worktreeId,
tabOrder,
activeTabId,
recentTabIds: activeTabId
? pushRecentTabId(
mapHostRecentTabIds(hostGroup.recentTabIds, hostToLocalTabId, tabOrder),
activeTabId
)
: []
})
seen.add(hostGroup.id)
}
for (const group of strippedGroups) {
if (
!seen.has(group.id) &&
(group.tabOrder.length > 0 ||
isWebSessionBrowserPlacementGroupReserved({ worktreeId, groupId: group.id }))
) {
orderedGroups.push(group)
}
}
return orderedGroups.length > 0 ? orderedGroups : null
}
@@ -0,0 +1,162 @@
import type {
RuntimeMobileSessionTabGroup,
RuntimeMobileSessionTabsResult
} from '../../../../shared/runtime-types'
import type { TabGroupLayoutNode } from '../../../../shared/tab-types'
import { WEB_SESSION_GROUP_PREFIX, type WebSessionTabsSyncState } from './state'
import { sanitizeRecentTabIds } from './state-equality-core'
export function chooseTargetGroupId(
state: WebSessionTabsSyncState,
snapshot: RuntimeMobileSessionTabsResult
): string {
const groups = state.groupsByWorktree[snapshot.worktree] ?? []
const layoutGroupIds = collectLayoutGroupIds(state.layoutByWorktree[snapshot.worktree])
const inRenderedLayout = (groupId: string | null | undefined): boolean =>
Boolean(groupId && (layoutGroupIds.size === 0 || layoutGroupIds.has(groupId)))
const preferred =
groups.find((group) => group.id === snapshot.activeGroupId && inRenderedLayout(group.id)) ??
groups.find(
(group) =>
group.id === state.activeGroupIdByWorktree[snapshot.worktree] && inRenderedLayout(group.id)
) ??
groups.find((group) => inRenderedLayout(group.id))
// Why: host snapshots can reference desktop-only group ids; the rendered group is the only safe CSS anchor for mirrored panes.
const firstRenderedLayoutGroupId = layoutGroupIds.values().next().value as string | undefined
return (
preferred?.id ??
firstRenderedLayoutGroupId ??
snapshot.activeGroupId ??
`${WEB_SESSION_GROUP_PREFIX}${snapshot.worktree}`
)
}
export function collectLayoutGroupIds(layout: TabGroupLayoutNode | undefined): Set<string> {
const result = new Set<string>()
const visit = (node: TabGroupLayoutNode | undefined): void => {
if (!node) {
return
}
if (node.type === 'leaf') {
result.add(node.groupId)
return
}
visit(node.first)
visit(node.second)
}
visit(layout)
return result
}
export function buildHostGroupIdByTabId(
hostGroups: readonly RuntimeMobileSessionTabGroup[] | undefined
): Map<string, string> {
const result = new Map<string, string>()
for (const group of hostGroups ?? []) {
for (const tabId of group.tabOrder) {
result.set(tabId, group.id)
}
if (group.activeTabId) {
result.set(group.activeTabId, group.id)
}
}
return result
}
export function pruneTabGroupLayout(
layout: TabGroupLayoutNode | null | undefined,
validGroupIds: ReadonlySet<string>
): TabGroupLayoutNode | null {
if (!layout) {
return null
}
if (layout.type === 'leaf') {
return validGroupIds.has(layout.groupId) ? layout : null
}
const first = pruneTabGroupLayout(layout.first, validGroupIds)
const second = pruneTabGroupLayout(layout.second, validGroupIds)
if (first && second) {
return { ...layout, first, second }
}
return first ?? second
}
export function dropTabGroupLayoutGroups(
layout: TabGroupLayoutNode | null,
excludedGroupIds: ReadonlySet<string>
): TabGroupLayoutNode | null {
if (!layout) {
return null
}
if (layout.type === 'leaf') {
return excludedGroupIds.has(layout.groupId) ? null : layout
}
const first = dropTabGroupLayoutGroups(layout.first, excludedGroupIds)
const second = dropTabGroupLayoutGroups(layout.second, excludedGroupIds)
if (first && second) {
return { ...layout, first, second }
}
return first ?? second
}
export function appendTabGroupLayout(
first: TabGroupLayoutNode | null,
second: TabGroupLayoutNode | null
): TabGroupLayoutNode | null {
if (!first) {
return second
}
if (!second) {
return first
}
// Why: a group already placed by `first` must not gain a second leaf — two
// leaves for one group render the same tab strip in two columns, and each
// later snapshot appends another copy.
const appended = dropTabGroupLayoutGroups(second, collectLayoutGroupIds(first))
if (!appended) {
return first
}
return {
type: 'split',
direction: 'horizontal',
first,
second: appended
}
}
export function tabGroupLayoutEqual(
a: TabGroupLayoutNode | null | undefined,
b: TabGroupLayoutNode | null | undefined
): boolean {
if (!a || !b) {
return !a && !b
}
if (a.type !== b.type) {
return false
}
if (a.type === 'leaf') {
return b.type === 'leaf' && a.groupId === b.groupId
}
return (
b.type === 'split' &&
a.direction === b.direction &&
a.ratio === b.ratio &&
tabGroupLayoutEqual(a.first, b.first) &&
tabGroupLayoutEqual(a.second, b.second)
)
}
export function mapHostRecentTabIds(
recentTabIds: readonly string[] | undefined,
hostToLocalTabId: ReadonlyMap<string, string>,
tabOrder: readonly string[]
): string[] {
if (!recentTabIds || recentTabIds.length === 0) {
return []
}
const valid = new Set(tabOrder)
return sanitizeRecentTabIds(
recentTabIds.map((tabId) => hostToLocalTabId.get(tabId) ?? '').filter(Boolean),
[...valid]
)
}
@@ -0,0 +1,143 @@
import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope'
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import { useAppStore } from '../../store'
import { getRuntimeEnvironmentRevision } from '../runtime-environment-revision'
import { recoverWebSessionTerminalOrphansBeforeApply } from '../web-session-terminal-orphan-recovery'
import {
beginWebSessionTabsSnapshotRecovery,
isSessionTabsListAllResult,
recordReceivedWebSessionTabsSnapshot,
shouldApplyRecoveredWebSessionTabsSnapshot
} from './tracking'
import { decideWebSessionTabsSnapshot } from './tracking-decisions'
import { applyWebSessionTabsSnapshots } from './snapshot-api'
import { applyWebSessionTabsStorePatch } from './store-patch'
import { isHostMirroredWorktree } from './visibility-types'
export type InitialSessionTabsLoadArgs = {
environmentId: string
expectedEnvironmentConnectionGeneration: number
expectedEnvironmentPairingRevision: number | undefined
expectedTrackingGeneration: number
isCurrent: () => boolean
}
/** Perform the one-shot inventory fetch used to seed the mirror deterministically. */
export function loadInitialWebSessionTabs({
environmentId,
expectedEnvironmentConnectionGeneration,
expectedEnvironmentPairingRevision,
expectedTrackingGeneration,
isCurrent
}: InitialSessionTabsLoadArgs): void {
let settleHydration: (() => void) | null = null
void window.api.runtimeEnvironments
.call({
selector: environmentId,
method: 'session.tabs.listAll',
params: {},
timeoutMs: 15_000,
expectedEnvironmentPairingRevision
})
.then(async (response: RuntimeRpcResponse<unknown>) => {
if (
!isCurrent() ||
getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision
) {
return
}
if (response.ok === false) {
console.warn('[web-session-tabs-sync] initial listAll failed:', response.error.message)
return
}
const result = response.result
if (!isSessionTabsListAllResult(result)) {
console.warn('[web-session-tabs-sync] initial listAll returned an invalid payload')
return
}
const receivedFrames = result.snapshots.map((snapshot) =>
recordReceivedWebSessionTabsSnapshot(environmentId, snapshot)
)
const finishRecoveries = result.snapshots.map((snapshot, index) =>
beginWebSessionTabsSnapshotRecovery(
environmentId,
snapshot.worktree,
receivedFrames[index]!
)
)
try {
const recovered = await Promise.all(
result.snapshots.map((snapshot) =>
recoverWebSessionTerminalOrphansBeforeApply(
useAppStore.getState(),
snapshot,
environmentId
)
)
)
if (
!isCurrent() ||
getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision
) {
return
}
const applicable = recovered.filter(
(snapshot, index): snapshot is RuntimeMobileSessionTabsResult =>
snapshot !== null &&
shouldApplyRecoveredWebSessionTabsSnapshot(
environmentId,
snapshot,
receivedFrames[index]!
)
)
const decisions = applicable.map((snapshot) =>
decideWebSessionTabsSnapshot(snapshot, environmentId)
)
const freshSnapshots = applicable.filter((_snapshot, index) => decisions[index]!.apply)
settleHydration = applyWebSessionTabsStorePatch(
(state) => applyWebSessionTabsSnapshots(state, freshSnapshots, environmentId),
{
frames: applicable.map((snapshot, index) => ({
environmentId,
worktreeId: snapshot.worktree,
decision: decisions[index]!,
expectedEnvironmentConnectionGeneration,
expectedEnvironmentPairingRevision,
expectedTrackingGeneration
})),
fullInventory: {
environmentId,
authoritative: result.authoritative === true,
expectedEnvironmentConnectionGeneration,
expectedEnvironmentPairingRevision,
expectedTrackingGeneration,
publishedSnapshotCount: result.snapshots.filter((snapshot) =>
isHostMirroredWorktree(snapshot.worktree)
).length
}
},
applicable
)
} finally {
for (const finishRecovery of finishRecoveries) {
finishRecovery()
}
}
})
.catch((error) => {
if (isCurrent()) {
console.warn(
'[web-session-tabs-sync] failed to load initial session tabs:',
error instanceof Error ? error.message : String(error)
)
}
})
.finally(() => {
if (
isCurrent() &&
getRuntimeEnvironmentRevision(environmentId) === expectedEnvironmentPairingRevision
) {
settleHydration?.()
}
})
}
@@ -0,0 +1,155 @@
import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status'
import { probeHostLiveTerminals } from '../host-live-terminal-probe'
import {
markHostSessionMirrorHydrated,
markHostSessionMirrorWorktreeHydrated
} from '../host-session-mirror-hydration'
import { getRuntimeEnvironmentRevision } from '../runtime-environment-revision'
import { getWebSessionTabsTrackingGeneration } from './tracking-lifecycle'
import type { WebSessionTabsSnapshotDecision } from './tracking-decisions'
/** Proof that the store patch committed and its host evidence may be settled. */
export type HostSessionMirrorSettle = () => void
export type HostSessionMirrorPatchFrame = {
environmentId: string
worktreeId: string
decision: WebSessionTabsSnapshotDecision
expectedEnvironmentConnectionGeneration?: number
expectedEnvironmentPairingRevision?: number
expectedTrackingGeneration?: number
}
export type HostSessionMirrorPatchVerdict = {
frames: readonly HostSessionMirrorPatchFrame[]
fullInventory?: {
environmentId: string
publishedSnapshotCount: number
authoritative?: boolean
expectedEnvironmentConnectionGeneration?: number
expectedEnvironmentPairingRevision?: number
expectedTrackingGeneration?: number
}
}
type HostSessionMirrorSettleFence = {
environmentId: string
connectionGeneration: number
pairingRevision?: number
trackingGeneration: number
}
function captureHostSessionMirrorSettleFence(
environmentId: string,
expected: {
connectionGeneration?: number
pairingRevision?: number
trackingGeneration?: number
} = {}
): HostSessionMirrorSettleFence {
return {
environmentId,
connectionGeneration:
expected.connectionGeneration ?? getRuntimeEnvironmentConnectionGeneration(environmentId),
pairingRevision: expected.pairingRevision ?? getRuntimeEnvironmentRevision(environmentId),
trackingGeneration:
expected.trackingGeneration ?? getWebSessionTabsTrackingGeneration(environmentId)
}
}
function hostSessionMirrorSettleFenceIsCurrent(fence: HostSessionMirrorSettleFence): boolean {
return (
getRuntimeEnvironmentConnectionGeneration(fence.environmentId) === fence.connectionGeneration &&
getRuntimeEnvironmentRevision(fence.environmentId) === fence.pairingRevision &&
getWebSessionTabsTrackingGeneration(fence.environmentId) === fence.trackingGeneration
)
}
function settleEmptyHostInventoryOnlyIfHostHasNoTerminals(
fence: HostSessionMirrorSettleFence
): void {
void probeHostLiveTerminals(
fence.environmentId,
undefined,
fence.connectionGeneration,
fence.pairingRevision
).then((verdict) => {
// A reconnect while probing invalidates the answer for this connection.
if (verdict === 'none' && hostSessionMirrorSettleFenceIsCurrent(fence)) {
markHostSessionMirrorHydrated(fence.environmentId)
}
})
}
export function createHostSessionMirrorSettle(
verdict: HostSessionMirrorPatchVerdict
): HostSessionMirrorSettle {
const fenceByEnvironment = new Map<string, HostSessionMirrorSettleFence>()
for (const frame of verdict.frames) {
fenceByEnvironment.set(
frame.environmentId,
captureHostSessionMirrorSettleFence(frame.environmentId, {
connectionGeneration: frame.expectedEnvironmentConnectionGeneration,
pairingRevision: frame.expectedEnvironmentPairingRevision,
trackingGeneration: frame.expectedTrackingGeneration
})
)
}
if (verdict.fullInventory) {
fenceByEnvironment.set(
verdict.fullInventory.environmentId,
captureHostSessionMirrorSettleFence(verdict.fullInventory.environmentId, {
connectionGeneration: verdict.fullInventory.expectedEnvironmentConnectionGeneration,
pairingRevision: verdict.fullInventory.expectedEnvironmentPairingRevision,
trackingGeneration: verdict.fullInventory.expectedTrackingGeneration
})
)
}
return () => {
const { frames, fullInventory } = verdict
const settles = frames.filter(({ decision }) => decision.settlesHostMirror)
if (fullInventory && settles.length === fullInventory.publishedSnapshotCount) {
const fence = fenceByEnvironment.get(fullInventory.environmentId)
if (!fence || !hostSessionMirrorSettleFenceIsCurrent(fence)) {
return
}
if (fullInventory.publishedSnapshotCount === 0) {
if (fullInventory.authoritative) {
markHostSessionMirrorHydrated(fullInventory.environmentId)
} else {
settleEmptyHostInventoryOnlyIfHostHasNoTerminals(fence)
}
return
}
markHostSessionMirrorHydrated(fullInventory.environmentId)
return
}
for (const { environmentId, worktreeId } of settles) {
const fence = fenceByEnvironment.get(environmentId)
if (fence && hostSessionMirrorSettleFenceIsCurrent(fence)) {
markHostSessionMirrorWorktreeHydrated(environmentId, worktreeId)
}
}
}
}
export function hostSessionMirrorSettleForPatchlessFrame(
decision: WebSessionTabsSnapshotDecision,
environmentId: string,
worktreeId: string,
expected: {
connectionGeneration?: number
pairingRevision?: number
trackingGeneration?: number
} = {}
): HostSessionMirrorSettle | null {
if (!decision.settlesHostMirror) {
return null
}
const fence = captureHostSessionMirrorSettleFence(environmentId, expected)
return () => {
if (hostSessionMirrorSettleFenceIsCurrent(fence)) {
markHostSessionMirrorWorktreeHydrated(environmentId, worktreeId)
}
}
}
@@ -0,0 +1,125 @@
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import type {
WebSessionTabsBatchContext,
WebSessionTabsSnapshotApplyOptions,
WebSessionTabsSyncState
} from './state'
import { applyWebSessionTabsSnapshotWithContext } from './apply-snapshot'
import {
decideWebSessionTabsSnapshot,
shouldApplyWebSessionTabsSnapshot,
type WebSessionTabsSnapshotDecision
} from './tracking-decisions'
export function applyWebSessionTabsSnapshot(
state: WebSessionTabsSyncState,
snapshot: RuntimeMobileSessionTabsResult,
environmentId: string,
now = Date.now(),
options?: WebSessionTabsSnapshotApplyOptions
): WebSessionTabsSyncState | Partial<WebSessionTabsSyncState> {
return applyWebSessionTabsSnapshotWithContext(
state,
snapshot,
environmentId,
now,
undefined,
options
)
}
export function applyWebSessionTabsSnapshots(
state: WebSessionTabsSyncState,
snapshots: readonly RuntimeMobileSessionTabsResult[],
environmentId: string,
now = Date.now()
): WebSessionTabsSyncState | Partial<WebSessionTabsSyncState> {
const nextState = { ...state }
const batchContext: WebSessionTabsBatchContext = {
agentPaneKeysByTabId: null,
changedRecords: new Set(),
openFilesIndex: null
}
let mergedPatch: Partial<WebSessionTabsSyncState> = {}
for (const snapshot of snapshots) {
const patch = applyWebSessionTabsSnapshotWithContext(
nextState,
snapshot,
environmentId,
now,
batchContext
)
if (patch === nextState) {
continue
}
mergedPatch = { ...mergedPatch, ...patch }
Object.assign(nextState, patch)
}
const mutableMergedPatch = mergedPatch as Record<string, unknown>
const mutableNextState = nextState as unknown as Record<string, unknown>
for (const recordKey of batchContext.changedRecords) {
mutableMergedPatch[recordKey] = mutableNextState[recordKey]
}
return Object.keys(mergedPatch).length === 0 ? state : mergedPatch
}
export function applyFreshWebSessionTabsSnapshot(
state: WebSessionTabsSyncState,
snapshot: RuntimeMobileSessionTabsResult,
environmentId: string,
now = Date.now()
): WebSessionTabsSyncState | Partial<WebSessionTabsSyncState> {
return shouldApplyWebSessionTabsSnapshot(snapshot, environmentId)
? applyWebSessionTabsSnapshot(state, snapshot, environmentId, now)
: state
}
export function applyFreshWebSessionTabsSnapshots(
state: WebSessionTabsSyncState,
snapshots: readonly RuntimeMobileSessionTabsResult[],
environmentId: string,
now = Date.now()
): WebSessionTabsSyncState | Partial<WebSessionTabsSyncState> {
const fresh = snapshots.filter((snapshot) =>
shouldApplyWebSessionTabsSnapshot(snapshot, environmentId)
)
return fresh.length === 0 ? state : applyWebSessionTabsSnapshots(state, fresh, environmentId, now)
}
export type WebSessionTabsSnapshotOperation = {
environmentId: string
snapshot: RuntimeMobileSessionTabsResult
}
export type DecidedWebSessionTabsSnapshotOperation = WebSessionTabsSnapshotOperation & {
decision: WebSessionTabsSnapshotDecision
}
export function decideWebSessionTabsSnapshotOperations(
operations: readonly WebSessionTabsSnapshotOperation[]
): DecidedWebSessionTabsSnapshotOperation[] {
return operations.map((operation) => ({
...operation,
decision: decideWebSessionTabsSnapshot(operation.snapshot, operation.environmentId)
}))
}
export function applyWebSessionTabsSnapshotOperations(
state: WebSessionTabsSyncState,
operations: readonly DecidedWebSessionTabsSnapshotOperation[]
): WebSessionTabsSyncState | Partial<WebSessionTabsSyncState> {
let nextState = state
let mergedPatch: Partial<WebSessionTabsSyncState> = {}
for (const { environmentId, snapshot, decision } of operations) {
if (!decision.apply) {
continue
}
const patch = applyWebSessionTabsSnapshot(nextState, snapshot, environmentId)
if (patch === nextState) {
continue
}
mergedPatch = { ...mergedPatch, ...patch }
nextState = { ...nextState, ...patch }
}
return Object.keys(mergedPatch).length === 0 ? state : mergedPatch
}
@@ -0,0 +1,154 @@
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume'
import type {
WebSessionTabsBatchContext,
WebSessionTabsBatchRecordKey,
WebSessionTabsSyncState
} from './state'
export function sameStringArray(a: readonly string[], b: readonly string[]): boolean {
if (a.length !== b.length) {
return false
}
return a.every((value, index) => value === b[index])
}
export function sameAgentStateHistory(
a: AgentStatusEntry['stateHistory'],
b: AgentStatusEntry['stateHistory']
): boolean {
if (a.length !== b.length) {
return false
}
return a.every(
(entry, index) =>
entry.state === b[index]?.state &&
entry.prompt === b[index]?.prompt &&
entry.startedAt === b[index]?.startedAt &&
entry.interrupted === b[index]?.interrupted
)
}
export function agentStatusEntryEqual(
a: AgentStatusEntry | undefined,
b: AgentStatusEntry
): boolean {
if (!a) {
return false
}
return (
a.state === b.state &&
a.workingMode === b.workingMode &&
a.prompt === b.prompt &&
a.updatedAt === b.updatedAt &&
a.stateStartedAt === b.stateStartedAt &&
a.agentType === b.agentType &&
a.paneKey === b.paneKey &&
a.worktreeId === b.worktreeId &&
a.tabId === b.tabId &&
a.terminalTitle === b.terminalTitle &&
a.toolName === b.toolName &&
a.toolInput === b.toolInput &&
a.interactivePrompt === b.interactivePrompt &&
a.lastAssistantMessage === b.lastAssistantMessage &&
a.interrupted === b.interrupted &&
a.promptInteractionKey === b.promptInteractionKey &&
a.restoredUnconfirmed === b.restoredUnconfirmed &&
agentProviderSessionsEqual(a.agentType, a.providerSession, b.providerSession) &&
sameAgentStateHistory(a.stateHistory, b.stateHistory)
)
}
export function isAgentStatusFresh(
entry: Pick<AgentStatusEntry, 'updatedAt' | 'restoredUnconfirmed'>,
now: number
): boolean {
return entry.restoredUnconfirmed !== true && now - entry.updatedAt <= AGENT_STATUS_STALE_AFTER_MS
}
export function isMirroredCommandCodeTurnBump(
existing: AgentStatusEntry | undefined,
entry: AgentStatusEntry
): boolean {
return (
existing?.agentType === 'command-code' &&
entry.agentType === 'command-code' &&
existing.state === 'working' &&
entry.state === 'working' &&
entry.stateStartedAt > existing.stateStartedAt
)
}
export function sanitizeRecentTabIds(recent: string[] | undefined, tabOrder: string[]): string[] {
if (!recent || recent.length === 0) {
return []
}
const valid = new Set(tabOrder)
const seen = new Set<string>()
const reversed: string[] = []
for (let i = recent.length - 1; i >= 0; i -= 1) {
const id = recent[i]
if (!valid.has(id) || seen.has(id)) {
continue
}
seen.add(id)
reversed.push(id)
}
return reversed.toReversed()
}
export function pushRecentTabId(recent: string[] | undefined, tabId: string): string[] {
const base = recent ?? []
if (base.length > 0 && base.at(-1) === tabId) {
return base
}
return [...base.filter((id) => id !== tabId), tabId]
}
export function writableWebSessionTabsRecord<K extends WebSessionTabsBatchRecordKey>(
state: WebSessionTabsSyncState,
recordKey: K,
batchContext?: WebSessionTabsBatchContext
): NonNullable<WebSessionTabsSyncState[K]> {
const record = (state[recordKey] ?? {}) as NonNullable<WebSessionTabsSyncState[K]>
if (!batchContext) {
return { ...record } as NonNullable<WebSessionTabsSyncState[K]>
}
// Why: one batch owns its record copies, so later snapshots can update them without recopying every workspace.
if (batchContext.changedRecords.has(recordKey)) {
return record
}
const next = { ...record } as NonNullable<WebSessionTabsSyncState[K]>
const mutableState = state as unknown as Record<
WebSessionTabsBatchRecordKey,
Record<string, unknown>
>
mutableState[recordKey] = next as Record<string, unknown>
batchContext.changedRecords.add(recordKey)
return next
}
export function withWorktreeEntry<T>(
state: WebSessionTabsSyncState,
recordKey: WebSessionTabsBatchRecordKey,
key: string,
value: T | null,
equal: (a: T | undefined, b: T | null) => boolean,
batchContext?: WebSessionTabsBatchContext,
deleteNull = true
): Record<string, T> {
const record = (state[recordKey] ?? {}) as Record<string, T>
if (equal(record[key], value)) {
return record
}
const next = writableWebSessionTabsRecord(state, recordKey, batchContext) as Record<string, T>
if (value === null && deleteNull) {
delete next[key]
} else {
next[key] = value as T
}
return next
}
@@ -0,0 +1,154 @@
import type { Tab, TabGroup } from '../../../../shared/tab-types'
import type { OpenFile } from '../../store/slices/editor'
import type { WebSessionTabsBatchContext, WebSessionTabsSyncState } from './state'
import { sameStringArray } from './state-equality-core'
export function openFileEqual(a: OpenFile, b: OpenFile): boolean {
return (
a.id === b.id &&
a.filePath === b.filePath &&
a.relativePath === b.relativePath &&
a.worktreeId === b.worktreeId &&
a.language === b.language &&
a.isDirty === b.isDirty &&
a.runtimeEnvironmentId === b.runtimeEnvironmentId &&
a.markdownPreviewSourceFileId === b.markdownPreviewSourceFileId &&
a.markdownPreviewAnchor === b.markdownPreviewAnchor &&
a.isPreview === b.isPreview &&
a.isUntitled === b.isUntitled &&
a.deleteUntouchedOnClose === b.deleteUntouchedOnClose &&
a.externalMutation === b.externalMutation &&
a.mirroredFromRuntimeSession === b.mirroredFromRuntimeSession &&
a.mode === b.mode
)
}
export function sameOpenFiles(a: readonly OpenFile[], b: readonly OpenFile[]): boolean {
if (a.length !== b.length) {
return false
}
return a.every((file, index) => openFileEqual(file, b[index]!))
}
/** This worktree's open files — the only scope a snapshot reconciles, so a batch can
* answer from here instead of walking every open file in the app. */
export function webSessionOpenFilesForWorktree(
state: WebSessionTabsSyncState,
worktreeId: string,
batchContext?: WebSessionTabsBatchContext
): readonly OpenFile[] {
if (!batchContext) {
return state.openFiles.filter((file) => file.worktreeId === worktreeId)
}
let index = batchContext.openFilesIndex
if (!index || index.source !== state.openFiles) {
const byWorktree = new Map<string, OpenFile[]>()
for (const file of state.openFiles) {
const bucket = byWorktree.get(file.worktreeId) ?? []
bucket.push(file)
byWorktree.set(file.worktreeId, bucket)
}
index = { source: state.openFiles, byWorktree }
batchContext.openFilesIndex = index
}
return index.byWorktree.get(worktreeId) ?? []
}
/** Retargets the index at the array a snapshot just produced, re-bucketing only the
* worktree that changed. Rebuilding it wholesale would cost the entire array again on
* every snapshot, which is the cost this index exists to avoid. */
export function advanceWebSessionOpenFilesIndex(
batchContext: WebSessionTabsBatchContext | undefined,
nextOpenFiles: readonly OpenFile[],
worktreeId: string
): void {
const index = batchContext?.openFilesIndex
if (!index || index.source === nextOpenFiles) {
return
}
const bucket: OpenFile[] = []
for (const file of nextOpenFiles) {
if (file.worktreeId === worktreeId) {
bucket.push(file)
}
}
index.byWorktree.set(worktreeId, bucket)
index.source = nextOpenFiles
}
/** Mirrors `openFiles.find()` first-wins lookup, which duplicate ids make observable. */
export function firstOpenFileByIdForWorktree(files: readonly OpenFile[]): Map<string, OpenFile> {
const byId = new Map<string, OpenFile>()
for (const file of files) {
if (!byId.has(file.id)) {
byId.set(file.id, file)
}
}
return byId
}
export function tabEqual(a: Tab, b: Tab): boolean {
return (
a.id === b.id &&
a.entityId === b.entityId &&
a.groupId === b.groupId &&
a.worktreeId === b.worktreeId &&
a.executionHostId === b.executionHostId &&
a.contentType === b.contentType &&
a.agentSessionAgent === b.agentSessionAgent &&
a.label === b.label &&
// Why: the generated label is the visible tab title; ignoring it let the
// equality bail keep a unified tab that disagreed with its terminal tab.
a.generatedLabel === b.generatedLabel &&
a.aiVaultTitle?.agent === b.aiVaultTitle?.agent &&
a.aiVaultTitle?.sessionId === b.aiVaultTitle?.sessionId &&
a.aiVaultTitle?.title === b.aiVaultTitle?.title &&
a.customLabel === b.customLabel &&
a.color === b.color &&
a.sortOrder === b.sortOrder &&
a.createdAt === b.createdAt &&
a.isPreview === b.isPreview &&
a.isPinned === b.isPinned
)
}
export function sameUnifiedTabs(a: readonly Tab[] | undefined, b: readonly Tab[] | null): boolean {
const left = a ?? []
const right = b ?? []
if (left.length !== right.length) {
return false
}
return left.every((tab, index) => tabEqual(tab, right[index]!))
}
export function groupEqual(a: TabGroup, b: TabGroup): boolean {
return (
a.id === b.id &&
a.worktreeId === b.worktreeId &&
a.activeTabId === b.activeTabId &&
sameStringArray(a.tabOrder, b.tabOrder) &&
sameStringArray(a.recentTabIds ?? [], b.recentTabIds ?? [])
)
}
export function sameGroups(
a: readonly TabGroup[] | undefined,
b: readonly TabGroup[] | null
): boolean {
const left = a ?? []
const right = b ?? []
if (left.length !== right.length) {
return false
}
return left.every((group, index) => groupEqual(group, right[index]!))
}
export function toVisibleTabType(tab: Tab): WebSessionTabsSyncState['activeTabType'] {
if (tab.contentType === 'agent-session') {
return 'agent-session'
}
if (tab.contentType === 'browser' || tab.contentType === 'terminal') {
return tab.contentType
}
return 'editor'
}
@@ -0,0 +1,142 @@
import type {
BrowserCertificateFailure,
BrowserPage,
BrowserWorkspace
} from '../../../../shared/browser-workspace-types'
import type { RuntimeBrowserPlacement } from '../../../../shared/runtime-browser-placement'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import { sameRuntimeBrowserPlacement } from '../../../../shared/runtime-browser-placement'
import { sameStringArray } from './state-equality-core'
export function terminalTabEqual(a: TerminalTab, b: TerminalTab): boolean {
return (
a.id === b.id &&
a.ptyId === b.ptyId &&
a.worktreeId === b.worktreeId &&
a.title === b.title &&
a.defaultTitle === b.defaultTitle &&
a.quickCommandLabel === b.quickCommandLabel &&
a.startupCwd === b.startupCwd &&
a.generatedTitle === b.generatedTitle &&
a.aiVaultTitle?.agent === b.aiVaultTitle?.agent &&
a.aiVaultTitle?.sessionId === b.aiVaultTitle?.sessionId &&
a.aiVaultTitle?.title === b.aiVaultTitle?.title &&
a.customTitle === b.customTitle &&
a.color === b.color &&
a.sortOrder === b.sortOrder &&
a.createdAt === b.createdAt &&
a.generation === b.generation &&
a.shellOverride === b.shellOverride &&
a.launchAgent === b.launchAgent &&
a.pendingActivationSpawn === b.pendingActivationSpawn
)
}
export function sameTerminalTabs(
a: readonly TerminalTab[] | undefined,
b: readonly TerminalTab[] | null
): boolean {
const left = a ?? []
const right = b ?? []
if (left.length !== right.length) {
return false
}
return left.every((tab, index) => terminalTabEqual(tab, right[index]!))
}
export function browserPageEqual(a: BrowserPage, b: BrowserPage): boolean {
return (
a.id === b.id &&
a.workspaceId === b.workspaceId &&
a.worktreeId === b.worktreeId &&
a.url === b.url &&
a.title === b.title &&
a.loading === b.loading &&
a.faviconUrl === b.faviconUrl &&
a.canGoBack === b.canGoBack &&
a.canGoForward === b.canGoForward &&
a.loadError?.code === b.loadError?.code &&
a.loadError?.description === b.loadError?.description &&
a.loadError?.validatedUrl === b.loadError?.validatedUrl &&
a.createdAt === b.createdAt &&
a.browserRuntimeEnvironmentId === b.browserRuntimeEnvironmentId &&
a.viewportPresetId === b.viewportPresetId
)
}
export function optionalRuntimeBrowserPlacementsEqual(
left: RuntimeBrowserPlacement | undefined,
right: RuntimeBrowserPlacement | undefined
): boolean {
return left === undefined
? right === undefined
: right !== undefined && sameRuntimeBrowserPlacement(left, right)
}
export function browserCertificateFailureEqual(
a: BrowserCertificateFailure | null | undefined,
b: BrowserCertificateFailure | null | undefined
): boolean {
const left = a ?? null
const right = b ?? null
if (left === right) {
return true
}
return Boolean(
left &&
right &&
left.challengeId === right.challengeId &&
left.browserPageId === right.browserPageId &&
left.errorCode === right.errorCode &&
left.error === right.error &&
left.origin === right.origin &&
left.displayHost === right.displayHost &&
left.canProceed === right.canProceed &&
left.observedAt === right.observedAt
)
}
export function sameBrowserPages(
a: readonly BrowserPage[] | undefined,
b: readonly BrowserPage[] | null
): boolean {
const left = a ?? []
const right = b ?? []
if (left.length !== right.length) {
return false
}
return left.every((page, index) => browserPageEqual(page, right[index]!))
}
export function browserWorkspaceEqual(a: BrowserWorkspace, b: BrowserWorkspace): boolean {
return (
a.id === b.id &&
a.worktreeId === b.worktreeId &&
a.label === b.label &&
a.sessionProfileId === b.sessionProfileId &&
a.activePageId === b.activePageId &&
sameStringArray(a.pageIds ?? [], b.pageIds ?? []) &&
a.url === b.url &&
a.title === b.title &&
a.loading === b.loading &&
a.faviconUrl === b.faviconUrl &&
a.canGoBack === b.canGoBack &&
a.canGoForward === b.canGoForward &&
a.loadError?.code === b.loadError?.code &&
a.loadError?.description === b.loadError?.description &&
a.loadError?.validatedUrl === b.loadError?.validatedUrl &&
a.createdAt === b.createdAt
)
}
export function sameBrowserTabs(
a: readonly BrowserWorkspace[] | undefined,
b: readonly BrowserWorkspace[] | null
): boolean {
const left = a ?? []
const right = b ?? []
if (left.length !== right.length) {
return false
}
return left.every((tab, index) => browserWorkspaceEqual(tab, right[index]!))
}
@@ -0,0 +1,209 @@
import type { AppState } from '../../store'
import type {
RuntimeMobileSessionAgentTab,
RuntimeMobileSessionBrowserTab,
RuntimeMobileSessionFileTab,
RuntimeMobileSessionMarkdownTab,
RuntimeMobileSessionTabGroup,
RuntimeMobileSessionTabsRemovedResult,
RuntimeMobileSessionTabsResult,
RuntimeMobileSessionTerminalClientTab
} from '../../../../shared/runtime-types'
import type {
BrowserCertificateFailure,
BrowserPage,
BrowserWorkspace
} from '../../../../shared/browser-workspace-types'
import type { Tab } from '../../../../shared/tab-types'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
import type { OpenFile } from '../../store/slices/editor'
import type { RuntimeBrowserPlacement } from '../../../../shared/runtime-browser-placement'
export const WEB_SESSION_GROUP_PREFIX = 'web-session-tabs:'
export const WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS = 100
export const VISIBILITY_INVENTORY_REMOVAL_EPOCH = 'visibility-inventory-removal'
export const HOST_WORKING_CLIENT_BOUNDARY_LIMIT = 512
export type SessionTabsStreamEvent =
| (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' })
| { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[]; authoritative?: boolean }
| { type: 'end' }
export type SessionTabsListAllResult = {
snapshots: RuntimeMobileSessionTabsResult[]
authoritative?: boolean
}
export type SnapshotFreshness = {
publicationEpoch: string
snapshotVersion: number
}
export type ReceivedSessionTabsSnapshot = SnapshotFreshness & { receivedFrame: number }
export type SessionTabsRecoveryState = { pendingCount: number }
export type SessionTabsRemovalFence = {
receivedFrame: number
recoveryState: SessionTabsRecoveryState
pendingCount: number
}
export type WebSessionTabsSnapshotApplyOptions = {
contentScope?: 'all' | 'agent-session'
preserveLocalLayout?: boolean
terminalPtyMode?: 'local' | 'remote'
}
export type TrackedWebSessionTabsWorktree = {
worktree: string
freshness: SnapshotFreshness
}
export type VisibilityResumeOmission = {
baseline: SnapshotFreshness
environmentId: string
inventoryReceivedFrame: number
superseded: boolean
visibilityGeneration: number
}
export const latestSessionTabsSnapshotByWorktree = new Map<string, SnapshotFreshness>()
export const replayableSessionTabsSnapshotByWorktree = new Map<string, SnapshotFreshness>()
export const latestReceivedSessionTabsSnapshotByWorktree = new Map<
string,
ReceivedSessionTabsSnapshot
>()
export const latestSessionTabsRemovalFenceByWorktree = new Map<string, SessionTabsRemovalFence>()
export const sessionTabsRecoveryStateByWorktree = new Map<string, SessionTabsRecoveryState>()
export const trackedSessionTabsWorktreeIdsByEnvironment = new Map<string, Set<string>>()
export const sessionTabsEnvironmentsByWorktree = new Map<string, Set<string>>()
export const sessionTabsTrackingGenerationByEnvironment = new Map<string, number>()
export const lastHostTerminalTabCountByWorktree = new Map<string, number>()
export const hostSessionTabIdByLocalKey = new Map<string, string>()
export const hostSessionTabMappingKeysByEnvironmentAndWorktree = new Map<
string,
Map<string, Set<string>>
>()
export const hostWorkingClientBoundaryByPaneKey = new Map<
string,
{
hostStateStartedAt: number
hostPrompt: string
clientStateStartedAt: number
stamped: boolean
}
>()
export let receivedSessionTabsFrameSequence = 0
export function nextReceivedSessionTabsFrame(): number {
receivedSessionTabsFrameSequence += 1
return receivedSessionTabsFrameSequence
}
export function resetReceivedSessionTabsFrameSequence(): void {
receivedSessionTabsFrameSequence = 0
}
export type TerminalSurface = RuntimeMobileSessionTerminalClientTab
export type ReadyTerminalSurface = RuntimeMobileSessionTerminalClientTab & { status: 'ready' }
export type ReadyBrowserSurface = RuntimeMobileSessionBrowserTab & { browserPageId: string }
export type ReadyEditorSurface = RuntimeMobileSessionMarkdownTab | RuntimeMobileSessionFileTab
export type MirroredAgentTab = { hostTabId: string; unifiedTab: Tab }
export type MirroredTerminalTab = {
tab: TerminalTab
hostTabId: string
ptyIds: string[]
layout: TerminalLayoutSnapshot
retainedSurfaceByPrunedLeafId?: ReadonlyMap<string, TerminalSurface>
}
export type MirroredBrowserTab = {
workspace: BrowserWorkspace
page: BrowserPage
certificateFailure: BrowserCertificateFailure | null
remotePageId: string
placement?: RuntimeBrowserPlacement
unifiedTab: Tab
hostTabId: string
clientGroupId?: string
}
export type MirroredEditorTab = { file: OpenFile; unifiedTab: Tab; hostTabId: string }
export type WebSessionTabsSyncState = Pick<
AppState,
| 'activeBrowserTabId'
| 'activeBrowserTabIdByWorktree'
| 'activeGroupIdByWorktree'
| 'activeFileId'
| 'activeFileIdByWorktree'
| 'activeTabId'
| 'activeTabIdByWorktree'
| 'activeTabType'
| 'activeTabTypeByWorktree'
| 'activeWorktreeId'
| 'agentStatusByPaneKey'
| 'agentStatusEpoch'
| 'browserPagesByWorkspace'
| 'browserCertificateFailuresByPageId'
| 'browserTabsByWorktree'
| 'groupsByWorktree'
| 'layoutByWorktree'
| 'openFiles'
| 'ptyIdsByTabId'
| 'remoteBrowserPageHandlesByPageId'
| 'tabBarOrderByWorktree'
| 'tabsByWorktree'
| 'terminalLayoutsByTabId'
| 'unifiedTabsByWorktree'
| 'unreadTerminalTabs'
| 'sortEpoch'
> &
Partial<
Pick<
AppState,
| 'acknowledgedAgentsByPaneKey'
| 'agentLaunchConfigByPaneKey'
| 'automaticAgentResumeClaimsByTabId'
| 'migrationUnsupportedByPtyId'
| 'paneForegroundAgentByPaneKey'
| 'pendingStartupByTabId'
| 'recentlyClosedAgentStatusTabIds'
| 'recentlyRetiredAgentStatusPaneKeys'
| 'retainedAgentsByPaneKey'
| 'retentionSuppressedPaneKeys'
>
>
export type WebSessionTabsBatchRecordKey =
| 'activeBrowserTabIdByWorktree'
| 'activeFileIdByWorktree'
| 'activeGroupIdByWorktree'
| 'activeTabIdByWorktree'
| 'activeTabTypeByWorktree'
| 'agentStatusByPaneKey'
| 'automaticAgentResumeClaimsByTabId'
| 'browserCertificateFailuresByPageId'
| 'browserPagesByWorkspace'
| 'browserTabsByWorktree'
| 'groupsByWorktree'
| 'layoutByWorktree'
| 'pendingStartupByTabId'
| 'ptyIdsByTabId'
| 'remoteBrowserPageHandlesByPageId'
| 'tabBarOrderByWorktree'
| 'tabsByWorktree'
| 'terminalLayoutsByTabId'
| 'unifiedTabsByWorktree'
| 'unreadTerminalTabs'
export type WebSessionOpenFilesIndex = {
source: readonly OpenFile[]
byWorktree: Map<string, OpenFile[]>
}
export type WebSessionTabsBatchContext = {
agentPaneKeysByTabId: Map<string, Set<string>> | null
changedRecords: Set<WebSessionTabsBatchRecordKey>
openFilesIndex: WebSessionOpenFilesIndex | null
}
export type AgentTab = RuntimeMobileSessionAgentTab
export type TabGroupSnapshot = RuntimeMobileSessionTabGroup
export type RemovedTabsResult = RuntimeMobileSessionTabsRemovedResult
@@ -0,0 +1,173 @@
import type { AppState } from '../../store'
import { useAppStore } from '../../store'
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import { pickParsedAgentStatusPayload } from '../../../../shared/agent-status-types'
import { normalizeTurnCompletedAtField } from '../../../../shared/agent-status-field-normalization'
import { isClientAuthoritativeAgentStatusPane } from '@/components/terminal-pane/renderer-owned-agent-status-registry'
import { observeAgentHookCompletionForNotification } from '@/hooks/agent-hook-completion-notifications'
import { remapHostAgentStatus } from './agent-status-primitives'
import { agentStatusEntryEqual } from './state-equality-core'
import {
HOST_WORKING_CLIENT_BOUNDARY_LIMIT,
hostWorkingClientBoundaryByPaneKey,
type WebSessionTabsSyncState
} from './state'
import {
createHostSessionMirrorSettle,
type HostSessionMirrorPatchVerdict,
type HostSessionMirrorSettle
} from './mirror-settle'
/** Commit a reconciliation patch and return a receipt for its host evidence. */
export function applyWebSessionTabsStorePatch(
buildPatch: (state: AppState) => WebSessionTabsSyncState | Partial<WebSessionTabsSyncState>,
hostMirrorVerdict: HostSessionMirrorPatchVerdict,
agentStatusSnapshots?: RuntimeMobileSessionTabsResult | readonly RuntimeMobileSessionTabsResult[],
allowCompletionNotification = false
): HostSessionMirrorSettle {
let mirroredAgentStatusChanged = false
// Zustand commits before notifying subscribers, so a completed producer has landed even if a subscriber throws.
let patchCommitted = false
const acceptedNotificationStatuses: {
paneKey: string
worktreeId: string
seedOnly?: true
payload: ReturnType<typeof pickParsedAgentStatusPayload> & {
stateStartedAt: number
localStateStartedAt?: number
}
}[] = []
const runStorePatch = (
state: AppState
): WebSessionTabsSyncState | Partial<WebSessionTabsSyncState> => {
const patch = buildPatch(state)
mirroredAgentStatusChanged = patch !== state && Object.hasOwn(patch, 'agentStatusByPaneKey')
if (agentStatusSnapshots) {
const nextAgentStatuses = patch.agentStatusByPaneKey ?? state.agentStatusByPaneKey
const snapshots = Array.isArray(agentStatusSnapshots)
? agentStatusSnapshots
: [agentStatusSnapshots]
for (const snapshot of snapshots) {
for (const surface of snapshot.tabs) {
if (surface.type !== 'terminal') {
continue
}
const remapped = remapHostAgentStatus(surface)
const accepted = remapped ? nextAgentStatuses[remapped.paneKey] : undefined
const turnCompletedAt = remapped
? normalizeTurnCompletedAtField(surface.turnCompletedAt, remapped.state)
: undefined
if (remapped?.state === 'done' && turnCompletedAt === undefined) {
hostWorkingClientBoundaryByPaneKey.delete(remapped.paneKey)
}
// Client OSC owns display state; the host hook stream carries background-turn stamps.
const clientOwnedNotification = Boolean(
remapped &&
isClientAuthoritativeAgentStatusPane(remapped.paneKey) &&
(remapped.state === 'working' || turnCompletedAt !== undefined)
)
if (
!remapped ||
(!clientOwnedNotification &&
(!accepted ||
accepted === state.agentStatusByPaneKey[remapped.paneKey] ||
!agentStatusEntryEqual(accepted, remapped)))
) {
continue
}
const notificationStatus = clientOwnedNotification ? remapped : accepted
if (!notificationStatus) {
continue
}
const currentClientStateStartedAt = clientOwnedNotification
? state.agentStatusByPaneKey[notificationStatus.paneKey]?.stateStartedAt
: undefined
let localStateStartedAt = currentClientStateStartedAt
if (
clientOwnedNotification &&
notificationStatus.state === 'working' &&
currentClientStateStartedAt !== undefined
) {
if (turnCompletedAt === undefined) {
const retainedBoundary = hostWorkingClientBoundaryByPaneKey.get(
notificationStatus.paneKey
)
if (
!retainedBoundary ||
retainedBoundary.hostStateStartedAt !== notificationStatus.stateStartedAt ||
retainedBoundary.hostPrompt !== notificationStatus.prompt ||
retainedBoundary.stamped
) {
hostWorkingClientBoundaryByPaneKey.delete(notificationStatus.paneKey)
hostWorkingClientBoundaryByPaneKey.set(notificationStatus.paneKey, {
hostStateStartedAt: notificationStatus.stateStartedAt,
hostPrompt: notificationStatus.prompt,
clientStateStartedAt: currentClientStateStartedAt,
stamped: false
})
if (hostWorkingClientBoundaryByPaneKey.size > HOST_WORKING_CLIENT_BOUNDARY_LIMIT) {
const oldestPaneKey = hostWorkingClientBoundaryByPaneKey.keys().next().value
if (oldestPaneKey !== undefined) {
hostWorkingClientBoundaryByPaneKey.delete(oldestPaneKey)
}
}
}
} else {
const retainedBoundary = hostWorkingClientBoundaryByPaneKey.get(
notificationStatus.paneKey
)
if (
retainedBoundary?.hostStateStartedAt === notificationStatus.stateStartedAt &&
retainedBoundary.hostPrompt === notificationStatus.prompt
) {
localStateStartedAt = retainedBoundary.clientStateStartedAt
retainedBoundary.stamped = true
}
}
}
if (!allowCompletionNotification && notificationStatus.state !== 'working') {
continue
}
acceptedNotificationStatuses.push({
paneKey: notificationStatus.paneKey,
worktreeId: notificationStatus.worktreeId ?? snapshot.worktree,
...(!allowCompletionNotification ? { seedOnly: true as const } : {}),
payload: {
...pickParsedAgentStatusPayload({
...notificationStatus,
...(turnCompletedAt !== undefined ? { turnCompletedAt } : {})
}),
stateStartedAt: notificationStatus.stateStartedAt,
...(clientOwnedNotification ? { localStateStartedAt } : {})
}
})
}
}
}
patchCommitted = true
return patch
}
try {
useAppStore.setState(runStorePatch)
} catch (error) {
if (!patchCommitted) {
throw error
}
console.warn('[web-session-tabs-sync] a store subscriber failed after the patch landed:', error)
}
const settleHostMirror = createHostSessionMirrorSettle(hostMirrorVerdict)
try {
if (mirroredAgentStatusChanged) {
useAppStore.getState().scheduleAgentStatusFreshness()
}
for (const status of acceptedNotificationStatuses) {
observeAgentHookCompletionForNotification(status)
}
} catch (error) {
console.warn('[web-session-tabs-sync] post-patch bookkeeping failed:', error)
}
return settleHostMirror
}
@@ -0,0 +1,144 @@
import type {
RuntimeMobileSessionBrowserTab,
RuntimeMobileSessionTabsResult
} from '../../../../shared/runtime-types'
import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types'
import type { Tab } from '../../../../shared/tab-types'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import type { OpenFile } from '../../store/slices/editor'
import { toRuntimeExecutionHostId } from '../../../../shared/execution-host'
import type { ReadyEditorSurface, MirroredEditorTab } from './state'
import type { WebSessionExistingTabIndex } from '../web-session-existing-tab-index'
import { isReadyEditorTab, localEditorFileId, editorSourceFileId } from './terminal-surfaces'
export function buildTerminalUnifiedTab(
tab: TerminalTab,
groupId: string,
environmentId: string,
// Why: viewMode is host-tracked but the client's optimistic toggle must win during the echo window; callers pass the reconciled value.
viewMode?: Tab['viewMode']
): Tab {
return {
id: tab.id,
entityId: tab.id,
groupId,
worktreeId: tab.worktreeId,
executionHostId: toRuntimeExecutionHostId(environmentId),
contentType: 'terminal',
label: tab.title,
...(tab.quickCommandLabel?.trim() ? { quickCommandLabel: tab.quickCommandLabel.trim() } : {}),
...(tab.generatedTitle?.trim() ? { generatedLabel: tab.generatedTitle.trim() } : {}),
...(tab.aiVaultTitle ? { aiVaultTitle: tab.aiVaultTitle } : {}),
customLabel: tab.customTitle,
color: tab.color,
sortOrder: tab.sortOrder,
createdAt: tab.createdAt,
isPreview: false,
isPinned: tab.isPinned === true,
...(viewMode ? { viewMode } : {})
}
}
export function buildBrowserUnifiedTab(
tab: BrowserWorkspace,
hostTab: RuntimeMobileSessionBrowserTab,
existingUnifiedTab: Tab | null,
groupId: string,
environmentId: string
): Tab {
return {
id: existingUnifiedTab?.id ?? hostTab.id,
entityId: tab.id,
groupId,
worktreeId: tab.worktreeId,
executionHostId: toRuntimeExecutionHostId(environmentId),
contentType: 'browser',
label: tab.title,
customLabel: null,
color: hostTab.color !== undefined ? hostTab.color : (existingUnifiedTab?.color ?? null),
sortOrder: tab.createdAt,
createdAt: tab.createdAt,
isPreview: false,
isPinned:
hostTab.isPinned !== undefined
? hostTab.isPinned === true
: existingUnifiedTab?.isPinned === true
}
}
export function buildEditorUnifiedTab(
file: OpenFile,
tab: ReadyEditorSurface,
hostTabId: string,
existingUnifiedTab: Tab | null,
label: string,
groupId: string,
sortOrder: number,
createdAt: number,
environmentId: string
): Tab {
return {
id: hostTabId,
entityId: file.id,
groupId,
worktreeId: file.worktreeId,
executionHostId: toRuntimeExecutionHostId(environmentId),
contentType: 'editor',
label,
customLabel: null,
color: tab.color !== undefined ? tab.color : (existingUnifiedTab?.color ?? null),
sortOrder,
createdAt,
isPreview: false,
isPinned:
tab.isPinned !== undefined ? tab.isPinned === true : existingUnifiedTab?.isPinned === true
}
}
export function buildMirroredEditorTabs(
snapshot: RuntimeMobileSessionTabsResult,
environmentId: string,
worktreeOpenFileById: ReadonlyMap<string, OpenFile>,
existingTabIndex: WebSessionExistingTabIndex,
hostGroupIdByTabId: ReadonlyMap<string, string>,
fallbackGroupId: string,
sortOffset: number,
now: number
): MirroredEditorTab[] {
return snapshot.tabs.filter(isReadyEditorTab).map((tab, index) => {
const fileId = localEditorFileId(tab)
const existingFile = worktreeOpenFileById.get(fileId)
const existingUnifiedTab = existingTabIndex.getEditorUnifiedTab(fileId, tab.id)
const sourceFileId = editorSourceFileId(tab)
const groupId = hostGroupIdByTabId.get(tab.id) ?? fallbackGroupId
const file: OpenFile = {
...existingFile,
id: fileId,
filePath: tab.filePath,
relativePath: tab.relativePath,
worktreeId: snapshot.worktree,
language: tab.language,
isDirty: tab.isDirty,
runtimeEnvironmentId: environmentId,
mode: tab.type === 'markdown' ? tab.mode : 'edit',
markdownPreviewSourceFileId: sourceFileId,
// Why: marks this tab host-owned so a later snapshot that omits it can cull it; locally opened tabs lack this flag and survive.
mirroredFromRuntimeSession: true
}
return {
file,
hostTabId: tab.id,
unifiedTab: buildEditorUnifiedTab(
file,
tab,
tab.id,
existingUnifiedTab,
tab.title.trim() || tab.relativePath || 'File',
groupId,
sortOffset + index,
existingUnifiedTab?.createdAt ?? now + sortOffset + index,
environmentId
)
}
})
}
@@ -0,0 +1,135 @@
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
import { normalizeTerminalLayoutPtyOwnership } from '@/components/terminal-pane/terminal-layout-pty-ownership'
import { resolvePaneAgentOwnerRecord } from '../../../../shared/pane-agent-owner'
import { normalizeCompatibleAgentTitleForOwner } from '../../../../shared/agent-title-owner'
import { toRemoteRuntimePtyId } from '../runtime-terminal-stream'
import { toWebTerminalSurfaceTabId } from '../web-runtime-session'
import type { MirroredTerminalTab, TerminalSurface, ReadyTerminalSurface } from './state'
import { chooseRemoteTerminalLayout, isTerminalSurfaceTab } from './terminal-surfaces'
/** Constructs mirrored terminal tabs from the mobile session status payload, normalising Pi-compatible agent titles under launch ownership. */
export function buildMirroredTerminalTabs(
snapshot: RuntimeMobileSessionTabsResult,
environmentId: string,
existingById: ReadonlyMap<string, TerminalTab>,
existingLayoutsByTabId: Readonly<Record<string, TerminalLayoutSnapshot>>,
sortOffset: number,
now: number,
focusTarget?: { parentTabId: string; leafId: string },
terminalPtyMode: 'local' | 'remote' = 'remote'
): MirroredTerminalTab[] {
const groups = new Map<string, TerminalSurface[]>()
for (const tab of snapshot.tabs.filter(isTerminalSurfaceTab)) {
const group = groups.get(tab.parentTabId) ?? []
group.push(tab)
groups.set(tab.parentTabId, group)
}
return [...groups.entries()].map(([parentTabId, surfaces], index) => {
const localTabId = toWebTerminalSurfaceTabId(parentTabId)
const existingLayout = existingLayoutsByTabId[localTabId]
const requestedActiveLeafId =
focusTarget?.parentTabId === parentTabId ? focusTarget.leafId : undefined
const activeSurface =
(requestedActiveLeafId
? surfaces.find((surface) => surface.leafId === requestedActiveLeafId)
: undefined) ??
(existingLayout?.activeLeafId
? surfaces.find((surface) => surface.leafId === existingLayout.activeLeafId)
: undefined) ??
surfaces.find((surface) => surface.isActive) ??
surfaces[0]!
const ptyIdForSurface = (handle: string): string =>
terminalPtyMode === 'local' ? handle : toRemoteRuntimePtyId(handle, environmentId)
const ptyIdsByLeafId = Object.fromEntries(
surfaces
.filter((surface): surface is ReadyTerminalSurface => surface.status === 'ready')
.map((surface) => [surface.leafId, ptyIdForSurface(surface.terminal)])
)
const layout = normalizeTerminalLayoutPtyOwnership(
chooseRemoteTerminalLayout(surfaces, ptyIdsByLeafId, existingLayout, requestedActiveLeafId)
).snapshot
const layoutPtyEntries = Object.entries(layout.ptyIdsByLeafId ?? {})
const ptyIds = layoutPtyEntries.map(([, ptyId]) => ptyId)
let retainedSurfaceByPrunedLeafId: Map<string, TerminalSurface> | undefined
if (layoutPtyEntries.length < Object.keys(ptyIdsByLeafId).length) {
const retainedLeafIdByPtyId = new Map(
layoutPtyEntries.map(([leafId, ptyId]) => [ptyId, leafId])
)
const surfaceByLeafId = new Map(surfaces.map((surface) => [surface.leafId, surface]))
retainedSurfaceByPrunedLeafId = new Map()
for (const [leafId, ptyId] of Object.entries(ptyIdsByLeafId)) {
const retainedLeafId = retainedLeafIdByPtyId.get(ptyId)
if (retainedLeafId && retainedLeafId !== leafId) {
const retainedSurface = surfaceByLeafId.get(retainedLeafId)
if (retainedSurface) {
retainedSurfaceByPrunedLeafId.set(leafId, retainedSurface)
}
}
}
}
const launchAgent =
activeSurface.launchAgent ?? surfaces.find((surface) => surface.launchAgent)?.launchAgent
const ownerRecord = resolvePaneAgentOwnerRecord({
launchAgent,
hookAgent: activeSurface.agentStatus?.agentType,
siblingHookAgent: surfaces.find((surface) => surface.agentStatus?.agentType)?.agentStatus
?.agentType
})
const title = normalizeCompatibleAgentTitleForOwner(
activeSurface.title.trim() || surfaces[0]?.title.trim() || 'Terminal',
ownerRecord?.agent,
{ ownerIsLaunch: ownerRecord?.ownerIsLaunch === true }
)
const existing =
existingById.get(localTabId) ??
existingById.get(parentTabId) ??
surfaces
.map((surface) => existingById.get(toWebTerminalSurfaceTabId(surface.id)))
.find((tab): tab is TerminalTab => Boolean(tab))
const quickCommandLabel =
activeSurface.quickCommandLabel?.trim() ||
surfaces.find((surface) => surface.quickCommandLabel?.trim())?.quickCommandLabel?.trim() ||
existing?.quickCommandLabel?.trim()
// Why: startupCwd is host-owned launch metadata; once the host omits it, don't resurrect stale subdirectory intent.
const startupCwd =
activeSurface.startupCwd || surfaces.find((surface) => surface.startupCwd)?.startupCwd
// Why: color/pin echo back through host snapshots, so prefer the client's own record and fall back to host only without a prior tab (avoids echo-window reverts).
const hostColorSurface = surfaces.find((surface) => surface.color != null)
const color = existing ? (existing.color ?? null) : (hostColorSurface?.color ?? null)
const isPinned = existing
? existing.isPinned === true
: surfaces.some((surface) => surface.isPinned)
// Why: viewMode echoes back through host snapshots, so prefer the client's record during the echo window and adopt the host value only without a prior tab.
const hostViewModeSurface = surfaces.find((surface) => surface.viewMode)
const viewMode = existing ? existing.viewMode : hostViewModeSurface?.viewMode
return {
tab: {
id: localTabId,
ptyId: ptyIdsByLeafId[activeSurface.leafId] ?? null,
worktreeId: snapshot.worktree,
title,
defaultTitle: existing?.defaultTitle ?? title,
// Why: the host transport carries no generated title, so rebuilding the tab
// without this dropped the client's agent-prompt label on every snapshot.
...(existing?.generatedTitle ? { generatedTitle: existing.generatedTitle } : {}),
...(existing?.aiVaultTitle ? { aiVaultTitle: existing.aiVaultTitle } : {}),
...(quickCommandLabel ? { quickCommandLabel } : {}),
...(startupCwd ? { startupCwd } : {}),
customTitle: existing?.customTitle ?? null,
color,
isPinned,
...(viewMode ? { viewMode } : {}),
sortOrder: sortOffset + index,
createdAt: existing?.createdAt ?? now + index,
// Why: launchAgent is host-owned lifecycle metadata; once the host omits it, don't resurrect stale startup intent.
...(launchAgent ? { launchAgent } : {})
},
hostTabId: parentTabId,
ptyIds,
layout,
...(retainedSurfaceByPrunedLeafId ? { retainedSurfaceByPrunedLeafId } : {})
}
})
}
@@ -0,0 +1,197 @@
import type {
RuntimeMobileSessionTabsResult,
RuntimeMobileSessionAgentTab
} from '../../../../shared/runtime-types'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
import { sanitizeTerminalLayoutPaneTitlesForLabels } from '@/lib/terminal-pane-title-sanitization'
import { resolveTerminalLayoutRoot } from '../remote-terminal-layout-resolution'
import { getRemoteRuntimePtyEnvironmentId } from '../runtime-terminal-stream'
import {
HOST_TERMINAL_SURFACE_SEPARATOR,
WEB_TERMINAL_SURFACE_TAB_PREFIX,
toWebTerminalSurfaceTabId
} from '../web-runtime-session'
import type {
ReadyBrowserSurface,
ReadyEditorSurface,
ReadyTerminalSurface,
TerminalSurface,
MirroredAgentTab
} from './state'
import type { Tab } from '../../../../shared/tab-types'
import { structuredAgentSessionTabId } from '../../../../shared/structured-agent-session-projection'
export function isReadyTerminalTab(
tab: RuntimeMobileSessionTabsResult['tabs'][number]
): tab is ReadyTerminalSurface {
return tab.type === 'terminal' && tab.status === 'ready' && tab.terminal.trim().length > 0
}
export function isTerminalSurfaceTab(
tab: RuntimeMobileSessionTabsResult['tabs'][number]
): tab is TerminalSurface {
return tab.type === 'terminal'
}
export function isReadyBrowserTab(
tab: RuntimeMobileSessionTabsResult['tabs'][number]
): tab is ReadyBrowserSurface {
return tab.type === 'browser' && typeof tab.browserPageId === 'string' && tab.browserPageId !== ''
}
export function isReadyEditorTab(
tab: RuntimeMobileSessionTabsResult['tabs'][number]
): tab is ReadyEditorSurface {
return tab.type === 'markdown' || tab.type === 'file'
}
export function isAgentSessionTab(
tab: RuntimeMobileSessionTabsResult['tabs'][number]
): tab is RuntimeMobileSessionAgentTab {
return tab.type === 'agent-session'
}
export function buildMirroredAgentTabs(
snapshot: RuntimeMobileSessionTabsResult,
hostGroupIdByTabId: ReadonlyMap<string, string>,
fallbackGroupId: string,
sortOffset: number,
currentUnifiedTabs: readonly Tab[],
now: number
): MirroredAgentTab[] {
return snapshot.tabs.filter(isAgentSessionTab).map((tab, index) => {
const localId = structuredAgentSessionTabId(tab.sessionId)
const existing = currentUnifiedTabs.find(
(candidate) => candidate.contentType === 'agent-session' && candidate.id === localId
)
return {
hostTabId: tab.id,
unifiedTab: {
id: localId,
entityId: tab.sessionId,
groupId: hostGroupIdByTabId.get(tab.id) ?? fallbackGroupId,
worktreeId: snapshot.worktree,
contentType: 'agent-session',
agentSessionAgent: tab.agent,
label: tab.title.trim() || 'Codex Chat',
customLabel: null,
color: tab.color !== undefined ? tab.color : (existing?.color ?? null),
sortOrder: sortOffset + index,
createdAt: existing?.createdAt ?? now + sortOffset + index,
isPinned: tab.isPinned !== undefined ? tab.isPinned : existing?.isPinned === true
}
}
})
}
export function localEditorFileId(tab: ReadyEditorSurface): string {
if (tab.type === 'markdown' && tab.mode === 'markdown-preview') {
return `markdown-preview::${tab.sourceFilePath}`
}
return tab.filePath
}
export function editorSourceFileId(tab: ReadyEditorSurface): string | undefined {
return tab.type === 'markdown' && tab.mode === 'markdown-preview' ? tab.sourceFilePath : undefined
}
export function isRuntimeTerminalTabForEnvironment(
tab: TerminalTab,
environmentId: string
): boolean {
if (!tab.ptyId) {
return false
}
return getRemoteRuntimePtyEnvironmentId(tab.ptyId) === environmentId
}
export function isMirroredTerminalSurfaceId(tabId: string): boolean {
return (
tabId.startsWith(WEB_TERMINAL_SURFACE_TAB_PREFIX) ||
tabId.includes(HOST_TERMINAL_SURFACE_SEPARATOR)
)
}
export function chooseRemoteTerminalLayout(
surfaces: readonly TerminalSurface[],
ptyIdsByLeafId: Record<string, string>,
existingLayout?: TerminalLayoutSnapshot,
requestedActiveLeafId?: string
): TerminalLayoutSnapshot {
const leafIds = surfaces.map((surface) => surface.leafId)
const knownLeafIds = new Set(leafIds)
const parentLayoutSource = surfaces.find((surface) => surface.parentLayout)
const parentLayout = parentLayoutSource?.parentLayout
? sanitizeTerminalLayoutPaneTitlesForLabels(parentLayoutSource.parentLayout, [
parentLayoutSource.title
])
: undefined
const activeLeafId =
(requestedActiveLeafId && knownLeafIds.has(requestedActiveLeafId)
? requestedActiveLeafId
: null) ??
// Why: host title/status snapshots may still mark an agent pane active after this client selected a different split pane.
(existingLayout?.activeLeafId && knownLeafIds.has(existingLayout.activeLeafId)
? existingLayout.activeLeafId
: null) ??
(parentLayout?.activeLeafId && knownLeafIds.has(parentLayout.activeLeafId)
? parentLayout.activeLeafId
: null) ??
surfaces.find((surface) => surface.isActive)?.leafId ??
leafIds[0] ??
null
const expandedLeafId =
requestedActiveLeafId &&
(Boolean(existingLayout?.expandedLeafId) || Boolean(parentLayout?.expandedLeafId))
? requestedActiveLeafId
: parentLayout?.expandedLeafId && knownLeafIds.has(parentLayout.expandedLeafId)
? parentLayout.expandedLeafId
: null
return {
// Why: host parentLayout is authoritative for split direction; else keep the prior client tree, then degenerate — never re-guess a direction.
root: resolveTerminalLayoutRoot({
authoritativeRoot: parentLayout?.root,
existingRoot: existingLayout?.root,
leafIds,
onSynthesize: (leafCount) =>
console.warn(
`[web-session-tabs-sync] synthesized layout for ${leafCount} leaves; no authoritative or prior tree covered them`
)
}),
activeLeafId,
expandedLeafId,
ptyIdsByLeafId,
// Why: surface.title is the tab/PTY label, not a pane title; restoring it as one renders a fake title bar. Only host layout titles are real pane titles.
...(parentLayout?.titlesByLeafId ? { titlesByLeafId: parentLayout.titlesByLeafId } : {})
}
}
export function shouldReplaceTerminalTab(
tab: TerminalTab,
environmentId: string,
nextRemotePtyIds: ReadonlySet<string>,
nextMirroredTerminalIds: ReadonlySet<string>,
exactProvisionalHandoffs: ReadonlySet<string>
): boolean {
if (exactProvisionalHandoffs.has(tab.id)) {
// Why: agent kind is not session identity; retire only the provisional tab
// whose request or structured response identifies this exact host surface.
return true
}
if (isMirroredTerminalSurfaceId(tab.id)) {
// Why: host snapshots are authoritative for mirrored tabs; replace old mirrors even when the next surface still awaits a stream handle, else parity drifts.
return true
}
if (tab.pendingActivationSpawn && tab.ptyId === null && nextRemotePtyIds.size > 0) {
return true
}
if (!isRuntimeTerminalTabForEnvironment(tab, environmentId)) {
return false
}
// Why: web-created remote tabs use local UUIDs until the host publishes their surface; only retire them once their PTY appears in the snapshot.
return (
tab.ptyId !== null &&
(nextRemotePtyIds.has(tab.ptyId) ||
nextMirroredTerminalIds.has(toWebTerminalSurfaceTabId(tab.id)))
)
}
@@ -0,0 +1,155 @@
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import {
latestSessionTabsSnapshotByWorktree,
replayableSessionTabsSnapshotByWorktree,
type SessionTabsStreamEvent
} from './state'
import {
sessionTabsFreshnessKey,
rememberHostTerminalTabCount,
trackWebSessionTabsWorktree,
recordAcceptedWebSessionTabsEnvironment
} from './tracking'
import { clearWebSessionTabsTrackingForWorktree } from './tracking-lifecycle'
import { queueAcceptedWebSessionTerminalSnapshot } from '../web-session-terminal-handle-events'
/** A frame's fate, paired with whether that fate is host evidence for the worktree. */
export type WebSessionTabsSnapshotDecision = {
readonly apply: boolean
readonly settlesHostMirror: boolean
}
const WEB_SESSION_TABS_FRAME_APPLIED = {
apply: true,
settlesHostMirror: true
} as const satisfies WebSessionTabsSnapshotDecision
export const WEB_SESSION_TABS_FRAME_OUTRANKED = {
apply: false,
settlesHostMirror: true
} as const satisfies WebSessionTabsSnapshotDecision
const WEB_SESSION_TABS_FRAME_UNMIRRORED = {
apply: false,
settlesHostMirror: false
} as const satisfies WebSessionTabsSnapshotDecision
function isHostMirroredWorktree(worktreeId: string): boolean {
return worktreeId !== FLOATING_TERMINAL_WORKTREE_ID
}
export function shouldApplyWebSessionTabsSnapshot(
snapshot: RuntimeMobileSessionTabsResult,
environmentId: string
): boolean {
return decideWebSessionTabsSnapshot(snapshot, environmentId).apply
}
export function decideWebSessionTabsSnapshot(
snapshot: RuntimeMobileSessionTabsResult,
environmentId: string
): WebSessionTabsSnapshotDecision {
const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree)
if ((snapshot as { removed?: unknown }).removed === true) {
// Why: removed worktrees can stop publishing, so clean up their tracking now instead of waiting for a replacement snapshot that may never arrive.
clearWebSessionTabsTrackingForWorktree(environmentId, snapshot.worktree)
queueAcceptedWebSessionTerminalSnapshot(snapshot, environmentId)
return WEB_SESSION_TABS_FRAME_APPLIED
}
if (!isHostMirroredWorktree(snapshot.worktree)) {
// Why: a remote empty same-id snapshot would delete the user's local floating tabs.
return WEB_SESSION_TABS_FRAME_UNMIRRORED
}
rememberHostTerminalTabCount(environmentId, snapshot)
const current = latestSessionTabsSnapshotByWorktree.get(key)
const replayable = replayableSessionTabsSnapshotByWorktree.get(key)
const isExactCurrentReplay = Boolean(
current &&
replayable &&
current.publicationEpoch === replayable.publicationEpoch &&
current.snapshotVersion === replayable.snapshotVersion &&
snapshot.publicationEpoch === replayable.publicationEpoch &&
snapshot.snapshotVersion === replayable.snapshotVersion
)
// Why: reject stale snapshots only within an epoch; host restarts create a new epoch.
if (
current &&
current.publicationEpoch === snapshot.publicationEpoch &&
snapshot.snapshotVersion <= current.snapshotVersion &&
!isExactCurrentReplay
) {
return WEB_SESSION_TABS_FRAME_OUTRANKED
}
replayableSessionTabsSnapshotByWorktree.delete(key)
latestSessionTabsSnapshotByWorktree.set(key, {
publicationEpoch: snapshot.publicationEpoch,
snapshotVersion: snapshot.snapshotVersion
})
trackWebSessionTabsWorktree(environmentId, snapshot.worktree)
recordAcceptedWebSessionTabsEnvironment(environmentId, snapshot)
// Why: a mounted mirror that exhausted bounded polling needs fresh host evidence without subscribing to every store write.
queueAcceptedWebSessionTerminalSnapshot(snapshot, environmentId)
return WEB_SESSION_TABS_FRAME_APPLIED
}
export function shouldBootstrapInitialWebRuntimeTerminal(args: {
event: SessionTabsStreamEvent
activeWorktreeId: string
requestedInitialTerminal: boolean
snapshotIsFresh: boolean
localTerminalCount: number
}): boolean {
return (
args.snapshotIsFresh &&
args.event.type === 'snapshot' &&
args.event.tabs.length === 0 &&
args.localTerminalCount === 0 &&
!args.requestedInitialTerminal &&
args.activeWorktreeId === args.event.worktree
)
}
export function shouldRespawnWebRuntimeTerminalAfterWake(args: {
event: SessionTabsStreamEvent
activeWorktreeId: string
requestedRespawnAfterWake: boolean
snapshotIsFresh: boolean
localTerminalCount: number
hasLiveLocalPty: boolean
skipWakeRespawn?: boolean
}): boolean {
if (
!args.snapshotIsFresh ||
args.requestedRespawnAfterWake ||
args.skipWakeRespawn === true ||
args.localTerminalCount === 0 ||
args.hasLiveLocalPty ||
(args.event.type !== 'snapshot' && args.event.type !== 'updated')
) {
return false
}
if (args.activeWorktreeId !== args.event.worktree) {
return false
}
const hostTerminalTabCount = args.event.tabs.filter((tab) => tab.type === 'terminal').length
return hostTerminalTabCount === 0
}
export function shouldSyncRuntimeSessionTabs(args: {
activeWorktreeId?: string | null
activeWorktreeRuntimeEnvironmentId?: string | null
workspaceSessionReady: boolean
}): boolean {
const environmentId = args.activeWorktreeRuntimeEnvironmentId?.trim()
if (!environmentId || !args.workspaceSessionReady) {
return false
}
return Boolean(args.activeWorktreeId?.trim())
}
export function shouldSyncAllRuntimeSessionTabs(args: {
activeRuntimeEnvironmentId: string | null | undefined
workspaceSessionReady: boolean
}): boolean {
const environmentId = args.activeRuntimeEnvironmentId?.trim()
return Boolean(environmentId && args.workspaceSessionReady)
}
@@ -0,0 +1,200 @@
import {
latestSessionTabsSnapshotByWorktree,
replayableSessionTabsSnapshotByWorktree,
latestReceivedSessionTabsSnapshotByWorktree,
latestSessionTabsRemovalFenceByWorktree,
sessionTabsRecoveryStateByWorktree,
trackedSessionTabsWorktreeIdsByEnvironment,
sessionTabsEnvironmentsByWorktree,
sessionTabsTrackingGenerationByEnvironment,
lastHostTerminalTabCountByWorktree,
hostSessionTabIdByLocalKey,
hostSessionTabMappingKeysByEnvironmentAndWorktree,
hostWorkingClientBoundaryByPaneKey,
resetReceivedSessionTabsFrameSequence
} from './state'
import {
clearWebRuntimeWakeTerminalRespawnForWorktree,
clearAllWebRuntimeWakeTerminalRespawn
} from '../web-runtime-wake-terminal-respawn'
import { clearWebSessionReorderIntentsForWorktree } from '../web-session-reorder-intent'
import { clearWebSessionCloseIntentsForWorktree } from '../web-session-close-intent'
import {
clearWebAgentSessionHandoffsForWorktree,
clearWebAgentSessionHandoffsForEnvironment
} from '../web-agent-session-handoff'
import {
clearWebSessionBrowserPlacementsForWorktree,
clearWebSessionBrowserPlacementsForEnvironment,
resetWebSessionBrowserPlacementsForTests
} from '../web-session-browser-placement'
import {
clearWebSessionTerminalPlacementsForWorktree,
clearWebSessionTerminalPlacementsForEnvironment
} from '../web-session-terminal-placement'
import { clearHostSessionMirrorHydration } from '../host-session-mirror-hydration'
import { clearHostSessionTabIdMappings } from './tracking-mappings'
import {
sessionTabsFreshnessKey,
untrackWebSessionTabsWorktree,
removeWebSessionTabsEnvironment
} from './tracking'
export function getLastKnownHostTerminalTabCount(
environmentId: string,
worktreeId: string
): number {
return (
lastHostTerminalTabCountByWorktree.get(sessionTabsFreshnessKey(environmentId, worktreeId)) ?? 0
)
}
export function getLatestWebSessionTabsPublicationEpoch(
environmentId: string,
worktreeId: string
): string | null {
return (
latestSessionTabsSnapshotByWorktree.get(sessionTabsFreshnessKey(environmentId, worktreeId))
?.publicationEpoch ?? null
)
}
// Why: a replay may repeat the current epoch/version; permit only that exact
// identity once so an older concurrent frame cannot bypass monotonic ordering.
export function acceptReplayedWebSessionTabsSnapshot(
environmentId: string,
worktreeId: string
): void {
const key = sessionTabsFreshnessKey(environmentId, worktreeId)
const current = latestSessionTabsSnapshotByWorktree.get(key)
if (current) {
replayableSessionTabsSnapshotByWorktree.set(key, current)
}
}
export function resetWebSessionTabsSnapshotFreshnessForTests(): void {
latestSessionTabsSnapshotByWorktree.clear()
replayableSessionTabsSnapshotByWorktree.clear()
latestReceivedSessionTabsSnapshotByWorktree.clear()
latestSessionTabsRemovalFenceByWorktree.clear()
sessionTabsRecoveryStateByWorktree.clear()
trackedSessionTabsWorktreeIdsByEnvironment.clear()
sessionTabsEnvironmentsByWorktree.clear()
resetReceivedSessionTabsFrameSequence()
lastHostTerminalTabCountByWorktree.clear()
hostSessionTabIdByLocalKey.clear()
hostSessionTabMappingKeysByEnvironmentAndWorktree.clear()
hostWorkingClientBoundaryByPaneKey.clear()
resetWebSessionBrowserPlacementsForTests()
}
export function _getWebSessionTabsTrackingCountsForTest(): {
freshness: number
hostMappings: number
hostMappingWorktrees: number
} {
let hostMappingWorktrees = 0
for (const mappingKeysByWorktree of hostSessionTabMappingKeysByEnvironmentAndWorktree.values()) {
hostMappingWorktrees += mappingKeysByWorktree.size
}
return {
freshness: latestSessionTabsSnapshotByWorktree.size,
hostMappings: hostSessionTabIdByLocalKey.size,
// Why: the mapping index is a parallel structure, so leak tests must see it drain alongside the flat map.
hostMappingWorktrees
}
}
export function _getWebSessionTabsRecoveryTrackingCountsForTest(): {
pendingRecoveries: number
removalFrames: number
} {
return {
pendingRecoveries: sessionTabsRecoveryStateByWorktree.size,
removalFrames: latestSessionTabsRemovalFenceByWorktree.size
}
}
export function clearWebSessionTabsTrackingForWorktree(
environmentId: string,
worktreeId: string
): void {
const key = sessionTabsFreshnessKey(environmentId, worktreeId)
latestSessionTabsSnapshotByWorktree.delete(key)
replayableSessionTabsSnapshotByWorktree.delete(key)
latestReceivedSessionTabsSnapshotByWorktree.delete(key)
untrackWebSessionTabsWorktree(environmentId, worktreeId)
removeWebSessionTabsEnvironment(environmentId, worktreeId)
lastHostTerminalTabCountByWorktree.delete(key)
clearWebRuntimeWakeTerminalRespawnForWorktree(worktreeId)
clearWebSessionReorderIntentsForWorktree({ environmentId }, worktreeId)
clearWebSessionCloseIntentsForWorktree({ environmentId }, worktreeId)
clearWebAgentSessionHandoffsForWorktree(environmentId, worktreeId)
clearHostSessionTabIdMappings(environmentId, worktreeId)
clearWebSessionBrowserPlacementsForWorktree(environmentId, worktreeId)
clearWebSessionTerminalPlacementsForWorktree(environmentId, worktreeId)
}
export function clearWebSessionTabsTrackingForEnvironment(environmentId: string): void {
const trimmedEnvironmentId = environmentId.trim()
if (!trimmedEnvironmentId) {
return
}
const keyPrefix = `${trimmedEnvironmentId}:`
sessionTabsTrackingGenerationByEnvironment.set(
trimmedEnvironmentId,
(sessionTabsTrackingGenerationByEnvironment.get(trimmedEnvironmentId) ?? 0) + 1
)
for (const key of latestSessionTabsSnapshotByWorktree.keys()) {
if (key.startsWith(keyPrefix)) {
latestSessionTabsSnapshotByWorktree.delete(key)
}
}
for (const key of replayableSessionTabsSnapshotByWorktree.keys()) {
if (key.startsWith(keyPrefix)) {
replayableSessionTabsSnapshotByWorktree.delete(key)
}
}
for (const key of latestReceivedSessionTabsSnapshotByWorktree.keys()) {
if (key.startsWith(keyPrefix)) {
latestReceivedSessionTabsSnapshotByWorktree.delete(key)
}
}
for (const key of latestSessionTabsRemovalFenceByWorktree.keys()) {
if (key.startsWith(keyPrefix)) {
latestSessionTabsRemovalFenceByWorktree.delete(key)
}
}
for (const key of sessionTabsRecoveryStateByWorktree.keys()) {
if (key.startsWith(keyPrefix)) {
sessionTabsRecoveryStateByWorktree.delete(key)
}
}
trackedSessionTabsWorktreeIdsByEnvironment.delete(trimmedEnvironmentId)
for (const worktreeId of sessionTabsEnvironmentsByWorktree.keys()) {
removeWebSessionTabsEnvironment(trimmedEnvironmentId, worktreeId)
}
for (const key of lastHostTerminalTabCountByWorktree.keys()) {
if (key.startsWith(keyPrefix)) {
lastHostTerminalTabCountByWorktree.delete(key)
}
}
const mappingKeysByWorktree =
hostSessionTabMappingKeysByEnvironmentAndWorktree.get(trimmedEnvironmentId)
if (mappingKeysByWorktree) {
for (const mappingKeys of mappingKeysByWorktree.values()) {
for (const mappingKey of mappingKeys) {
hostSessionTabIdByLocalKey.delete(mappingKey)
}
}
hostSessionTabMappingKeysByEnvironmentAndWorktree.delete(trimmedEnvironmentId)
}
clearWebAgentSessionHandoffsForEnvironment(trimmedEnvironmentId)
clearWebSessionBrowserPlacementsForEnvironment(trimmedEnvironmentId)
clearWebSessionTerminalPlacementsForEnvironment(trimmedEnvironmentId)
clearHostSessionMirrorHydration(trimmedEnvironmentId)
clearAllWebRuntimeWakeTerminalRespawn()
}
export function getWebSessionTabsTrackingGeneration(environmentId: string): number {
return sessionTabsTrackingGenerationByEnvironment.get(environmentId.trim()) ?? 0
}
@@ -0,0 +1,58 @@
import type { WebSessionTabsSyncState } from './state'
import {
hostSessionTabIdByLocalKey,
hostSessionTabMappingKeysByEnvironmentAndWorktree
} from './state'
import { resolveWebAgentSessionHandoff } from '../web-agent-session-handoff'
function hostSessionTabMappingKey(args: {
environmentId: string
worktreeId: string
tabId: string
}): string {
return `${args.environmentId}:${args.worktreeId}:${args.tabId}`
}
export function clearHostSessionTabIdMappings(environmentId: string, worktreeId: string): void {
const mappingKeysByWorktree = hostSessionTabMappingKeysByEnvironmentAndWorktree.get(environmentId)
const mappingKeys = mappingKeysByWorktree?.get(worktreeId)
if (!mappingKeys) {
return
}
for (const mappingKey of mappingKeys) {
hostSessionTabIdByLocalKey.delete(mappingKey)
}
mappingKeysByWorktree?.delete(worktreeId)
if (mappingKeysByWorktree?.size === 0) {
hostSessionTabMappingKeysByEnvironmentAndWorktree.delete(environmentId)
}
}
export function setHostSessionTabIdMapping(
args: { environmentId: string; worktreeId: string; tabId: string },
hostTabId: string
): void {
const mappingKey = hostSessionTabMappingKey(args)
hostSessionTabIdByLocalKey.set(mappingKey, hostTabId)
const mappingKeysByWorktree =
hostSessionTabMappingKeysByEnvironmentAndWorktree.get(args.environmentId) ?? new Map()
const mappingKeys = mappingKeysByWorktree.get(args.worktreeId) ?? new Set<string>()
mappingKeys.add(mappingKey)
mappingKeysByWorktree.set(args.worktreeId, mappingKeys)
hostSessionTabMappingKeysByEnvironmentAndWorktree.set(args.environmentId, mappingKeysByWorktree)
}
export function resolveHostSessionTabIdForWebSessionTab(
_state: WebSessionTabsSyncState,
args: { environmentId: string; worktreeId: string; tabId: string }
): string | null {
return (
hostSessionTabIdByLocalKey.get(hostSessionTabMappingKey(args)) ??
// Why: structured create returns canonical identity before its confirming snapshot; an immediate user close must already target that host tab.
resolveWebAgentSessionHandoff({
environmentId: args.environmentId,
worktreeId: args.worktreeId,
provisionalTabId: args.tabId
})
)
}
@@ -0,0 +1,263 @@
import type {
RuntimeMobileSessionTabsRemovedResult,
RuntimeMobileSessionTabsResult
} from '../../../../shared/runtime-types'
import { VISIBILITY_INVENTORY_REMOVAL_EPOCH, type SessionTabsListAllResult } from './state'
import {
latestReceivedSessionTabsSnapshotByWorktree,
latestSessionTabsRemovalFenceByWorktree,
latestSessionTabsSnapshotByWorktree,
lastHostTerminalTabCountByWorktree,
sessionTabsEnvironmentsByWorktree,
sessionTabsRecoveryStateByWorktree,
trackedSessionTabsWorktreeIdsByEnvironment,
type SnapshotFreshness,
type TrackedWebSessionTabsWorktree
} from './state'
import { nextReceivedSessionTabsFrame } from './state'
export function isSessionTabsListAllResult(value: unknown): value is SessionTabsListAllResult {
return (
Boolean(value) &&
typeof value === 'object' &&
Array.isArray((value as { snapshots?: unknown }).snapshots)
)
}
export function sessionTabsFreshnessKey(environmentId: string, worktreeId: string): string {
return `${environmentId}:${worktreeId}`
}
export function advancesSessionTabsFreshness(
snapshot: RuntimeMobileSessionTabsResult,
baseline: SnapshotFreshness
): boolean {
return (
snapshot.publicationEpoch !== baseline.publicationEpoch ||
snapshot.snapshotVersion > baseline.snapshotVersion
)
}
export function getTrackedWebSessionTabsWorktrees(
environmentId: string
): TrackedWebSessionTabsWorktree[] {
return [...(trackedSessionTabsWorktreeIdsByEnvironment.get(environmentId) ?? [])].flatMap(
(worktree) => {
const key = sessionTabsFreshnessKey(environmentId, worktree)
const freshness = latestSessionTabsSnapshotByWorktree.get(key)
return freshness
? [
{
worktree,
freshness
}
]
: []
}
)
}
export function trackWebSessionTabsWorktree(environmentId: string, worktreeId: string): void {
const worktrees = trackedSessionTabsWorktreeIdsByEnvironment.get(environmentId) ?? new Set()
worktrees.add(worktreeId)
trackedSessionTabsWorktreeIdsByEnvironment.set(environmentId, worktrees)
}
export function untrackWebSessionTabsWorktree(environmentId: string, worktreeId: string): void {
const worktrees = trackedSessionTabsWorktreeIdsByEnvironment.get(environmentId)
if (!worktrees) {
return
}
worktrees.delete(worktreeId)
if (worktrees.size === 0) {
trackedSessionTabsWorktreeIdsByEnvironment.delete(environmentId)
}
}
export function recordReceivedWebSessionTabsSnapshot(
environmentId: string,
snapshot: RuntimeMobileSessionTabsResult
): number {
const receivedFrame = nextReceivedSessionTabsFrame()
const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree)
latestReceivedSessionTabsSnapshotByWorktree.set(key, {
receivedFrame,
publicationEpoch: snapshot.publicationEpoch,
snapshotVersion: snapshot.snapshotVersion
})
if ((snapshot as { removed?: unknown }).removed === true) {
recordReceivedWebSessionTabsRemoval(environmentId, snapshot.worktree, receivedFrame)
}
return receivedFrame
}
export function recordReceivedWebSessionTabsInventory(): number {
return nextReceivedSessionTabsFrame()
}
export function beginWebSessionTabsSnapshotRecovery(
environmentId: string,
worktreeId: string,
receivedFrame: number
): () => void {
const key = sessionTabsFreshnessKey(environmentId, worktreeId)
const recoveryState = sessionTabsRecoveryStateByWorktree.get(key) ?? { pendingCount: 0 }
recoveryState.pendingCount += 1
sessionTabsRecoveryStateByWorktree.set(key, recoveryState)
let settled = false
return () => {
if (settled) {
return
}
settled = true
recoveryState.pendingCount -= 1
if (
recoveryState.pendingCount === 0 &&
sessionTabsRecoveryStateByWorktree.get(key) === recoveryState
) {
sessionTabsRecoveryStateByWorktree.delete(key)
}
const removalFence = latestSessionTabsRemovalFenceByWorktree.get(key)
if (
removalFence?.recoveryState === recoveryState &&
receivedFrame < removalFence.receivedFrame
) {
removalFence.pendingCount -= 1
if (removalFence.pendingCount === 0) {
latestSessionTabsRemovalFenceByWorktree.delete(key)
}
}
}
}
export function recordReceivedWebSessionTabsRemoval(
environmentId: string,
worktreeId: string,
receivedFrame: number
): void {
const key = sessionTabsFreshnessKey(environmentId, worktreeId)
const current = latestSessionTabsRemovalFenceByWorktree.get(key)
if (current && current.receivedFrame >= receivedFrame) {
return
}
const recoveryState = sessionTabsRecoveryStateByWorktree.get(key)
if (!recoveryState || recoveryState.pendingCount === 0) {
latestSessionTabsRemovalFenceByWorktree.delete(key)
return
}
latestSessionTabsRemovalFenceByWorktree.set(key, {
receivedFrame,
recoveryState,
pendingCount: recoveryState.pendingCount
})
}
export function shouldApplyRecoveredWebSessionTabsSnapshot(
environmentId: string,
snapshot: RuntimeMobileSessionTabsResult,
receivedFrame: number
): boolean {
const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree)
const removalFrame = latestSessionTabsRemovalFenceByWorktree.get(key)?.receivedFrame
if (removalFrame !== undefined && receivedFrame < removalFrame) {
return false
}
const latest = latestReceivedSessionTabsSnapshotByWorktree.get(key)
if (!latest || latest.receivedFrame === receivedFrame) {
return latest !== undefined
}
if (latest.publicationEpoch !== snapshot.publicationEpoch) {
return receivedFrame > latest.receivedFrame
}
return snapshot.snapshotVersion >= latest.snapshotVersion
}
export function isTrackedWebSessionTabsOmissionCurrent(
environmentId: string,
trackedWorktree: TrackedWebSessionTabsWorktree
): boolean {
const key = sessionTabsFreshnessKey(environmentId, trackedWorktree.worktree)
const current = latestSessionTabsSnapshotByWorktree.get(key)
return (
current?.publicationEpoch === trackedWorktree.freshness.publicationEpoch &&
current.snapshotVersion === trackedWorktree.freshness.snapshotVersion
)
}
export function recordAcceptedWebSessionTabsEnvironment(
environmentId: string,
snapshot: RuntimeMobileSessionTabsResult
): void {
const environments = new Set(sessionTabsEnvironmentsByWorktree.get(snapshot.worktree) ?? [])
if (snapshot.tabs.length > 0) {
environments.add(environmentId)
} else {
environments.delete(environmentId)
}
if (environments.size > 0) {
sessionTabsEnvironmentsByWorktree.set(snapshot.worktree, environments)
} else {
sessionTabsEnvironmentsByWorktree.delete(snapshot.worktree)
}
}
export function removeWebSessionTabsEnvironment(environmentId: string, worktreeId: string): void {
const environments = new Set(sessionTabsEnvironmentsByWorktree.get(worktreeId) ?? [])
environments.delete(environmentId)
if (environments.size > 0) {
sessionTabsEnvironmentsByWorktree.set(worktreeId, environments)
} else {
sessionTabsEnvironmentsByWorktree.delete(worktreeId)
}
}
// Why: a tombstone empties the whole worktree mirror — including tabs a still-live sibling environment publishes — so it is a
// visibility fact, never evidence that the host closed anything.
export function isWebSessionTabsWorktreeRemovalFrame(
snapshot: RuntimeMobileSessionTabsResult
): boolean {
return (
(snapshot as { removed?: unknown }).removed === true ||
snapshot.publicationEpoch === VISIBILITY_INVENTORY_REMOVAL_EPOCH
)
}
// Why: omission means removal only because `listAllMobileSessionTabs` publishes every worktree it knows unfiltered; if a host ever
// scopes that map, this turns live worktrees into tombstones, so the fence below is deliberately short-lived.
export function buildMissingWebSessionTabsRemovals(
environmentId: string,
trackedWorktrees: readonly TrackedWebSessionTabsWorktree[],
publishedWorktrees: ReadonlySet<string>
): {
trackedWorktree: TrackedWebSessionTabsWorktree
snapshot: RuntimeMobileSessionTabsRemovedResult
}[] {
return trackedWorktrees
.filter(
(trackedWorktree) =>
!publishedWorktrees.has(trackedWorktree.worktree) &&
isTrackedWebSessionTabsOmissionCurrent(environmentId, trackedWorktree)
)
.map((trackedWorktree) => ({
trackedWorktree,
snapshot: {
worktree: trackedWorktree.worktree,
publicationEpoch: VISIBILITY_INVENTORY_REMOVAL_EPOCH,
snapshotVersion: 0,
removed: true,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
}))
}
export function rememberHostTerminalTabCount(
environmentId: string,
snapshot: RuntimeMobileSessionTabsResult
): void {
const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree)
const terminalCount = snapshot.tabs.filter((tab) => tab.type === 'terminal').length
lastHostTerminalTabCountByWorktree.set(key, terminalCount)
}
@@ -0,0 +1,109 @@
import { useEffect, useLayoutEffect, useRef } from 'react'
import { useAppStore } from '../../store'
import { getExplicitRuntimeEnvironmentIdForWorktree } from '../../lib/worktree-runtime-owner'
import { useRuntimeSessionMirrorEnvironmentKey } from '../use-runtime-session-mirror-environment-key'
import { sessionTabsFreshnessKey } from './tracking'
import { clearWebSessionTabsTrackingForEnvironment } from './tracking-lifecycle'
import {
installGlobalSessionTabsSubscriptions,
type GlobalSubscriptionRefs
} from './global-session-subscription'
import { installActiveSessionTabsSubscription } from './active-session-subscription'
/** Mount the paired-runtime tab mirrors and the selected-worktree stream. */
export function useWebSessionTabsSync(): void {
const activeRuntimeEnvironmentIdRef = useRef<string | null>(null)
const activeRuntimeWorktreeKeyRef = useRef<string | null>(null)
const visibilityResumeOmissionsRef = useRef<
GlobalSubscriptionRefs['visibilityResumeOmissions']['current']
>(new Map())
const ownerRevisionsRef = useRef<GlobalSubscriptionRefs['ownerRevisions']['current']>(new Map())
const visibilitySnapshotReceiptRef = useRef<GlobalSubscriptionRefs['snapshotReceipt']['current']>(
() => {}
)
const visibilitySnapshotApplyRef = useRef<GlobalSubscriptionRefs['snapshotApply']['current']>(
() => true
)
const activeWorktreeId = useAppStore((state) => state.activeWorktreeId)
const workspaceSessionReady = useAppStore((state) => state.workspaceSessionReady)
const runtimeSessionMirrorEnvironmentKey = useRuntimeSessionMirrorEnvironmentKey()
const activeWorktreeRuntimeEnvironmentId = useAppStore((state) =>
getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId)
)
// Keep this subscription dependency: a runtime reconnect can retain the same environment id
// while replacing its runtime instance, which must restart the scoped stream.
const activeWorktreeRuntimeId = useAppStore((state) => {
const environmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId)
return environmentId
? (state.runtimeStatusByEnvironmentId.get(environmentId)?.status?.runtimeId ?? null)
: null
})
const activeWorktreeRuntimeConnectionGeneration = useAppStore((state) => {
const environmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId)
return environmentId
? (state.runtimeStatusByEnvironmentId.get(environmentId)?.connectionGeneration ?? 0)
: 0
})
const activeWorktreeRuntimePairingRevision = useAppStore((state) => {
const environmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId)
const environment = state.runtimeEnvironments.find(
(candidate) => candidate.id === environmentId
)
return environment ? (environment.pairingRevision ?? environment.createdAt) : undefined
})
useLayoutEffect(() => {
const environmentId = activeWorktreeRuntimeEnvironmentId?.trim() || null
activeRuntimeEnvironmentIdRef.current = environmentId
activeRuntimeWorktreeKeyRef.current =
environmentId && activeWorktreeId
? sessionTabsFreshnessKey(environmentId, activeWorktreeId)
: null
}, [activeWorktreeId, activeWorktreeRuntimeEnvironmentId])
useEffect(
() => () => {
for (const environmentId of ownerRevisionsRef.current.keys()) {
clearWebSessionTabsTrackingForEnvironment(environmentId)
}
ownerRevisionsRef.current.clear()
visibilityResumeOmissionsRef.current.clear()
},
[]
)
useEffect(() => {
return installGlobalSessionTabsSubscriptions({
runtimeSessionMirrorEnvironmentKey,
workspaceSessionReady,
refs: {
activeRuntimeEnvironmentId: activeRuntimeEnvironmentIdRef,
activeRuntimeWorktreeKey: activeRuntimeWorktreeKeyRef,
visibilityResumeOmissions: visibilityResumeOmissionsRef,
snapshotReceipt: visibilitySnapshotReceiptRef,
snapshotApply: visibilitySnapshotApplyRef,
ownerRevisions: ownerRevisionsRef
}
})
}, [runtimeSessionMirrorEnvironmentKey, workspaceSessionReady])
useEffect(() => {
return installActiveSessionTabsSubscription({
activeWorktreeId,
activeWorktreeRuntimeEnvironmentId,
activeWorktreeRuntimeConnectionGeneration,
activeWorktreeRuntimePairingRevision,
workspaceSessionReady,
visibilitySnapshotReceipt: visibilitySnapshotReceiptRef,
visibilitySnapshotApply: visibilitySnapshotApplyRef
})
}, [
activeWorktreeId,
activeWorktreeRuntimeEnvironmentId,
activeWorktreeRuntimeConnectionGeneration,
activeWorktreeRuntimePairingRevision,
activeWorktreeRuntimeId,
workspaceSessionReady
])
}
@@ -0,0 +1,64 @@
import { getTrackedWebSessionTabsWorktrees } from './tracking'
import { getWebSessionTabsTrackingGeneration } from './tracking-lifecycle'
import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status'
import type { VisibilityResumeOmission } from './state'
import type {
MirroredRuntimeEnvironment,
VisibilityResumeBatch,
VisibilityResumeEnvironment
} from './visibility-resume-types'
/** Build the inventory/replay state for the subscriptions restarted by a resume. */
export function buildVisibilityResumeBatch(args: {
visibilityGeneration: number
restartingSpecIndexes: readonly number[]
environmentIdBySubscriptionSpec: readonly string[]
environments: readonly MirroredRuntimeEnvironment[]
omissions: Map<string, VisibilityResumeOmission>
activeRuntimeWorktreeKey: () => string | null
}): VisibilityResumeBatch | null {
const activeKey = args.activeRuntimeWorktreeKey()
for (const [key, omission] of args.omissions) {
if (key !== activeKey || omission.visibilityGeneration < args.visibilityGeneration - 1) {
args.omissions.delete(key)
}
}
const resumed = new Map<string, VisibilityResumeEnvironment>()
const trackedWorktreeIds = new Set<string>()
for (const index of args.restartingSpecIndexes) {
const environmentId = args.environmentIdBySubscriptionSpec[index]
if (!environmentId) {
continue
}
const trackedWorktrees = getTrackedWebSessionTabsWorktrees(environmentId)
if (trackedWorktrees.length === 0) {
continue
}
for (const { worktree } of trackedWorktrees) {
trackedWorktreeIds.add(worktree)
}
const descriptor = args.environments.find((entry) => entry.environmentId === environmentId)
resumed.set(environmentId, {
trackedWorktrees,
inventoryReceived: false,
latestInventoryReceivedFrame: 0,
pendingMissingWorktrees: new Set(),
expectedEnvironmentConnectionGeneration:
descriptor?.expectedEnvironmentConnectionGeneration ??
getRuntimeEnvironmentConnectionGeneration(environmentId),
expectedEnvironmentPairingRevision: descriptor?.expectedEnvironmentPairingRevision,
expectedTrackingGeneration: getWebSessionTabsTrackingGeneration(environmentId)
})
}
return resumed.size
? {
visibilityGeneration: args.visibilityGeneration,
environments: resumed,
pendingInventoryCount: resumed.size,
pendingMissingByWorktree: new Map(),
deferredRepairWorktrees: new Set(),
trackedWorktreeIds,
reapplyableSnapshotsByKey: new Map()
}
: null
}
@@ -0,0 +1,285 @@
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import {
advancesSessionTabsFreshness,
shouldApplyRecoveredWebSessionTabsSnapshot,
sessionTabsFreshnessKey
} from './tracking'
import { latestSessionTabsSnapshotByWorktree, sessionTabsEnvironmentsByWorktree } from './state'
import { acceptReplayedWebSessionTabsSnapshot } from './tracking-lifecycle'
import {
applyWebSessionTabsSnapshotOperations,
decideWebSessionTabsSnapshotOperations,
type WebSessionTabsSnapshotOperation
} from './snapshot-api'
import { applyWebSessionTabsStorePatch } from './store-patch'
import {
recordVisibilityResumeInventory,
recordVisibilityResumeInventoryReceipt
} from './visibility-resume-inventory'
import type { VisibilityResumeOmission } from './state'
import type {
MirroredRuntimeEnvironment,
VisibilityResumeBatch,
VisibilityResumeMissing
} from './visibility-resume-types'
import { buildVisibilityResumeBatch } from './visibility-resume-batch'
export type VisibilityResumeCoordinatorOptions = {
environments: readonly MirroredRuntimeEnvironment[]
environmentIdBySubscriptionSpec: readonly string[]
omissions: Map<string, VisibilityResumeOmission>
activeRuntimeWorktreeKey: () => string | null
}
/** Coordinates inventory omissions and cross-environment replay during visibility resumes. */
export class VisibilityResumeCoordinator {
private batch: VisibilityResumeBatch | null = null
constructor(private readonly options: VisibilityResumeCoordinatorOptions) {}
recordSnapshotReceipt(
environmentId: string,
snapshot: RuntimeMobileSessionTabsResult,
receivedFrame: number
): void {
const omission = this.options.omissions.get(
sessionTabsFreshnessKey(environmentId, snapshot.worktree)
)
if (
omission &&
receivedFrame > omission.inventoryReceivedFrame &&
((snapshot as { removed?: unknown }).removed === true ||
advancesSessionTabsFreshness(snapshot, omission.baseline))
) {
omission.superseded = true
if (this.batch?.pendingMissingByWorktree.has(snapshot.worktree)) {
this.reconcileWorktrees([snapshot.worktree])
}
}
}
shouldApplySnapshot(
environmentId: string,
snapshot: RuntimeMobileSessionTabsResult,
receivedFrame: number
): boolean {
const omission = this.options.omissions.get(
sessionTabsFreshnessKey(environmentId, snapshot.worktree)
)
if (!omission) {
return true
}
if (receivedFrame < omission.inventoryReceivedFrame) {
return false
}
return (
(snapshot as { removed?: unknown }).removed === true ||
advancesSessionTabsFreshness(snapshot, omission.baseline)
)
}
private missingCurrent(missing: VisibilityResumeMissing): boolean {
const omission = this.options.omissions.get(
sessionTabsFreshnessKey(missing.environmentId, missing.snapshot.worktree)
)
return (
omission?.inventoryReceivedFrame === missing.inventoryReceivedFrame && !omission.superseded
)
}
private replayableSnapshot(
batch: VisibilityResumeBatch,
environmentId: string,
worktreeId: string
): RuntimeMobileSessionTabsResult | null {
const key = sessionTabsFreshnessKey(environmentId, worktreeId)
const entry = batch.reapplyableSnapshotsByKey.get(key)
const freshness = entry ? latestSessionTabsSnapshotByWorktree.get(key) : undefined
if (
!entry ||
!freshness ||
freshness.publicationEpoch !== entry.snapshot.publicationEpoch ||
freshness.snapshotVersion !== entry.snapshot.snapshotVersion ||
!shouldApplyRecoveredWebSessionTabsSnapshot(
environmentId,
entry.snapshot,
entry.receivedFrame
)
) {
return null
}
return entry.snapshot
}
private finishIfIdle(batch: VisibilityResumeBatch): void {
if (
batch.pendingInventoryCount === 0 &&
batch.pendingMissingByWorktree.size === 0 &&
this.batch === batch
) {
this.batch = null
}
}
private reconcileWorktrees(worktreeIds: Iterable<string>): void {
const batch = this.batch
if (!batch) {
return
}
const operations: WebSessionTabsSnapshotOperation[] = []
for (const worktreeId of new Set(worktreeIds)) {
const pendingMissing = batch.pendingMissingByWorktree.get(worktreeId)
if (!pendingMissing) {
batch.deferredRepairWorktrees.delete(worktreeId)
continue
}
for (const [environmentId, missing] of pendingMissing) {
if (this.missingCurrent(missing)) {
continue
}
pendingMissing.delete(environmentId)
batch.environments.get(environmentId)?.pendingMissingWorktrees.delete(worktreeId)
}
if (pendingMissing.size === 0) {
batch.pendingMissingByWorktree.delete(worktreeId)
batch.deferredRepairWorktrees.delete(worktreeId)
continue
}
const missingEnvironmentIds = new Set(pendingMissing.keys())
const survivingSnapshots: {
environmentId: string
snapshot: RuntimeMobileSessionTabsResult
}[] = []
let canRepair = true
for (const environmentId of sessionTabsEnvironmentsByWorktree.get(worktreeId) ?? []) {
if (missingEnvironmentIds.has(environmentId)) {
continue
}
const snapshot = this.replayableSnapshot(batch, environmentId, worktreeId)
if (!snapshot) {
canRepair = false
break
}
survivingSnapshots.push({ environmentId, snapshot })
}
if (!canRepair) {
batch.deferredRepairWorktrees.add(worktreeId)
continue
}
for (const missing of pendingMissing.values()) {
operations.push({ environmentId: missing.environmentId, snapshot: missing.snapshot })
}
for (const { environmentId, snapshot } of survivingSnapshots) {
acceptReplayedWebSessionTabsSnapshot(environmentId, worktreeId)
operations.push({ environmentId, snapshot })
}
for (const environmentId of pendingMissing.keys()) {
batch.environments.get(environmentId)?.pendingMissingWorktrees.delete(worktreeId)
}
batch.pendingMissingByWorktree.delete(worktreeId)
batch.deferredRepairWorktrees.delete(worktreeId)
}
if (operations.length > 0) {
const decided = decideWebSessionTabsSnapshotOperations(operations)
const settle = applyWebSessionTabsStorePatch(
(state) => applyWebSessionTabsSnapshotOperations(state, decided),
{
frames: decided.map(({ environmentId, snapshot, decision }) => ({
environmentId,
worktreeId: snapshot.worktree,
decision,
expectedEnvironmentConnectionGeneration:
batch.environments.get(environmentId)?.expectedEnvironmentConnectionGeneration,
expectedEnvironmentPairingRevision:
batch.environments.get(environmentId)?.expectedEnvironmentPairingRevision,
expectedTrackingGeneration:
batch.environments.get(environmentId)?.expectedTrackingGeneration
}))
},
operations.map(({ snapshot }) => snapshot)
)
settle()
}
this.finishIfIdle(batch)
}
recordSnapshot(
environmentId: string,
snapshot: RuntimeMobileSessionTabsResult,
receivedFrame: number
): void {
const batch = this.batch
if (!batch || !batch.trackedWorktreeIds.has(snapshot.worktree)) {
return
}
const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree)
const existing = this.replayableSnapshot(batch, environmentId, snapshot.worktree)
const freshness = latestSessionTabsSnapshotByWorktree.get(key)
const crossHost =
(sessionTabsEnvironmentsByWorktree.get(snapshot.worktree)?.size ?? 0) > 1 ||
batch.deferredRepairWorktrees.has(snapshot.worktree)
if (
(snapshot as { removed?: unknown }).removed === true ||
snapshot.tabs.length === 0 ||
!crossHost ||
freshness?.publicationEpoch !== snapshot.publicationEpoch ||
freshness.snapshotVersion !== snapshot.snapshotVersion ||
!shouldApplyRecoveredWebSessionTabsSnapshot(environmentId, snapshot, receivedFrame)
) {
if (!existing) {
batch.reapplyableSnapshotsByKey.delete(key)
}
} else {
batch.reapplyableSnapshotsByKey.set(key, { snapshot, receivedFrame })
}
if (batch.pendingMissingByWorktree.has(snapshot.worktree)) {
this.reconcileWorktrees([snapshot.worktree])
}
}
recordInventory(
environmentId: string,
visibilityGeneration: number,
inventoryReceivedFrame: number,
missingWorktrees: readonly VisibilityResumeMissing[]
): void {
recordVisibilityResumeInventory({
batch: this.batch,
environmentId,
visibilityGeneration,
inventoryReceivedFrame,
missingWorktrees,
reconcileWorktrees: (worktreeIds) => this.reconcileWorktrees(worktreeIds)
})
}
recordInventoryReceipt(
environmentId: string,
visibilityGeneration: number,
inventoryReceivedFrame: number,
snapshots: readonly RuntimeMobileSessionTabsResult[]
): VisibilityResumeMissing[] {
return recordVisibilityResumeInventoryReceipt({
batch: this.batch,
omissions: this.options.omissions,
environmentId,
visibilityGeneration,
inventoryReceivedFrame,
snapshots
})
}
beginVisibilityResume(
visibilityGeneration: number,
restartingSpecIndexes: readonly number[]
): void {
this.batch = buildVisibilityResumeBatch({
visibilityGeneration,
restartingSpecIndexes,
environmentIdBySubscriptionSpec: this.options.environmentIdBySubscriptionSpec,
environments: this.options.environments,
omissions: this.options.omissions,
activeRuntimeWorktreeKey: this.options.activeRuntimeWorktreeKey
})
}
}
@@ -0,0 +1,105 @@
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import {
buildMissingWebSessionTabsRemovals,
recordReceivedWebSessionTabsRemoval,
sessionTabsFreshnessKey
} from './tracking'
import type { VisibilityResumeOmission } from './state'
import type { VisibilityResumeBatch, VisibilityResumeMissing } from './visibility-resume-types'
export function recordVisibilityResumeInventoryReceipt(args: {
batch: VisibilityResumeBatch | null
omissions: Map<string, VisibilityResumeOmission>
environmentId: string
visibilityGeneration: number
inventoryReceivedFrame: number
snapshots: readonly RuntimeMobileSessionTabsResult[]
}): VisibilityResumeMissing[] {
const {
batch,
omissions,
environmentId,
visibilityGeneration,
inventoryReceivedFrame,
snapshots
} = args
for (const snapshot of snapshots) {
omissions.delete(sessionTabsFreshnessKey(environmentId, snapshot.worktree))
}
if (!batch || batch.visibilityGeneration !== visibilityGeneration) {
return []
}
const environment = batch.environments.get(environmentId)
if (!environment) {
return []
}
environment.latestInventoryReceivedFrame = Math.max(
environment.latestInventoryReceivedFrame,
inventoryReceivedFrame
)
if (environment.latestInventoryReceivedFrame !== inventoryReceivedFrame) {
return []
}
const publishedWorktrees = new Set(snapshots.map((snapshot) => snapshot.worktree))
return buildMissingWebSessionTabsRemovals(
environmentId,
environment.trackedWorktrees,
publishedWorktrees
).map((missing) => {
const key = sessionTabsFreshnessKey(environmentId, missing.snapshot.worktree)
omissions.set(key, {
baseline: missing.trackedWorktree.freshness,
environmentId,
inventoryReceivedFrame,
superseded: false,
visibilityGeneration
})
recordReceivedWebSessionTabsRemoval(
environmentId,
missing.snapshot.worktree,
inventoryReceivedFrame
)
return { environmentId, inventoryReceivedFrame, ...missing }
})
}
export function recordVisibilityResumeInventory(args: {
batch: VisibilityResumeBatch | null
environmentId: string
visibilityGeneration: number
inventoryReceivedFrame: number
missingWorktrees: readonly VisibilityResumeMissing[]
reconcileWorktrees: (worktreeIds: Iterable<string>) => void
}): void {
const { batch, environmentId, visibilityGeneration, inventoryReceivedFrame, missingWorktrees } =
args
if (!batch || visibilityGeneration === 0 || batch.visibilityGeneration !== visibilityGeneration) {
return
}
const environment = batch.environments.get(environmentId)
if (!environment || environment.latestInventoryReceivedFrame !== inventoryReceivedFrame) {
return
}
const affectedWorktrees = new Set(environment.pendingMissingWorktrees)
for (const worktreeId of environment.pendingMissingWorktrees) {
const pendingMissing = batch.pendingMissingByWorktree.get(worktreeId)
pendingMissing?.delete(environmentId)
if (pendingMissing?.size === 0) {
batch.pendingMissingByWorktree.delete(worktreeId)
}
}
environment.pendingMissingWorktrees.clear()
for (const missing of missingWorktrees) {
const worktreeId = missing.snapshot.worktree
const pendingMissing = batch.pendingMissingByWorktree.get(worktreeId) ?? new Map()
pendingMissing.set(environmentId, missing)
batch.pendingMissingByWorktree.set(worktreeId, pendingMissing)
environment.pendingMissingWorktrees.add(worktreeId)
affectedWorktrees.add(worktreeId)
}
if (!environment.inventoryReceived) {
environment.inventoryReceived = true
batch.pendingInventoryCount -= 1
}
args.reconcileWorktrees(affectedWorktrees)
}
@@ -0,0 +1,41 @@
import type {
RuntimeMobileSessionTabsResult,
RuntimeMobileSessionTabsRemovedResult
} from '../../../../shared/runtime-types'
import type { TrackedWebSessionTabsWorktree } from './state'
export type VisibilityResumeEnvironment = {
trackedWorktrees: readonly TrackedWebSessionTabsWorktree[]
inventoryReceived: boolean
latestInventoryReceivedFrame: number
pendingMissingWorktrees: Set<string>
expectedEnvironmentConnectionGeneration: number
expectedEnvironmentPairingRevision?: number
expectedTrackingGeneration: number
}
export type VisibilityResumeMissing = {
environmentId: string
inventoryReceivedFrame: number
trackedWorktree: TrackedWebSessionTabsWorktree
snapshot: RuntimeMobileSessionTabsRemovedResult
}
export type VisibilityResumeBatch = {
visibilityGeneration: number
environments: Map<string, VisibilityResumeEnvironment>
pendingInventoryCount: number
pendingMissingByWorktree: Map<string, Map<string, VisibilityResumeMissing>>
deferredRepairWorktrees: Set<string>
trackedWorktreeIds: ReadonlySet<string>
reapplyableSnapshotsByKey: Map<
string,
{ snapshot: RuntimeMobileSessionTabsResult; receivedFrame: number }
>
}
export type MirroredRuntimeEnvironment = {
environmentId: string
expectedEnvironmentConnectionGeneration: number
expectedEnvironmentPairingRevision?: number
}
@@ -0,0 +1,6 @@
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
/** The floating terminal is local-only and never belongs to a host mirror. */
export function isHostMirroredWorktree(worktreeId: string): boolean {
return worktreeId !== FLOATING_TERMINAL_WORKTREE_ID
}
@@ -0,0 +1,218 @@
import type { AgentStatusSlice } from './agent-status-slice-contract'
import type { AgentStatusRuntime } from './agent-status-runtime'
import { rendererAgentStatusObservations } from '../../lib/renderer-agent-status-observations'
import {
resolveAgentPaneAuthorityKey,
retireAgentPaneAuthorityAliases,
transferAgentPaneAuthorityAlias
} from './agent-pane-authority'
import {
boundRecentlyRetiredAgentStatusPaneKeys,
movePaneKeyedRecord,
removePaneKeys
} from './agent-status-map-helpers'
import {
getLeafIdFromPaneKey,
getTabIdFromPaneKey,
isRecentlyClosedAgentStatusTab
} from './agent-status-pane-helpers'
export function createAgentStatusAuthorityActions(
runtime: AgentStatusRuntime
): Pick<
AgentStatusSlice,
| 'scheduleAgentStatusFreshness'
| 'retireAgentPaneAuthority'
| 'restoreAgentPaneAuthority'
| 'transferAgentPaneAuthority'
> {
const { get, set, freshness } = runtime
return {
scheduleAgentStatusFreshness: () => freshness.schedule(),
retireAgentPaneAuthority: (paneKey, options) => {
const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey)
const retiredPaneKeys = retireAgentPaneAuthorityAliases(paneKey)
const retiredPaneKeySet = new Set(retiredPaneKeys)
for (const key of retiredPaneKeys) {
rendererAgentStatusObservations.forget(key)
}
let hadLive = false
set((s) => {
const retiredLivePaneKeys = retiredPaneKeys.filter((key) => key in s.agentStatusByPaneKey)
hadLive = retiredLivePaneKeys.length > 0
let nextRetentionSuppressedPaneKeys = removePaneKeys(
s.retentionSuppressedPaneKeys,
retiredPaneKeySet
)
if (
retiredLivePaneKeys.length > 0 &&
nextRetentionSuppressedPaneKeys === s.retentionSuppressedPaneKeys
) {
nextRetentionSuppressedPaneKeys = { ...nextRetentionSuppressedPaneKeys }
}
for (const key of retiredLivePaneKeys) {
nextRetentionSuppressedPaneKeys[key] = true
}
return {
agentStatusByPaneKey: removePaneKeys(s.agentStatusByPaneKey, retiredPaneKeySet),
runtimeAgentOrchestrationByPaneKey: removePaneKeys(
s.runtimeAgentOrchestrationByPaneKey,
retiredPaneKeySet
),
retainedAgentsByPaneKey: removePaneKeys(s.retainedAgentsByPaneKey, retiredPaneKeySet),
sleepingAgentSessionsByPaneKey: options?.preserveSleepingAgentSession
? s.sleepingAgentSessionsByPaneKey
: removePaneKeys(s.sleepingAgentSessionsByPaneKey, retiredPaneKeySet),
agentLaunchConfigByPaneKey: removePaneKeys(
s.agentLaunchConfigByPaneKey,
retiredPaneKeySet
),
acknowledgedAgentsByPaneKey: removePaneKeys(
s.acknowledgedAgentsByPaneKey,
retiredPaneKeySet
),
paneForegroundAgentByPaneKey: removePaneKeys(
s.paneForegroundAgentByPaneKey,
retiredPaneKeySet
),
unreadTerminalPanes: removePaneKeys(s.unreadTerminalPanes, retiredPaneKeySet),
unreadAgentCompletionPanes: removePaneKeys(
s.unreadAgentCompletionPanes,
retiredPaneKeySet
),
lastTerminalInputAtByPaneKey: removePaneKeys(
s.lastTerminalInputAtByPaneKey,
retiredPaneKeySet
),
cacheTimerByKey: removePaneKeys(s.cacheTimerByKey, retiredPaneKeySet),
retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys,
recentlyRetiredAgentStatusPaneKeys: boundRecentlyRetiredAgentStatusPaneKeys(
s.recentlyRetiredAgentStatusPaneKeys,
retiredPaneKeys
),
agentStatusEpoch: hadLive ? s.agentStatusEpoch + 1 : s.agentStatusEpoch,
sortEpoch: hadLive ? s.sortEpoch + 1 : s.sortEpoch
}
})
if (hadLive) {
freshness.scheduleDeferred()
}
if (typeof window !== 'undefined') {
window.api?.agentStatus?.retirePaneAuthority?.(ownerPaneKey)
}
},
// Why: the tombstone claims this pane is gone; a live PTY binding to it proves
// otherwise. Lift the fence on that proof rather than on the next hook event —
// a pane re-attached mid-turn or while idle emits no new-turn event, so a
// turn-triggered revival leaves exactly the reported permanent suppression
// (STA-4114). This deliberately does NOT restore the rows retirement dropped;
// those are genuinely stale. It only re-opens the pane to future status.
restoreAgentPaneAuthority: (paneKey) => {
const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey)
// Why: a closed tab is a stronger, separate claim — re-attach must not undo it.
if (
isRecentlyClosedAgentStatusTab(
get().recentlyClosedAgentStatusTabIds,
getTabIdFromPaneKey(ownerPaneKey)
)
) {
return
}
set((s) => {
const restorable = [paneKey, ownerPaneKey].filter(
(key) => key in s.recentlyRetiredAgentStatusPaneKeys
)
if (restorable.length === 0) {
return s
}
const next = { ...s.recentlyRetiredAgentStatusPaneKeys }
for (const key of restorable) {
delete next[key]
}
return { recentlyRetiredAgentStatusPaneKeys: next }
})
// Why: deliberately OUTSIDE the guard above, and not gated on having cleared
// anything here. This map is not a mirror of main's — main fences panes the
// renderer never hears about (retirePtyAgentLaunchAuthority on command-finished
// and PTY exit calls the hook server directly, and nothing pushes that back), and
// this map is per-window and non-persisted, so a renderer reload empties it while
// main's survives. Gating the send on a local tombstone reintroduces STA-4114 for
// exactly those panes. The send is idempotent and main refuses closed tabs itself.
if (typeof window !== 'undefined') {
window.api?.agentStatus?.restorePaneAuthority?.(ownerPaneKey)
}
},
transferAgentPaneAuthority: ({ fromPaneKey, toPaneKey, ptyId }) => {
const transfer = transferAgentPaneAuthorityAlias({ fromPaneKey, toPaneKey, ptyId })
if (!transfer || transfer.previousOwnerPaneKey === transfer.ownerPaneKey) {
return
}
const from = transfer.previousOwnerPaneKey
const to = transfer.ownerPaneKey
// Why: the moved row carries the observation stamped for its OLD key; renderer-authored
// observations for the new key must sort after it, not race it.
rendererAgentStatusObservations.forget(from)
rendererAgentStatusObservations.rebind(to)
const targetTabId = getTabIdFromPaneKey(to) ?? undefined
const targetLeafId = getLeafIdFromPaneKey(to) ?? undefined
set((s) => ({
agentStatusByPaneKey: movePaneKeyedRecord(s.agentStatusByPaneKey, from, to, (entry) => ({
...entry,
paneKey: to,
tabId: targetTabId
})),
// Why: retention/sidebar consumers gate on the epoch; a moved live row is a
// pane-key change they must observe, not a silent remap.
...(from in s.agentStatusByPaneKey
? { agentStatusEpoch: s.agentStatusEpoch + 1, sortEpoch: s.sortEpoch + 1 }
: {}),
runtimeAgentOrchestrationByPaneKey: movePaneKeyedRecord(
s.runtimeAgentOrchestrationByPaneKey,
from,
to
),
retainedAgentsByPaneKey: movePaneKeyedRecord(
s.retainedAgentsByPaneKey,
from,
to,
(retained) => ({
...retained,
entry: { ...retained.entry, paneKey: to, tabId: targetTabId },
tab: targetTabId ? { ...retained.tab, id: targetTabId } : retained.tab
})
),
sleepingAgentSessionsByPaneKey: movePaneKeyedRecord(
s.sleepingAgentSessionsByPaneKey,
from,
to,
(record) => ({ ...record, paneKey: to, tabId: targetTabId })
),
agentLaunchConfigByPaneKey: movePaneKeyedRecord(
s.agentLaunchConfigByPaneKey,
from,
to,
(entry) => ({
...entry,
identity: { ...entry.identity, tabId: targetTabId, leafId: targetLeafId }
})
),
acknowledgedAgentsByPaneKey: movePaneKeyedRecord(s.acknowledgedAgentsByPaneKey, from, to),
paneForegroundAgentByPaneKey: movePaneKeyedRecord(s.paneForegroundAgentByPaneKey, from, to),
unreadTerminalPanes: movePaneKeyedRecord(s.unreadTerminalPanes, from, to),
unreadAgentCompletionPanes: movePaneKeyedRecord(s.unreadAgentCompletionPanes, from, to),
lastTerminalInputAtByPaneKey: movePaneKeyedRecord(s.lastTerminalInputAtByPaneKey, from, to),
cacheTimerByKey: movePaneKeyedRecord(s.cacheTimerByKey, from, to),
retentionSuppressedPaneKeys: movePaneKeyedRecord(s.retentionSuppressedPaneKeys, from, to)
}))
if (typeof window !== 'undefined') {
window.api?.agentStatus?.transferPaneAuthority?.({
fromPaneKey: from,
toPaneKey: to,
...(transfer.ptyId ? { ptyId: transfer.ptyId } : {})
})
}
}
}
}
@@ -0,0 +1,197 @@
import type { AgentStatusSlice } from './agent-status-slice-contract'
import type { AgentStatusRuntime } from './agent-status-runtime'
import {
collectWorktreeIdsForConnection,
pruneMigrationUnsupportedEntries
} from './agent-status-map-helpers'
/** Actions for removing transient rows and migration-era cache entries. */
export function createAgentStatusCleanupActions(
runtime: AgentStatusRuntime
): Pick<
AgentStatusSlice,
| 'setMigrationUnsupportedPty'
| 'clearMigrationUnsupportedPty'
| 'removeAgentStatus'
| 'removeAgentStatusByTabPrefix'
| 'clearTransientAgentStatuses'
> {
const { get, set, freshness } = runtime
return {
setMigrationUnsupportedPty: (entry) => {
set((s) => {
const existing = s.migrationUnsupportedByPtyId[entry.ptyId]
if (existing && entry.updatedAt < existing.updatedAt) {
return s
}
return {
migrationUnsupportedByPtyId: {
...s.migrationUnsupportedByPtyId,
[entry.ptyId]: entry
},
agentStatusEpoch: s.agentStatusEpoch + 1,
sortEpoch: s.sortEpoch + 1
}
})
},
clearMigrationUnsupportedPty: (ptyId) => {
if (!(ptyId in get().migrationUnsupportedByPtyId)) {
return
}
set((s) => {
const next = { ...s.migrationUnsupportedByPtyId }
delete next[ptyId]
return {
migrationUnsupportedByPtyId: next,
agentStatusEpoch: s.agentStatusEpoch + 1,
sortEpoch: s.sortEpoch + 1
}
})
},
removeAgentStatus: (paneKey) => {
const current = get()
if (
!(paneKey in current.agentStatusByPaneKey) &&
!(paneKey in current.agentLaunchConfigByPaneKey) &&
!Object.values(current.migrationUnsupportedByPtyId).some(
(entry) => entry.paneKey === paneKey
)
) {
return
}
set((s) => {
const hasLive = paneKey in s.agentStatusByPaneKey
const next = hasLive ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey
if (hasLive) {
delete next[paneKey]
}
const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey
const nextLaunchConfigs = hasLaunchConfig
? { ...s.agentLaunchConfigByPaneKey }
: s.agentLaunchConfigByPaneKey
if (hasLaunchConfig) {
delete nextLaunchConfigs[paneKey]
}
const migrationUnsupported = pruneMigrationUnsupportedEntries(
s.migrationUnsupportedByPtyId,
(entry) => entry.paneKey === paneKey
)
// Ack entries belong to the pane lifecycle; never let a reused key inherit one.
let nextAck = s.acknowledgedAgentsByPaneKey
if (paneKey in nextAck) {
nextAck = { ...nextAck }
delete nextAck[paneKey]
}
return {
agentStatusByPaneKey: next,
agentLaunchConfigByPaneKey: nextLaunchConfigs,
migrationUnsupportedByPtyId: migrationUnsupported.next,
...(nextAck !== s.acknowledgedAgentsByPaneKey
? { acknowledgedAgentsByPaneKey: nextAck }
: {}),
agentStatusEpoch: s.agentStatusEpoch + 1,
sortEpoch: s.sortEpoch + 1
}
})
freshness.scheduleDeferred()
},
removeAgentStatusByTabPrefix: (tabIdPrefix) => {
const prefix = `${tabIdPrefix}:`
const current = get()
const toRemove = Object.keys(current.agentStatusByPaneKey).filter((key) =>
key.startsWith(prefix)
)
const launchConfigKeys = Object.keys(current.agentLaunchConfigByPaneKey).filter((key) =>
key.startsWith(prefix)
)
const hasMigrationUnsupported = Object.values(current.migrationUnsupportedByPtyId).some(
(entry) => entry.paneKey?.startsWith(prefix)
)
if (toRemove.length === 0 && launchConfigKeys.length === 0 && !hasMigrationUnsupported) {
return
}
set((s) => {
const next = { ...s.agentStatusByPaneKey }
for (const key of toRemove) {
delete next[key]
}
const nextLaunchConfigs = { ...s.agentLaunchConfigByPaneKey }
for (const key of launchConfigKeys) {
delete nextLaunchConfigs[key]
}
const migrationUnsupported = pruneMigrationUnsupportedEntries(
s.migrationUnsupportedByPtyId,
(entry) => entry.paneKey?.startsWith(prefix) ?? false
)
let nextAck = s.acknowledgedAgentsByPaneKey
const ackKeys = Object.keys(nextAck).filter((key) => key.startsWith(prefix))
if (ackKeys.length > 0) {
nextAck = { ...nextAck }
for (const key of ackKeys) {
delete nextAck[key]
}
}
return {
agentStatusByPaneKey: next,
agentLaunchConfigByPaneKey: nextLaunchConfigs,
migrationUnsupportedByPtyId: migrationUnsupported.next,
...(nextAck !== s.acknowledgedAgentsByPaneKey
? { acknowledgedAgentsByPaneKey: nextAck }
: {}),
agentStatusEpoch: s.agentStatusEpoch + 1,
sortEpoch: s.sortEpoch + 1
}
})
freshness.scheduleDeferred()
},
clearTransientAgentStatuses: (connectionId, clearedAt) => {
if (connectionId.length === 0 || !Number.isFinite(clearedAt)) {
return
}
let removed = false
set((s) => {
const worktreeIdsOnConnection = collectWorktreeIdsForConnection(s, connectionId)
let next: Record<string, (typeof s.agentStatusByPaneKey)[string]> | null = null
for (const [paneKey, existing] of Object.entries(s.agentStatusByPaneKey)) {
if (existing.updatedAt > clearedAt) {
continue
}
const belongsToConnection =
existing.connectionId === connectionId ||
(existing.connectionId === undefined &&
existing.worktreeId !== undefined &&
worktreeIdsOnConnection.has(existing.worktreeId))
if (!belongsToConnection) {
continue
}
next ??= { ...s.agentStatusByPaneKey }
delete next[paneKey]
}
const alreadyBlocked = connectionId in s.transientClearedAgentStatusConnectionIds
if (!next && alreadyBlocked) {
return s
}
removed = next !== null
return {
...(next
? {
agentStatusByPaneKey: next,
agentStatusEpoch: s.agentStatusEpoch + 1,
sortEpoch: s.sortEpoch + 1
}
: {}),
transientClearedAgentStatusConnectionIds: alreadyBlocked
? s.transientClearedAgentStatusConnectionIds
: { ...s.transientClearedAgentStatusConnectionIds, [connectionId]: true }
}
})
if (removed) {
freshness.scheduleDeferred()
}
}
}
}
@@ -0,0 +1,149 @@
import type { AppState } from '../types'
import type {
AgentStateHistoryEntry,
AgentStatusEntry,
AgentStatusOrchestrationContext,
AgentType,
MigrationUnsupportedPtyEntry,
ParsedAgentStatusPayload
} from '../../../../shared/agent-status-types'
import type { AgentStatusObservation } from '../../../../shared/agent-status-observation'
import type {
AgentProviderSessionMetadata,
ResumableTuiAgent,
SleepingAgentLaunchConfig,
SleepingAgentSessionRecord
} from '../../../../shared/agent-session-resume'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
export type RetainedAgentEntry = {
entry: AgentStatusEntry
worktreeId: string
/** Snapshot of the tab at retention time; kept full (not just an id) because the tab may be gone from `tabsByWorktree` by render time. */
tab: TerminalTab
agentType: AgentType
startedAt: number
}
export type AgentStatusWorktreeShutdownReason =
| 'manual-sleep'
| 'remove-worktree'
| 'auto-hibernate-completed-agent'
export type AllAgentSessionCaptureMode = 'periodic' | 'quit'
export type DropAgentStatusByWorktreeOptions = {
shutdownReason?: AgentStatusWorktreeShutdownReason
sleepingPaneKeys?: readonly string[] | ReadonlySet<string>
retainedCompletionEvidence?: readonly RetainedAgentEntry[]
}
export type DropHibernatedAgentPaneOptions = {
retainedCompletionEvidence?: readonly RetainedAgentEntry[]
}
export type DropAgentStatusByTabPrefixOptions = {
worktreeId?: string
}
export type AgentLaunchConfigRegistrationMetadata = {
agentType?: AgentType
launchToken?: string
tabId?: string
leafId?: string
terminalHandle?: string
providerSession?: AgentProviderSessionMetadata
}
export type AgentLaunchConfigStatusMetadata = {
paneKey: string
agentType?: AgentType
tabId?: string
terminalHandle?: string
launchToken?: string
providerSession?: AgentProviderSessionMetadata
existingProviderSession?: AgentProviderSessionMetadata
providerSessionChanged?: boolean
}
export type AgentLaunchConfigRegistryEntry = {
launchConfig: SleepingAgentLaunchConfig
registeredAt: number
identity: AgentLaunchConfigRegistrationMetadata
}
export type AgentStatusPayload = ParsedAgentStatusPayload & {
orchestration?: AgentStatusOrchestrationContext
promptInteractionKey?: string
restoredUnconfirmed?: boolean
/** Ingress provenance for this write (STA-4293). Read by nothing yet; a caller that omits
* it produces exactly the entry it produces today. See agent-status-observation.ts. */
observation?: AgentStatusObservation
}
export type AgentStatusTiming = { updatedAt?: number; stateStartedAt?: number }
export type AgentStatusRouting = {
tabId?: string
worktreeId?: string
terminalHandle?: string
connectionId?: string | null
}
export type AgentStatusMetadata = {
providerSession?: AgentProviderSessionMetadata
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
terminalResumeEligible?: false
}
export type AgentStatusUpdate = {
kind?: 'status'
paneKey: string
payload: AgentStatusPayload
terminalTitle?: string
timing?: AgentStatusTiming
routing?: AgentStatusRouting
metadata?: AgentStatusMetadata
}
export type AgentProviderSessionTiming = { updatedAt?: number }
export type AgentProviderSessionRouting = {
tabId?: string
worktreeId?: string
connectionId?: string | null
}
export type AgentProviderSessionRecordMetadata = { launchToken?: string }
export type AgentProviderSessionUpdate = {
kind: 'providerSession'
paneKey: string
agent: ResumableTuiAgent
providerSession: AgentProviderSessionMetadata
timing?: AgentProviderSessionTiming
routing?: AgentProviderSessionRouting
metadata?: AgentProviderSessionRecordMetadata
}
export type AgentStatusBatchUpdate = AgentStatusUpdate | AgentProviderSessionUpdate
export type AgentStatusBatchTransaction = {
getState: () => AppState
apply: (update: AgentStatusBatchUpdate) => boolean
afterCommit: (effect: () => void) => void
}
export type {
AgentStateHistoryEntry,
AgentStatusEntry,
AgentStatusOrchestrationContext,
AgentType,
MigrationUnsupportedPtyEntry,
ParsedAgentStatusPayload,
AgentProviderSessionMetadata,
ResumableTuiAgent,
SleepingAgentLaunchConfig,
SleepingAgentSessionRecord
}
@@ -0,0 +1,225 @@
import type {
RetainedAgentEntry,
DropAgentStatusByTabPrefixOptions,
DropHibernatedAgentPaneOptions
} from './agent-status-contract'
import type { AgentStatusSlice } from './agent-status-slice-contract'
import type { AgentStatusRuntime } from './agent-status-runtime'
import { buildAgentStatusTabPrefixDropPatch } from './agent-status-drop-reducer'
import { pruneMigrationUnsupportedEntries } from './agent-status-map-helpers'
import {
retainedAgentEntryFromLive,
shouldReplaceRetainedWithLive
} from './agent-status-pane-helpers'
import { retireAgentPaneAuthorityAliasesByOwnerTab } from './agent-pane-authority'
function removeAcknowledgement(
acknowledgements: Record<string, number>,
paneKey: string
): Record<string, number> {
if (!(paneKey in acknowledgements)) {
return acknowledgements
}
const next = { ...acknowledgements }
delete next[paneKey]
return next
}
export function createAgentStatusDropActions(
runtime: AgentStatusRuntime
): Pick<
AgentStatusSlice,
'dropAgentStatus' | 'dropAgentStatusByTabPrefix' | 'dropHibernatedAgentStatusPane'
> {
const { set, freshness } = runtime
return {
dropAgentStatus: (paneKey) => {
let liveExisted = false
set((s) => {
const hasLive = paneKey in s.agentStatusByPaneKey
liveExisted = hasLive
const hasRetained = paneKey in s.retainedAgentsByPaneKey
const migrationUnsupported = pruneMigrationUnsupportedEntries(
s.migrationUnsupportedByPtyId,
(entry) => entry.paneKey === paneKey
)
const nextAck = removeAcknowledgement(s.acknowledgedAgentsByPaneKey, paneKey)
const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey
const nextLaunchConfigs = hasLaunchConfig
? { ...s.agentLaunchConfigByPaneKey }
: s.agentLaunchConfigByPaneKey
if (hasLaunchConfig) {
delete nextLaunchConfigs[paneKey]
}
if (!hasLive && !hasRetained && !migrationUnsupported.changed) {
if (hasLaunchConfig) {
return {
agentLaunchConfigByPaneKey: nextLaunchConfigs,
...(nextAck !== s.acknowledgedAgentsByPaneKey
? { acknowledgedAgentsByPaneKey: nextAck }
: {})
}
}
return nextAck !== s.acknowledgedAgentsByPaneKey
? { acknowledgedAgentsByPaneKey: nextAck }
: s
}
const nextLive = hasLive ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey
if (hasLive) {
delete nextLive[paneKey]
}
const nextRetained = hasRetained
? { ...s.retainedAgentsByPaneKey }
: s.retainedAgentsByPaneKey
if (hasRetained) {
delete nextRetained[paneKey]
}
const needsSuppressor = hasLive && !(paneKey in s.retentionSuppressedPaneKeys)
return {
agentStatusByPaneKey: nextLive,
agentLaunchConfigByPaneKey: nextLaunchConfigs,
retainedAgentsByPaneKey: nextRetained,
migrationUnsupportedByPtyId: migrationUnsupported.next,
...(nextAck !== s.acknowledgedAgentsByPaneKey
? { acknowledgedAgentsByPaneKey: nextAck }
: {}),
...(needsSuppressor
? {
retentionSuppressedPaneKeys: {
...s.retentionSuppressedPaneKeys,
[paneKey]: true
}
}
: {}),
agentStatusEpoch:
hasLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch,
sortEpoch: hasLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch
}
})
if (liveExisted) {
freshness.scheduleDeferred()
}
if (typeof window !== 'undefined') {
window.api?.agentStatus?.drop?.(paneKey)
}
},
dropAgentStatusByTabPrefix: (tabIdPrefix, opts?: DropAgentStatusByTabPrefixOptions) => {
const retiredAliasPaneKeys = retireAgentPaneAuthorityAliasesByOwnerTab(tabIdPrefix)
let hadLive = false
set((s) => {
const dropped = buildAgentStatusTabPrefixDropPatch(
s,
tabIdPrefix,
retiredAliasPaneKeys,
opts
)
hadLive = dropped.hadLive
return dropped.patch
})
if (hadLive) {
freshness.scheduleDeferred()
}
if (typeof window !== 'undefined') {
window.api?.agentStatus?.dropByTabPrefix?.(tabIdPrefix)
}
},
dropHibernatedAgentStatusPane: (worktreeId, paneKey, opts?: DropHibernatedAgentPaneOptions) => {
let hadLive = false
set((s) => {
const liveEntry = s.agentStatusByPaneKey[paneKey]
const hasLive = liveEntry !== undefined
const hasRetained = paneKey in s.retainedAgentsByPaneKey
const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey
const migrationUnsupported = pruneMigrationUnsupportedEntries(
s.migrationUnsupportedByPtyId,
(entry) => entry.paneKey === paneKey
)
const retainedEvidence = new Map<string, RetainedAgentEntry>()
for (const retained of opts?.retainedCompletionEvidence ?? []) {
if (
retained.entry.paneKey === paneKey &&
!liveEntry &&
shouldReplaceRetainedWithLive(retainedEvidence.get(paneKey), retained)
) {
retainedEvidence.set(paneKey, retained)
}
}
if (
liveEntry?.state === 'done' &&
liveEntry.agentType !== undefined &&
liveEntry.interrupted !== true
) {
retainedEvidence.set(
paneKey,
retainedAgentEntryFromLive(s, worktreeId, liveEntry, liveEntry.agentType)
)
}
const keepsCompletionEvidence = retainedEvidence.has(paneKey)
const nextAck = !keepsCompletionEvidence
? removeAcknowledgement(s.acknowledgedAgentsByPaneKey, paneKey)
: s.acknowledgedAgentsByPaneKey
if (
!hasLive &&
!hasRetained &&
!hasLaunchConfig &&
!migrationUnsupported.changed &&
!keepsCompletionEvidence
) {
return nextAck !== s.acknowledgedAgentsByPaneKey
? { acknowledgedAgentsByPaneKey: nextAck }
: s
}
hadLive = hasLive
const nextLive = hasLive ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey
if (hasLive) {
delete nextLive[paneKey]
}
const nextLaunchConfigs = hasLaunchConfig
? { ...s.agentLaunchConfigByPaneKey }
: s.agentLaunchConfigByPaneKey
if (hasLaunchConfig) {
delete nextLaunchConfigs[paneKey]
}
const nextRetained =
hasRetained || keepsCompletionEvidence
? { ...s.retainedAgentsByPaneKey }
: s.retainedAgentsByPaneKey
if (hasRetained && !keepsCompletionEvidence) {
delete nextRetained[paneKey]
}
for (const [key, retained] of retainedEvidence) {
if (shouldReplaceRetainedWithLive(nextRetained[key], retained)) {
nextRetained[key] = retained
}
}
const needsSuppressor =
hasLive && !keepsCompletionEvidence && !(paneKey in s.retentionSuppressedPaneKeys)
return {
agentStatusByPaneKey: nextLive,
agentLaunchConfigByPaneKey: nextLaunchConfigs,
retainedAgentsByPaneKey: nextRetained,
migrationUnsupportedByPtyId: migrationUnsupported.next,
...(nextAck !== s.acknowledgedAgentsByPaneKey
? { acknowledgedAgentsByPaneKey: nextAck }
: {}),
...(needsSuppressor
? {
retentionSuppressedPaneKeys: {
...s.retentionSuppressedPaneKeys,
[paneKey]: true
}
}
: {}),
agentStatusEpoch:
hasLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch,
sortEpoch: hasLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch
}
})
if (hadLive) {
freshness.scheduleDeferred()
}
}
}
}
@@ -0,0 +1,159 @@
import type { AppState } from '../types'
import type { DropAgentStatusByTabPrefixOptions } from './agent-status-contract'
import {
boundRecentlyClosedAgentStatusTabIds,
boundRecentlyRetiredAgentStatusPaneKeys,
pruneMigrationUnsupportedEntries
} from './agent-status-map-helpers'
import { findCompletedOrphanPaneKeysForTabClose } from './agent-status-pane-helpers'
/** Slices that only the fold touched, so the batch commits as a MERGE. */
export function buildAgentStatusBatchPatch(
initialState: AppState,
nextState: AppState
): Partial<AppState> {
const patch: Record<string, unknown> = {}
for (const key of Object.keys(nextState) as (keyof AppState)[]) {
if (!Object.is(nextState[key], initialState[key])) {
patch[key as string] = nextState[key]
}
}
return patch as Partial<AppState>
}
/** The slice of app state the tab-prefix drop reduces over — narrow so callers
* outside the store (the paired snapshot apply) can build the same patch. */
export type AgentStatusTabPrefixDropState = Pick<
AppState,
| 'acknowledgedAgentsByPaneKey'
| 'agentLaunchConfigByPaneKey'
| 'agentStatusByPaneKey'
| 'agentStatusEpoch'
| 'migrationUnsupportedByPtyId'
| 'recentlyClosedAgentStatusTabIds'
| 'recentlyRetiredAgentStatusPaneKeys'
| 'retainedAgentsByPaneKey'
| 'retentionSuppressedPaneKeys'
| 'sortEpoch'
| 'tabsByWorktree'
>
/** Pure form of the dropAgentStatusByTabPrefix reducer: the paired snapshot
* apply folds the same sweep into a patch it assembles itself, so the two
* paths cannot drift. `retiredAliasPaneKeys` comes from the caller because
* retiring pane-authority aliases is a registry side effect, not a reduction. */
export function buildAgentStatusTabPrefixDropPatch(
s: AgentStatusTabPrefixDropState,
tabIdPrefix: string,
retiredAliasPaneKeys: readonly string[],
opts?: DropAgentStatusByTabPrefixOptions
): { patch: Partial<AgentStatusTabPrefixDropState>; hadLive: boolean } {
const prefix = `${tabIdPrefix}:`
let hadLive = false
const buildPatch = (): Partial<AgentStatusTabPrefixDropState> => {
const completedOrphanKeys = findCompletedOrphanPaneKeysForTabClose(s, opts?.worktreeId, prefix)
const completedOrphanKeySet = new Set(completedOrphanKeys)
const liveKeys = [
...Object.keys(s.agentStatusByPaneKey).filter((k) => k.startsWith(prefix)),
...completedOrphanKeys
]
const launchConfigKeys = Object.keys(s.agentLaunchConfigByPaneKey).filter(
(k) => k.startsWith(prefix) || completedOrphanKeySet.has(k)
)
const retainedKeys = Object.keys(s.retainedAgentsByPaneKey).filter(
(k) => k.startsWith(prefix) || completedOrphanKeySet.has(k)
)
const migrationUnsupported = pruneMigrationUnsupportedEntries(
s.migrationUnsupportedByPtyId,
(entry) => entry.paneKey?.startsWith(prefix) ?? false
)
// See removeAgentStatus for ack-cleanup rationale; ack entries are owned by the pane lifecycle regardless of live/retained state.
let nextAck = s.acknowledgedAgentsByPaneKey
const ackKeys = Object.keys(nextAck).filter(
(k) => k.startsWith(prefix) || completedOrphanKeySet.has(k)
)
if (ackKeys.length > 0) {
nextAck = { ...nextAck }
for (const k of ackKeys) {
delete nextAck[k]
}
}
const nextClosedTabs = boundRecentlyClosedAgentStatusTabIds(
s.recentlyClosedAgentStatusTabIds,
tabIdPrefix
)
const nextRetiredPaneKeys = boundRecentlyRetiredAgentStatusPaneKeys(
s.recentlyRetiredAgentStatusPaneKeys,
retiredAliasPaneKeys
)
if (
liveKeys.length === 0 &&
launchConfigKeys.length === 0 &&
retainedKeys.length === 0 &&
!migrationUnsupported.changed
) {
if (nextAck !== s.acknowledgedAgentsByPaneKey) {
return {
acknowledgedAgentsByPaneKey: nextAck,
recentlyClosedAgentStatusTabIds: nextClosedTabs,
recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys
}
}
return {
recentlyClosedAgentStatusTabIds: nextClosedTabs,
recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys
}
}
hadLive = liveKeys.length > 0
const nextLive = liveKeys.length > 0 ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey
for (const key of liveKeys) {
delete nextLive[key]
}
const nextLaunchConfigs =
launchConfigKeys.length > 0
? { ...s.agentLaunchConfigByPaneKey }
: s.agentLaunchConfigByPaneKey
for (const key of launchConfigKeys) {
delete nextLaunchConfigs[key]
}
const nextRetained =
retainedKeys.length > 0 ? { ...s.retainedAgentsByPaneKey } : s.retainedAgentsByPaneKey
for (const key of retainedKeys) {
delete nextRetained[key]
}
// Why: a suppressor is only consumed on a live→gone transition, so plant one only for live paneKeys and skip already-suppressed and completed-orphan keys — otherwise it leaks (mirrors dropAgentStatus).
const suppressorAdds = liveKeys.filter(
(k) => !completedOrphanKeySet.has(k) && !(k in s.retentionSuppressedPaneKeys)
)
let nextRetentionSuppressedPaneKeys = s.retentionSuppressedPaneKeys
if (suppressorAdds.length > 0) {
nextRetentionSuppressedPaneKeys = { ...s.retentionSuppressedPaneKeys }
for (const key of suppressorAdds) {
nextRetentionSuppressedPaneKeys[key] = true
}
}
return {
agentStatusByPaneKey: nextLive,
agentLaunchConfigByPaneKey: nextLaunchConfigs,
retainedAgentsByPaneKey: nextRetained,
migrationUnsupportedByPtyId: migrationUnsupported.next,
retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys,
recentlyClosedAgentStatusTabIds: nextClosedTabs,
recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys,
...(nextAck !== s.acknowledgedAgentsByPaneKey
? { acknowledgedAgentsByPaneKey: nextAck }
: {}),
// Why: mirrors removeAgentStatusByTabPrefix — only bump epochs when the live map changed; retained-only sweeps don't affect sort/freshness.
agentStatusEpoch:
hadLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch,
sortEpoch: hadLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch
}
}
const patch = buildPatch()
return { patch, hadLive }
}
@@ -0,0 +1,110 @@
import type { AgentStatusSlice } from './agent-status-slice-contract'
import type { AgentStatusRuntime } from './agent-status-runtime'
import type { AgentLaunchConfigRegistryEntry } from './agent-status-contract'
import { copyLaunchConfig, sleepingRecordFromEntry } from './agent-status-recovery-helpers'
import {
getLaunchConfigForEntry,
launchConfigRegistryEntriesEqual,
normalizeLaunchConfigRegistrationMetadata,
registryEntryMatchesStatus
} from './agent-status-launch-config'
import { getLaunchConfigForStatusMetadata } from './agent-status-map-helpers'
import { findAgentPaneWorktreeId, getTabIdFromPaneKey } from './agent-status-pane-helpers'
export function createAgentStatusLaunchActions(
runtime: AgentStatusRuntime
): Pick<
AgentStatusSlice,
| 'registerAgentLaunchConfig'
| 'getAgentLaunchConfigForStatusEntry'
| 'getAgentLaunchConfigForStatusMetadata'
| 'clearAgentLaunchConfig'
> {
const { get, set } = runtime
return {
registerAgentLaunchConfig: (paneKey, launchConfig, metadata) => {
set((s) => {
const copiedLaunchConfig = copyLaunchConfig(launchConfig)
const nextRegistryEntry: AgentLaunchConfigRegistryEntry = {
launchConfig: copiedLaunchConfig,
registeredAt: Date.now(),
identity: normalizeLaunchConfigRegistrationMetadata(paneKey, metadata)
}
const existingRegistryEntry = s.agentLaunchConfigByPaneKey[paneKey]
const registryChanged = !launchConfigRegistryEntriesEqual(
existingRegistryEntry,
nextRegistryEntry
)
const existingEntry = s.agentStatusByPaneKey[paneKey]
const entryMatchesRegistry = registryEntryMatchesStatus({
entry: nextRegistryEntry,
paneKey,
agentType: existingEntry?.agentType,
tabId: existingEntry?.tabId ?? getTabIdFromPaneKey(paneKey) ?? undefined,
terminalHandle: existingEntry?.terminalHandle,
launchToken: metadata?.launchToken,
providerSession: existingEntry?.providerSession,
existingProviderSession: existingEntry?.providerSession,
providerSessionChanged: false
})
const existingSleepingRecord = s.sleepingAgentSessionsByPaneKey[paneKey]
let nextSleepingAgentSessions = s.sleepingAgentSessionsByPaneKey
if (existingSleepingRecord && entryMatchesRegistry && existingEntry) {
const worktreeId =
existingEntry.worktreeId ??
existingSleepingRecord.worktreeId ??
findAgentPaneWorktreeId(s, paneKey)
const refreshedRecord = worktreeId
? sleepingRecordFromEntry({
state: s,
entry: existingEntry,
worktreeId,
capturedAt: existingSleepingRecord.capturedAt,
launchConfig: copiedLaunchConfig,
origin: existingSleepingRecord.origin
})
: null
if (refreshedRecord) {
nextSleepingAgentSessions = {
...s.sleepingAgentSessionsByPaneKey,
[paneKey]: {
...refreshedRecord,
capturedAt: existingSleepingRecord.capturedAt
}
}
}
}
if (!registryChanged && nextSleepingAgentSessions === s.sleepingAgentSessionsByPaneKey) {
return s
}
return {
...(registryChanged
? {
agentLaunchConfigByPaneKey: {
...s.agentLaunchConfigByPaneKey,
[paneKey]: nextRegistryEntry
}
}
: {}),
...(nextSleepingAgentSessions !== s.sleepingAgentSessionsByPaneKey
? { sleepingAgentSessionsByPaneKey: nextSleepingAgentSessions }
: {})
}
})
},
getAgentLaunchConfigForStatusEntry: (entry) => getLaunchConfigForEntry(get(), entry),
getAgentLaunchConfigForStatusMetadata: (metadata) =>
getLaunchConfigForStatusMetadata(get(), metadata),
clearAgentLaunchConfig: (paneKey) => {
set((s) => {
if (!(paneKey in s.agentLaunchConfigByPaneKey)) {
return s
}
const nextLaunchConfigs = { ...s.agentLaunchConfigByPaneKey }
delete nextLaunchConfigs[paneKey]
return { agentLaunchConfigByPaneKey: nextLaunchConfigs }
})
}
}
}
@@ -0,0 +1,142 @@
import type { AppState } from '../types'
import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types'
import {
agentProviderSessionsEqual,
type AgentProviderSessionMetadata,
type SleepingAgentLaunchConfig
} from '../../../../shared/agent-session-resume'
import type {
AgentLaunchConfigRegistryEntry,
AgentLaunchConfigRegistrationMetadata
} from './agent-status-contract'
import { getLeafIdFromPaneKey, getTabIdFromPaneKey } from './agent-status-pane-helpers'
import { launchConfigsEqual } from './agent-status-recovery-equivalence'
export function normalizeLaunchConfigRegistrationMetadata(
paneKey: string,
metadata: AgentLaunchConfigRegistrationMetadata | undefined
): AgentLaunchConfigRegistrationMetadata {
return {
...(metadata?.agentType ? { agentType: metadata.agentType } : {}),
...(metadata?.launchToken ? { launchToken: metadata.launchToken } : {}),
tabId: metadata?.tabId ?? getTabIdFromPaneKey(paneKey) ?? undefined,
leafId: metadata?.leafId ?? getLeafIdFromPaneKey(paneKey) ?? undefined,
...(metadata?.terminalHandle ? { terminalHandle: metadata.terminalHandle } : {}),
...(metadata?.providerSession ? { providerSession: metadata.providerSession } : {})
}
}
export function launchConfigRegistryEntriesEqual(
a: AgentLaunchConfigRegistryEntry | undefined,
b: AgentLaunchConfigRegistryEntry
): boolean {
return (
a !== undefined &&
launchConfigsEqual(a.launchConfig, b.launchConfig) &&
a.identity.agentType === b.identity.agentType &&
a.identity.launchToken === b.identity.launchToken &&
a.identity.tabId === b.identity.tabId &&
a.identity.leafId === b.identity.leafId &&
a.identity.terminalHandle === b.identity.terminalHandle &&
agentProviderSessionsEqual(
a.identity.agentType ?? b.identity.agentType,
a.identity.providerSession,
b.identity.providerSession
)
)
}
export function registryEntryMatchesStatus(args: {
entry: AgentLaunchConfigRegistryEntry | undefined
paneKey: string
agentType: AgentType | undefined
tabId: string | undefined
terminalHandle: string | undefined
launchToken: string | undefined
providerSession: AgentProviderSessionMetadata | undefined
existingProviderSession: AgentProviderSessionMetadata | undefined
providerSessionChanged: boolean
}): boolean {
const entry = args.entry
if (!entry || args.providerSessionChanged) {
return false
}
const identity = entry.identity
if (identity.agentType !== undefined && identity.agentType !== args.agentType) {
return false
}
if (identity.tabId !== undefined && identity.tabId !== args.tabId) {
return false
}
if (identity.leafId !== undefined && identity.leafId !== getLeafIdFromPaneKey(args.paneKey)) {
return false
}
if (
identity.terminalHandle !== undefined &&
(args.terminalHandle === undefined || identity.terminalHandle !== args.terminalHandle)
) {
return false
}
if (
identity.launchToken !== undefined &&
(args.launchToken === undefined || identity.launchToken !== args.launchToken)
) {
// Why: a missing/mismatched launch token is stale proof even if a later manual/mixed Codex run reused the provider session id.
return false
}
if (identity.providerSession !== undefined) {
return agentProviderSessionsEqual(
args.agentType,
identity.providerSession,
args.providerSession
)
}
if (identity.launchToken !== undefined) {
return true
}
if (identity.terminalHandle !== undefined) {
return true
}
if (args.existingProviderSession && args.providerSession) {
return agentProviderSessionsEqual(
args.agentType,
args.existingProviderSession,
args.providerSession
)
}
return false
}
export function getLaunchConfigForEntry(
state: AppState,
entry: AgentStatusEntry
): SleepingAgentLaunchConfig | undefined {
const registryEntry = state.agentLaunchConfigByPaneKey[entry.paneKey]
const registryLaunchConfig = registryEntryMatchesStatus({
entry: registryEntry,
paneKey: entry.paneKey,
agentType: entry.agentType,
tabId: entry.tabId ?? getTabIdFromPaneKey(entry.paneKey) ?? undefined,
terminalHandle: entry.terminalHandle,
launchToken: undefined,
providerSession: entry.providerSession,
existingProviderSession: entry.providerSession,
providerSessionChanged: false
})
? registryEntry?.launchConfig
: undefined
if (registryLaunchConfig) {
return registryLaunchConfig
}
const sleepingRecord = state.sleepingAgentSessionsByPaneKey[entry.paneKey]
return sleepingRecord?.launchConfig &&
sleepingRecord.agent === entry.agentType &&
entry.providerSession &&
agentProviderSessionsEqual(
entry.agentType,
sleepingRecord.providerSession,
entry.providerSession
)
? sleepingRecord.launchConfig
: undefined
}
@@ -0,0 +1,120 @@
import type { AgentStatusSlice } from './agent-status-slice-contract'
import type { AgentStatusRuntime } from './agent-status-runtime'
import type {
AgentStatusMetadata,
AgentStatusPayload,
AgentStatusRouting,
AgentStatusTiming
} from './agent-status-contract'
import { resolveAgentPaneAuthorityKey } from './agent-pane-authority'
import {
buildAgentStatusLiveEntry,
type AgentStatusLiveEntryBuild
} from './agent-status-live-entry-builder'
import { reduceAgentStatusLiveUpdate } from './agent-status-live-reducer'
import {
agentStatusTabAlreadyHasProtectedOrGeneratedTitle,
getTabIdFromPaneKey,
isRecentlyClosedAgentStatusTab
} from './agent-status-pane-helpers'
import {
getAgentRowGeneratedTitleText,
getOrcaDispatchTaskId,
isOrcaDispatchPrompt,
orchestrationLabelsMatchLiveDispatch
} from '@/lib/agent-row-primary-text'
export function createAgentStatusLiveActions(
runtime: AgentStatusRuntime
): Pick<AgentStatusSlice, 'setAgentStatus' | 'setAgentStatuses' | 'transactAgentStatuses'> {
const { get, set, applyGeneratedTabTitleUpdate, requestFreshness, transactAgentStatuses } =
runtime
const setAgentStatus = (
rawPaneKey: string,
payload: AgentStatusPayload,
terminalTitle?: string,
timing?: AgentStatusTiming,
routing?: AgentStatusRouting,
metadata?: AgentStatusMetadata
): void => {
const paneKey = resolveAgentPaneAuthorityKey(rawPaneKey)
const updatedAt = timing?.updatedAt ?? Date.now()
const current = get()
if (
paneKey in current.recentlyRetiredAgentStatusPaneKeys ||
isRecentlyClosedAgentStatusTab(
current.recentlyClosedAgentStatusTabIds,
getTabIdFromPaneKey(paneKey)
)
) {
return
}
let built: AgentStatusLiveEntryBuild | null = null
set((state) => {
built = buildAgentStatusLiveEntry({
state,
paneKey,
payload,
terminalTitle,
timing,
routing,
metadata,
updatedAt
})
return built ? reduceAgentStatusLiveUpdate(state, built, updatedAt) : state
})
// Zustand's updater runs synchronously, but TypeScript cannot observe the closure assignment.
const builtResult = built as AgentStatusLiveEntryBuild | null
if (!builtResult) {
// Keep standalone calls' deferred freshness contract even when a stale
// event is rejected by the reducer.
requestFreshness(false)
return
}
const { entry } = builtResult
// Sticky orchestration titles are replaced only when they still describe this dispatch.
const hasMatchingOrchestrationLabels = Boolean(
(entry.orchestration?.displayName?.trim() || entry.orchestration?.taskTitle?.trim()) &&
orchestrationLabelsMatchLiveDispatch(entry)
)
const liveIsDispatchPrompt = isOrcaDispatchPrompt(entry.prompt)
const liveDispatchTaskId = liveIsDispatchPrompt ? getOrcaDispatchTaskId(entry.prompt) : null
const stickyOrchestrationTaskId = entry.orchestration?.taskId?.trim() || null
const isNewDispatchAgainstStickyOrchestration = Boolean(
liveDispatchTaskId &&
stickyOrchestrationTaskId &&
liveDispatchTaskId !== stickyOrchestrationTaskId
)
const shouldReplaceGeneratedTitle =
hasMatchingOrchestrationLabels || isNewDispatchAgainstStickyOrchestration
const mayWriteGeneratedTitle =
get().settings?.tabAutoGenerateTitle === true &&
(shouldReplaceGeneratedTitle ||
!agentStatusTabAlreadyHasProtectedOrGeneratedTitle(
get(),
entry.tabId ?? getTabIdFromPaneKey(paneKey),
entry.worktreeId
))
const generatedTitlePrompt =
liveIsDispatchPrompt && mayWriteGeneratedTitle
? getAgentRowGeneratedTitleText(entry)
: entry.prompt
applyGeneratedTabTitleUpdate({
paneKey,
prompt: generatedTitlePrompt,
...(shouldReplaceGeneratedTitle ? { options: { replaceExistingGeneratedTitle: true } } : {})
})
requestFreshness(true)
if (builtResult.completionRefreshWorktreeId) {
const worktreeId = builtResult.completionRefreshWorktreeId
queueMicrotask(() => get().refreshGitHubForWorktreeIfStale(worktreeId))
}
}
const setAgentStatuses = (updates: Parameters<AgentStatusSlice['setAgentStatuses']>[0]) =>
updates.length === 0
? []
: transactAgentStatuses((transaction) => updates.map(transaction.apply))
return { setAgentStatus, setAgentStatuses, transactAgentStatuses }
}
@@ -0,0 +1,290 @@
import type { AppState } from '../types'
import {
AGENT_STATE_HISTORY_MAX,
agentSubagentsEqual,
type MigrationUnsupportedPtyEntry,
type AgentStateHistoryEntry,
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import {
agentProviderSessionsEqual,
getAgentResumeArgv,
isResumableTuiAgent,
type SleepingAgentLaunchConfig,
type SleepingAgentSessionRecord
} from '../../../../shared/agent-session-resume'
import {
resolveAgentStatusIdentity,
shouldSuppressInheritedTerminalStatus
} from '../../../../shared/agent-status-identity'
import { isCommandCodeNewTurnWhileWorking } from '../../../../shared/command-code-turn-boundary'
import type {
AgentStatusMetadata,
AgentStatusPayload,
AgentStatusRouting,
AgentStatusTiming
} from './agent-status-contract'
import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume'
import { registryEntryMatchesStatus } from './agent-status-launch-config'
import { findAgentPaneWorktreeId, getTabIdFromPaneKey } from './agent-status-pane-helpers'
import { mergeCurrentOrchestrationContext } from './agent-status-map-helpers'
import { deriveAgentStatusLiveFacts } from './agent-status-live-facts'
export type AgentStatusLiveEntryBuild = {
entry: AgentStatusEntry
existing: AgentStatusEntry | undefined
existingSleepingRecord: SleepingAgentSessionRecord | undefined
liveRecoveryRecord: SleepingAgentSessionRecord | null
launchConfigSource: SleepingAgentLaunchConfig | undefined
registryEntry: AppState['agentLaunchConfigByPaneKey'][string] | undefined
registryMatched: boolean
providerSession: AgentProviderSessionMetadata | undefined
providerSessionChanged: boolean
retainsResumableRecoveryIdentity: boolean
migrationUnsupported: {
next: Record<string, MigrationUnsupportedPtyEntry>
changed: boolean
}
commandCodeNewTurn: boolean
sortRelevantChange: boolean
retentionRelevantChange: boolean
completionRefreshWorktreeId: string | null
boundaryResolved: boolean
}
export type AgentStatusLiveEntryArgs = {
state: AppState
paneKey: string
payload: AgentStatusPayload
terminalTitle?: string
timing?: AgentStatusTiming
routing?: AgentStatusRouting
metadata?: AgentStatusMetadata
updatedAt: number
}
/** Build one accepted live row and the derived map-update facts. */
export function buildAgentStatusLiveEntry(
args: AgentStatusLiveEntryArgs
): AgentStatusLiveEntryBuild | null {
const { state, paneKey, payload, terminalTitle, timing, routing, metadata, updatedAt } = args
const existing = state.agentStatusByPaneKey[paneKey]
if (existing && updatedAt < existing.updatedAt) {
return null
}
const effectiveTitle = terminalTitle ?? existing?.terminalTitle
let history: AgentStateHistoryEntry[] = existing?.stateHistory ?? []
let lastCompletedAssistantMessage = existing?.lastCompletedAssistantMessage
const boundaryLandsOnRealDone =
existing?.state === 'done' &&
existing.sessionBoundary !== true &&
payload.state === 'done' &&
payload.sessionBoundary === true
if (
existing &&
(existing.state !== payload.state || boundaryLandsOnRealDone) &&
!(existing.state === 'done' && existing.sessionBoundary === true)
) {
history = [
...history,
{
state: existing.state,
prompt: existing.prompt,
startedAt: existing.stateStartedAt,
interrupted: existing.interrupted
}
]
if (history.length > AGENT_STATE_HISTORY_MAX) {
history = history.slice(history.length - AGENT_STATE_HISTORY_MAX)
}
if (existing.state === 'done') {
lastCompletedAssistantMessage = existing.lastAssistantMessage
}
}
const identity = resolveAgentStatusIdentity({
existing: existing
? {
agentType: existing.agentType,
state: existing.state,
updatedAt: existing.updatedAt,
restoredUnconfirmed: existing.restoredUnconfirmed
}
: undefined,
incoming: payload.agentType,
now: updatedAt
})
const commandCodeNewTurn =
existing !== undefined &&
isCommandCodeNewTurnWhileWorking({
agentType: identity.agentType,
previousState: existing.state,
incomingState: payload.state,
previousPrompt: existing.prompt,
incomingPrompt: payload.prompt,
previousPromptInteractionKey: existing.promptInteractionKey,
incomingPromptInteractionKey: payload.promptInteractionKey
})
const promptInteractionKey =
payload.promptInteractionKey ??
(payload.prompt === existing?.prompt ? existing?.promptInteractionKey : undefined)
const stateStartedAt =
timing?.stateStartedAt ??
(commandCodeNewTurn
? updatedAt
: existing && existing.state === payload.state
? existing.stateStartedAt
: updatedAt)
if (
existing &&
shouldSuppressInheritedTerminalStatus({
inheritedFromActivePane: identity.inheritedFromActivePane,
incomingState: payload.state
})
) {
return null
}
const runtimeOrchestration = state.runtimeAgentOrchestrationByPaneKey[paneKey]
const runtimeMergedOrchestration = runtimeOrchestration
? mergeCurrentOrchestrationContext(existing?.orchestration, runtimeOrchestration)
: undefined
const payloadMergedOrchestration = payload.orchestration
? mergeCurrentOrchestrationContext(
runtimeMergedOrchestration ?? existing?.orchestration,
payload.orchestration
)
: undefined
const orchestration =
payloadMergedOrchestration ??
runtimeMergedOrchestration ??
(payload.state === 'done' ? existing?.orchestration : undefined)
const canReuseExistingProviderSession =
existing?.agentType === identity.agentType &&
(existing.state !== 'done' || payload.state === 'done')
const providerSession =
metadata?.providerSession ??
(canReuseExistingProviderSession ? existing.providerSession : undefined)
const existingProviderSession = canReuseExistingProviderSession
? existing.providerSession
: undefined
const providerSessionChanged =
Boolean(metadata?.providerSession && existingProviderSession) &&
!agentProviderSessionsEqual(
identity.agentType,
metadata?.providerSession,
existingProviderSession
)
const statusTabId = routing?.tabId ?? existing?.tabId ?? getTabIdFromPaneKey(paneKey) ?? undefined
const statusTerminalHandle = routing?.terminalHandle ?? existing?.terminalHandle
const registryEntry = state.agentLaunchConfigByPaneKey[paneKey]
const registryMatched = registryEntryMatchesStatus({
entry: registryEntry,
paneKey,
agentType: identity.agentType,
tabId: statusTabId,
terminalHandle: statusTerminalHandle,
launchToken: metadata?.launchToken,
providerSession,
existingProviderSession,
providerSessionChanged
})
const matchedRegistryLaunchConfig = registryMatched ? registryEntry?.launchConfig : undefined
const existingSleepingRecord = state.sleepingAgentSessionsByPaneKey[paneKey]
const retainsResumableRecoveryIdentity =
payload.state === 'done' &&
isResumableTuiAgent(identity.agentType) &&
providerSession !== undefined &&
getAgentResumeArgv(identity.agentType, providerSession) !== null
const matchedSleepingLaunchConfig =
(payload.state !== 'done' || retainsResumableRecoveryIdentity) &&
existingSleepingRecord?.launchConfig &&
existingSleepingRecord.agent === identity.agentType &&
providerSession &&
agentProviderSessionsEqual(
identity.agentType,
existingSleepingRecord.providerSession,
providerSession
)
? existingSleepingRecord.launchConfig
: undefined
const launchConfigSource =
(payload.state !== 'done' && !providerSessionChanged && metadata?.launchToken
? metadata?.launchConfig
: undefined) ??
matchedRegistryLaunchConfig ??
matchedSleepingLaunchConfig
const entry: AgentStatusEntry = {
state: payload.state,
workingMode: payload.workingMode,
prompt: payload.prompt,
updatedAt,
stateStartedAt,
agentType: identity.agentType,
model:
payload.model ?? (existing?.agentType === identity.agentType ? existing.model : undefined),
paneKey,
terminalHandle: statusTerminalHandle,
worktreeId:
routing?.worktreeId ??
existing?.worktreeId ??
findAgentPaneWorktreeId(state, paneKey) ??
undefined,
...(routing?.connectionId !== undefined
? { connectionId: routing.connectionId }
: existing?.connectionId !== undefined
? { connectionId: existing.connectionId }
: state.sleepingAgentSessionsByPaneKey[paneKey]?.connectionId !== undefined
? { connectionId: state.sleepingAgentSessionsByPaneKey[paneKey].connectionId }
: {}),
tabId: statusTabId,
terminalTitle: effectiveTitle,
stateHistory: history,
toolName: payload.toolName,
toolInput: payload.toolInput,
interactivePrompt: payload.interactivePrompt,
lastAssistantMessage: payload.lastAssistantMessage,
...(lastCompletedAssistantMessage ? { lastCompletedAssistantMessage } : {}),
orchestration,
subagents: agentSubagentsEqual(existing?.subagents, payload.subagents)
? existing?.subagents
: payload.subagents,
...(providerSession ? { providerSession } : {}),
...(metadata?.terminalResumeEligible === false
? { terminalResumeEligible: false as const }
: {}),
...(promptInteractionKey ? { promptInteractionKey } : {}),
...(payload.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
acceptedStatusSeq: (existing?.acceptedStatusSeq ?? 0) + 1,
...(payload.observation ? { observation: payload.observation } : {}),
interrupted: payload.interrupted,
sessionBoundary:
payload.sessionBoundary ??
(existing?.state === 'done' &&
payload.state === 'done' &&
payload.lastAssistantMessage === undefined &&
payload.prompt === existing.prompt
? existing.sessionBoundary
: undefined)
}
const facts = deriveAgentStatusLiveFacts({
state,
paneKey,
entry,
existing,
launchConfigSource,
retainsResumableRecoveryIdentity,
commandCodeNewTurn,
updatedAt
})
return {
entry,
existing,
...facts,
launchConfigSource,
providerSession,
providerSessionChanged,
registryEntry,
registryMatched,
retainsResumableRecoveryIdentity,
commandCodeNewTurn
}
}
@@ -0,0 +1,130 @@
import type { AppState } from '../types'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import type {
SleepingAgentLaunchConfig,
SleepingAgentSessionRecord
} from '../../../../shared/agent-session-resume'
import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time'
import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
import { recordHibernationBoundaryResolved } from '@/lib/agent-hibernation-pane-age'
import { findAgentPaneWorktreeId, isAgentCompletionState } from './agent-status-pane-helpers'
import { pruneMigrationUnsupportedEntries } from './agent-status-map-helpers'
import { sleepingRecordFromEntry } from './agent-status-recovery-helpers'
export type AgentStatusLiveFacts = {
existingSleepingRecord: SleepingAgentSessionRecord | undefined
liveRecoveryRecord: SleepingAgentSessionRecord | null
migrationUnsupported: ReturnType<typeof pruneMigrationUnsupportedEntries>
completionRefreshWorktreeId: string | null
sortRelevantChange: boolean
retentionRelevantChange: boolean
boundaryResolved: boolean
}
export type AgentStatusLiveFactsArgs = {
state: AppState
paneKey: string
entry: AgentStatusEntry
existing: AgentStatusEntry | undefined
launchConfigSource: SleepingAgentLaunchConfig | undefined
retainsResumableRecoveryIdentity: boolean
commandCodeNewTurn: boolean
updatedAt: number
}
export function deriveAgentStatusLiveFacts(args: AgentStatusLiveFactsArgs): AgentStatusLiveFacts {
const {
state,
paneKey,
entry,
existing,
launchConfigSource,
retainsResumableRecoveryIdentity,
commandCodeNewTurn,
updatedAt
} = args
const boundaryResolved =
entry.state === 'done' && entry.sessionBoundary !== true && existing?.sessionBoundary === true
if (boundaryResolved) {
recordHibernationBoundaryResolved(paneKey, updatedAt)
}
const completionRefreshWorktreeId =
isAgentCompletionState(entry.state) &&
entry.sessionBoundary !== true &&
existing !== undefined &&
!isAgentCompletionState(existing.state)
? (entry.worktreeId ?? findAgentPaneWorktreeId(state, paneKey))
: null
const wasFresh =
!!existing && isExplicitAgentStatusFresh(existing, updatedAt, AGENT_STATUS_STALE_AFTER_MS)
const attributionChanged =
existing?.worktreeId !== entry.worktreeId || existing?.tabId !== entry.tabId
const sameStateStateStartedAtChanged =
!!existing && existing.state === entry.state && entry.stateStartedAt !== existing.stateStartedAt
const sameStateDoneAttentionChanged =
existing?.state === 'done' &&
entry.state === 'done' &&
agentEntryCompletionAt(existing) !== agentEntryCompletionAt(entry)
const sortRelevantChange =
!existing ||
existing.state !== entry.state ||
!wasFresh ||
attributionChanged ||
commandCodeNewTurn ||
sameStateStateStartedAtChanged ||
sameStateDoneAttentionChanged
const doneRetentionFieldsChanged =
existing?.state === 'done' &&
entry.state === 'done' &&
(entry.prompt !== existing.prompt ||
entry.updatedAt !== existing.updatedAt ||
entry.stateStartedAt !== existing.stateStartedAt ||
entry.agentType !== existing.agentType ||
entry.model !== existing.model ||
entry.terminalTitle !== existing.terminalTitle ||
entry.toolName !== existing.toolName ||
entry.toolInput !== existing.toolInput ||
entry.lastAssistantMessage !== existing.lastAssistantMessage ||
entry.orchestration !== existing.orchestration ||
entry.subagents !== existing.subagents ||
entry.providerSession !== existing.providerSession ||
entry.interrupted !== existing.interrupted)
const retentionRelevantChange =
sortRelevantChange ||
attributionChanged ||
existing?.workingMode !== entry.workingMode ||
doneRetentionFieldsChanged
const existingSleepingRecord = state.sleepingAgentSessionsByPaneKey[paneKey]
const liveRecoveryWorktreeId =
entry.state === 'done' && !retainsResumableRecoveryIdentity
? null
: (entry.worktreeId ?? findAgentPaneWorktreeId(state, paneKey))
const liveRecoveryRecord = liveRecoveryWorktreeId
? sleepingRecordFromEntry({
state,
entry: retainsResumableRecoveryIdentity
? { ...entry, prompt: '', lastAssistantMessage: undefined }
: entry,
worktreeId: liveRecoveryWorktreeId,
capturedAt: updatedAt,
launchConfig: launchConfigSource,
origin: 'live'
})
: null
const migrationUnsupported = pruneMigrationUnsupportedEntries(
state.migrationUnsupportedByPtyId,
(migrationEntry) => migrationEntry.paneKey === paneKey
)
return {
existingSleepingRecord,
liveRecoveryRecord,
migrationUnsupported,
completionRefreshWorktreeId,
sortRelevantChange,
retentionRelevantChange,
boundaryResolved
}
}
@@ -0,0 +1,104 @@
import type { AppState } from '../types'
import { capLiveAgentStatusesInPlace, classifyPaneKeyLiveness } from './agent-status-pane-helpers'
import { removePaneKeys } from './agent-status-map-helpers'
import { recoveryRecordMatches } from './agent-status-recovery-equivalence'
import type { AgentStatusLiveEntryBuild } from './agent-status-live-entry-builder'
import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume'
/** Apply the map changes associated with an accepted live status row. */
export function reduceAgentStatusLiveUpdate(
state: AppState,
build: AgentStatusLiveEntryBuild,
updatedAt: number
): Partial<AppState> {
const {
entry,
existingSleepingRecord,
liveRecoveryRecord,
registryEntry,
registryMatched,
providerSession,
providerSessionChanged,
migrationUnsupported,
retentionRelevantChange,
sortRelevantChange
} = build
const paneKey = entry.paneKey
let nextRetentionSuppressedPaneKeys = state.retentionSuppressedPaneKeys
if (paneKey in nextRetentionSuppressedPaneKeys) {
nextRetentionSuppressedPaneKeys = { ...nextRetentionSuppressedPaneKeys }
delete nextRetentionSuppressedPaneKeys[paneKey]
}
const nextRetainedAgents =
paneKey in state.retainedAgentsByPaneKey
? { ...state.retainedAgentsByPaneKey }
: state.retainedAgentsByPaneKey
if (nextRetainedAgents !== state.retainedAgentsByPaneKey) {
delete nextRetainedAgents[paneKey]
}
let nextSleepingAgentSessions = state.sleepingAgentSessionsByPaneKey
let nextLaunchConfigs = state.agentLaunchConfigByPaneKey
if (
registryMatched &&
registryEntry &&
providerSession &&
!agentProviderSessionsEqual(
entry.agentType,
registryEntry.identity.providerSession,
providerSession
)
) {
nextLaunchConfigs = {
...nextLaunchConfigs,
[paneKey]: { ...registryEntry, identity: { ...registryEntry.identity, providerSession } }
}
}
// Launch tokens authorize only the session they started; a completed turn or changed provider id consumes them.
if (
(providerSessionChanged || (entry.state === 'done' && entry.sessionBoundary !== true)) &&
paneKey in state.agentLaunchConfigByPaneKey
) {
nextLaunchConfigs = { ...state.agentLaunchConfigByPaneKey }
delete nextLaunchConfigs[paneKey]
}
if (liveRecoveryRecord) {
if (!recoveryRecordMatches(existingSleepingRecord, liveRecoveryRecord)) {
nextSleepingAgentSessions = {
...state.sleepingAgentSessionsByPaneKey,
[paneKey]: liveRecoveryRecord
}
}
} else if (existingSleepingRecord) {
nextSleepingAgentSessions = { ...state.sleepingAgentSessionsByPaneKey }
delete nextSleepingAgentSessions[paneKey]
}
const nextLive = { ...state.agentStatusByPaneKey, [paneKey]: entry }
const evictedPaneKeys = capLiveAgentStatusesInPlace(
nextLive,
paneKey,
() => classifyPaneKeyLiveness(state),
updatedAt
)
const evictedOrphans = evictedPaneKeys.length > 0
if (evictedOrphans) {
const evicted = new Set(evictedPaneKeys)
nextSleepingAgentSessions = removePaneKeys(nextSleepingAgentSessions, evicted)
nextLaunchConfigs = removePaneKeys(nextLaunchConfigs, evicted)
}
return {
agentStatusByPaneKey: nextLive,
retainedAgentsByPaneKey: nextRetainedAgents,
sleepingAgentSessionsByPaneKey: nextSleepingAgentSessions,
agentLaunchConfigByPaneKey: nextLaunchConfigs,
migrationUnsupportedByPtyId: migrationUnsupported.next,
retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys,
agentStatusEpoch:
retentionRelevantChange || migrationUnsupported.changed || evictedOrphans
? state.agentStatusEpoch + 1
: state.agentStatusEpoch,
sortEpoch:
sortRelevantChange || migrationUnsupported.changed || evictedOrphans
? state.sortEpoch + 1
: state.sortEpoch
}
}
@@ -0,0 +1,210 @@
import type { AppState } from '../types'
import type {
AgentStatusOrchestrationContext,
MigrationUnsupportedPtyEntry
} from '../../../../shared/agent-status-types'
import {
getRepoExecutionHostId,
getWorktreeExecutionHostId
} from '../../../../shared/execution-host'
import { getTabIdFromPaneKey } from './agent-status-pane-helpers'
import { registryEntryMatchesStatus } from './agent-status-launch-config'
import type { AgentLaunchConfigStatusMetadata } from './agent-status-contract'
import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume'
export const RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024
export const RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX = 1024
// delete-then-set for LRU recency, then evict oldest keys past the cap (Record iterates
// insertion order); safe because a status for a tab closed >MAX tabs ago cannot still arrive.
export function boundRecentlyClosedAgentStatusTabIds(
existing: Record<string, true>,
tabId: string
): Record<string, true> {
const next: Record<string, true> = {}
for (const key of Object.keys(existing)) {
if (key !== tabId) {
next[key] = true
}
}
next[tabId] = true
const keys = Object.keys(next)
if (keys.length > RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX) {
for (const stale of keys.slice(0, keys.length - RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX)) {
delete next[stale]
}
}
return next
}
export function boundRecentlyRetiredAgentStatusPaneKeys(
existing: Record<string, true>,
paneKeys: readonly string[]
): Record<string, true> {
const additions = new Set(paneKeys)
const next: Record<string, true> = {}
for (const key of Object.keys(existing)) {
if (!additions.has(key)) {
next[key] = true
}
}
for (const paneKey of additions) {
next[paneKey] = true
}
const keys = Object.keys(next)
for (const stale of keys.slice(0, -RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX)) {
delete next[stale]
}
return next
}
export function movePaneKeyedRecord<T>(
record: Record<string, T>,
fromPaneKey: string,
toPaneKey: string,
transform: (value: T) => T = (value) => value
): Record<string, T> {
const value = record[fromPaneKey]
if (value === undefined || fromPaneKey === toPaneKey) {
return record
}
const next = { ...record }
delete next[fromPaneKey]
next[toPaneKey] = transform(value)
return next
}
export function removePaneKeys<T>(
record: Record<string, T>,
paneKeys: ReadonlySet<string>
): Record<string, T> {
const matchingKeys = Object.keys(record).filter((key) => paneKeys.has(key))
if (matchingKeys.length === 0) {
return record
}
const next = { ...record }
for (const key of matchingKeys) {
delete next[key]
}
return next
}
export function getLaunchConfigForStatusMetadata(
state: AppState,
metadata: AgentLaunchConfigStatusMetadata
): SleepingAgentLaunchConfig | undefined {
const registryEntry = state.agentLaunchConfigByPaneKey[metadata.paneKey]
return registryEntryMatchesStatus({
entry: registryEntry,
paneKey: metadata.paneKey,
agentType: metadata.agentType,
tabId: metadata.tabId ?? getTabIdFromPaneKey(metadata.paneKey) ?? undefined,
terminalHandle: metadata.terminalHandle,
launchToken: metadata.launchToken,
providerSession: metadata.providerSession,
existingProviderSession: metadata.existingProviderSession,
providerSessionChanged: metadata.providerSessionChanged ?? false
})
? registryEntry?.launchConfig
: undefined
}
export function pruneMigrationUnsupportedEntries(
entries: Record<string, MigrationUnsupportedPtyEntry>,
predicate: (entry: MigrationUnsupportedPtyEntry) => boolean
): { next: Record<string, MigrationUnsupportedPtyEntry>; changed: boolean } {
let changed = false
const next: Record<string, MigrationUnsupportedPtyEntry> = {}
for (const [ptyId, entry] of Object.entries(entries)) {
if (predicate(entry)) {
changed = true
continue
}
next[ptyId] = entry
}
return { next: changed ? next : entries, changed }
}
export function orchestrationContextsEqual(
a: AgentStatusOrchestrationContext,
b: AgentStatusOrchestrationContext
): boolean {
return (
a.taskId === b.taskId &&
a.dispatchId === b.dispatchId &&
a.dispatchStatus === b.dispatchStatus &&
a.taskTitle === b.taskTitle &&
a.displayName === b.displayName &&
a.parentTerminalHandle === b.parentTerminalHandle &&
a.parentPaneKey === b.parentPaneKey &&
a.coordinatorHandle === b.coordinatorHandle &&
a.orchestrationRunId === b.orchestrationRunId
)
}
export function orchestrationMapsEqual(
a: Record<string, AgentStatusOrchestrationContext>,
b: Record<string, AgentStatusOrchestrationContext>
): boolean {
const aKeys = Object.keys(a)
const bKeys = Object.keys(b)
if (aKeys.length !== bKeys.length) {
return false
}
return aKeys.every((key) => b[key] !== undefined && orchestrationContextsEqual(a[key]!, b[key]!))
}
export function mergeCurrentOrchestrationContext(
existing: AgentStatusOrchestrationContext | undefined,
current: AgentStatusOrchestrationContext
): AgentStatusOrchestrationContext {
if (!existing) {
return current
}
const sameDispatch =
existing.taskId === current.taskId && existing.dispatchId === current.dispatchId
if (!sameDispatch) {
return current
}
if (current.dispatchStatus !== undefined) {
return orchestrationContextsEqual(existing, current) ? existing : current
}
const merged = { ...existing, ...current }
return orchestrationContextsEqual(existing, merged) ? existing : merged
}
// Why: relay/daemon teardown drops main's rows, but renderer entries whose connectionId stamp never
// matched (unstamped over SSH) survive and stay "fresh" 30 min (#9030). Resolve each worktree's host
// via the canonical hostId-first precedence and keep only ids UNAMBIGUOUSLY on this connection — a
// worktree id is `${repoId}::${path}` (no host component), so the same project mirrored at the same
// path on two hosts yields one shared id that must not clear another host's live rows.
export function collectWorktreeIdsForConnection(
state: AppState,
connectionId: string
): Set<string> {
const hostIdsOnConnection = new Set(
state.repos
.filter((repo) => repo.connectionId === connectionId)
.map((repo) => getRepoExecutionHostId(repo))
)
if (hostIdsOnConnection.size === 0) {
return new Set()
}
const repoById = new Map(state.repos.map((repo) => [repo.id, repo] as const))
const onConnection = new Set<string>()
const onOtherHost = new Set<string>()
for (const [repoId, worktrees] of Object.entries(state.worktreesByRepo)) {
const repo = repoById.get(repoId)
for (const worktree of worktrees) {
const bucket = hostIdsOnConnection.has(getWorktreeExecutionHostId(worktree, repo))
? onConnection
: onOtherHost
bucket.add(worktree.id)
}
}
// A worktree id that also lives on another host is ambiguous — leave it rather than hide a live row.
for (const id of onOtherHost) {
onConnection.delete(id)
}
return onConnection
}
@@ -0,0 +1,103 @@
import type {
AgentStatusEntry,
AgentStatusOrchestrationContext
} from '../../../../shared/agent-status-types'
import type { AgentStatusSlice } from './agent-status-slice-contract'
import type { AgentStatusRuntime } from './agent-status-runtime'
import {
getAgentRowGeneratedTitleText,
orchestrationLabelsMatchLiveDispatch
} from '@/lib/agent-row-primary-text'
import {
mergeCurrentOrchestrationContext,
orchestrationMapsEqual
} from './agent-status-map-helpers'
export function createAgentStatusOrchestrationActions(
runtime: AgentStatusRuntime
): Pick<AgentStatusSlice, 'setRuntimeAgentOrchestrationByPaneKey'> {
const { get, set } = runtime
return {
setRuntimeAgentOrchestrationByPaneKey: (
entries: Record<string, AgentStatusOrchestrationContext>
) => {
const generatedTitleUpdates: AgentStatusEntry[] = []
set((s) => {
const runtimeMapChanged = !orchestrationMapsEqual(
s.runtimeAgentOrchestrationByPaneKey,
entries
)
let nextLive = s.agentStatusByPaneKey
let liveChanged = false
let nextRetained = s.retainedAgentsByPaneKey
let retainedChanged = false
for (const [paneKey, runtimeOrchestration] of Object.entries(entries)) {
const liveEntry = nextLive[paneKey]
if (liveEntry) {
const merged = mergeCurrentOrchestrationContext(
liveEntry.orchestration,
runtimeOrchestration
)
if (merged !== liveEntry.orchestration) {
if (!liveChanged) {
nextLive = { ...nextLive }
liveChanged = true
}
const nextEntry = { ...liveEntry, orchestration: merged }
nextLive[paneKey] = nextEntry
// Why: only replace titles when labels match the live dispatch taskId; sticky completed context must not rename a later turn.
if (
(merged.displayName?.trim() || merged.taskTitle?.trim()) &&
orchestrationLabelsMatchLiveDispatch({
prompt: nextEntry.prompt,
orchestration: merged
})
) {
generatedTitleUpdates.push(nextEntry)
}
}
}
const retainedEntry = nextRetained[paneKey]
if (retainedEntry) {
const merged = mergeCurrentOrchestrationContext(
retainedEntry.entry.orchestration,
runtimeOrchestration
)
if (merged !== retainedEntry.entry.orchestration) {
if (!retainedChanged) {
nextRetained = { ...nextRetained }
retainedChanged = true
}
nextRetained[paneKey] = {
...retainedEntry,
entry: { ...retainedEntry.entry, orchestration: merged }
}
}
}
}
if (!runtimeMapChanged && !liveChanged && !retainedChanged) {
return s
}
return {
...(runtimeMapChanged ? { runtimeAgentOrchestrationByPaneKey: entries } : {}),
...(liveChanged ? { agentStatusByPaneKey: nextLive } : {}),
...(retainedChanged ? { retainedAgentsByPaneKey: nextRetained } : {}),
...(liveChanged ? { agentStatusEpoch: s.agentStatusEpoch + 1 } : {})
}
})
for (const entry of generatedTitleUpdates) {
get().setGeneratedTabTitleFromAgentPrompt(
entry.paneKey,
getAgentRowGeneratedTitleText(entry),
{
replaceExistingGeneratedTitle: true
}
)
}
}
}
}
@@ -0,0 +1,277 @@
import type { AppState } from '../types'
import type {
AgentStatusEntry,
AgentType,
ParsedAgentStatusPayload
} from '../../../../shared/agent-status-types'
import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/terminal-tab-types'
import type { DropAgentStatusByWorktreeOptions, RetainedAgentEntry } from './agent-status-contract'
import type { AgentStatusTabPrefixDropState } from './agent-status-drop-reducer'
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types'
const MAX_RETAINED_AGENTS = 500
export function capRetainedAgents(
retained: Record<string, RetainedAgentEntry>,
maxEntries = MAX_RETAINED_AGENTS
): Record<string, RetainedAgentEntry> {
const keys = Object.keys(retained)
if (keys.length <= maxEntries) {
return retained
}
const capped: Record<string, RetainedAgentEntry> = {}
for (const key of keys.slice(keys.length - maxEntries)) {
capped[key] = retained[key]
}
return capped
}
// Why: missed pane teardown can leak heavy live rows in any state and amplify every status-map copy (#9872).
export const MAX_LIVE_AGENT_STATUSES = 500
type PaneLiveness = 'live' | 'dead' | 'unprovable'
// Why: only a rooted tab proves which leaves are mounted; rootless and headless rows may still be live (#2962).
export function classifyPaneKeyLiveness(state: AppState): (paneKey: string) => PaneLiveness {
const rootedLeafKeys = new Set<string>()
const rootedTabIds = new Set<string>()
for (const [tabId, layout] of Object.entries(state.terminalLayoutsByTabId)) {
if (!layout?.root) {
continue
}
rootedTabIds.add(tabId)
const stack: TerminalPaneLayoutNode[] = [layout.root]
while (stack.length > 0) {
const node = stack.pop()!
if (node.type === 'leaf') {
rootedLeafKeys.add(`${tabId}:${node.leafId}`)
} else {
stack.push(node.first, node.second)
}
}
}
return (paneKey) => {
if (rootedLeafKeys.has(paneKey)) {
return 'live'
}
const tabId = getTabIdFromPaneKey(paneKey)
return tabId !== null && rootedTabIds.has(tabId) ? 'dead' : 'unprovable'
}
}
// Why: mutate the caller-owned spread so eviction does not allocate another heavy-map copy.
export function capLiveAgentStatusesInPlace(
freshLive: Record<string, AgentStatusEntry>,
protectedPaneKey: string,
buildClassifier: () => (paneKey: string) => PaneLiveness,
now: number,
maxEntries = MAX_LIVE_AGENT_STATUSES
): string[] {
const keys = Object.keys(freshLive)
let overflow = keys.length - maxEntries
if (overflow <= 0) {
return []
}
const classify = buildClassifier()
const evictedPaneKeys: string[] = []
const sweep = (canEvict: (liveness: PaneLiveness, entry: AgentStatusEntry) => boolean): void => {
for (const key of keys) {
if (overflow <= 0) {
break
}
if (key === protectedPaneKey || !(key in freshLive)) {
continue
}
const liveness = classify(key)
if (liveness === 'live' || !canEvict(liveness, freshLive[key])) {
continue
}
delete freshLive[key]
overflow -= 1
evictedPaneKeys.push(key)
}
}
// Prefer rows that are provably dead or too stale to represent a live agent.
sweep(
(liveness, entry) => liveness === 'dead' || now - entry.updatedAt > AGENT_STATUS_STALE_AFTER_MS
)
// Shed fresh unprovable rows only when needed; rooted live panes make this a soft cap.
if (overflow > 0) {
sweep(() => true)
}
return evictedPaneKeys
}
export function paneKeyMatchesAnyTabPrefix(paneKey: string, tabPrefixes: string[]): boolean {
for (const prefix of tabPrefixes) {
if (paneKey.startsWith(prefix)) {
return true
}
}
return false
}
export function isAgentCompletionState(state: ParsedAgentStatusPayload['state']): boolean {
return state === 'done' || state === 'waiting' || state === 'blocked'
}
export function getTabIdFromPaneKey(paneKey: string): string | null {
const separator = paneKey.indexOf(':')
if (separator <= 0 || separator !== paneKey.lastIndexOf(':')) {
return null
}
return paneKey.slice(0, separator)
}
/** True when auto-title generation would no-op without replace (custom/quick/generated). */
export function agentStatusTabAlreadyHasProtectedOrGeneratedTitle(
state: AppState,
tabId: string | null,
worktreeId?: string | null
): boolean {
if (!tabId) {
return false
}
const ownerTabs = worktreeId ? state.tabsByWorktree[worktreeId] : undefined
if (ownerTabs) {
const tab = ownerTabs.find((candidate) => candidate.id === tabId)
return Boolean(
tab?.customTitle?.trim() || tab?.quickCommandLabel?.trim() || tab?.generatedTitle?.trim()
)
}
for (const tabs of Object.values(state.tabsByWorktree)) {
const tab = tabs.find((candidate) => candidate.id === tabId)
if (!tab) {
continue
}
return Boolean(
tab.customTitle?.trim() || tab.quickCommandLabel?.trim() || tab.generatedTitle?.trim()
)
}
return false
}
export function getLeafIdFromPaneKey(paneKey: string): string | null {
const separator = paneKey.indexOf(':')
if (separator <= 0 || separator !== paneKey.lastIndexOf(':')) {
return null
}
const leafId = paneKey.slice(separator + 1)
return leafId.length > 0 ? leafId : null
}
export function findCompletedOrphanPaneKeysForTabClose(
state: AgentStatusTabPrefixDropState,
worktreeId: string | undefined,
prefix: string
): string[] {
if (!worktreeId) {
return []
}
const openTabIds = new Set((state.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id))
const paneKeys: string[] = []
for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) {
if (paneKey.startsWith(prefix) || entry.state !== 'done' || entry.worktreeId !== worktreeId) {
continue
}
const tabId = getTabIdFromPaneKey(paneKey)
if (!tabId || openTabIds.has(tabId)) {
continue
}
paneKeys.push(paneKey)
}
return paneKeys
}
export function isRecentlyClosedAgentStatusTab(
closedTabs: Record<string, true>,
tabId: string | null
): boolean {
if (!tabId) {
return false
}
return closedTabs[tabId] === true
}
export function findAgentPaneWorktreeId(state: AppState, paneKey: string): string | null {
const tabId = getTabIdFromPaneKey(paneKey)
if (!tabId) {
return null
}
for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) {
if (tabs.some((tab) => tab.id === tabId)) {
return worktreeId
}
}
return null
}
export function findTabForAgentEntry(
state: AppState,
worktreeId: string,
entry: AgentStatusEntry
): TerminalTab | undefined {
const tabId = entry.tabId ?? getTabIdFromPaneKey(entry.paneKey)
if (!tabId) {
return undefined
}
return (state.tabsByWorktree[worktreeId] ?? []).find((tab) => tab.id === tabId)
}
export function getRetainedFallbackTab(entry: AgentStatusEntry, worktreeId: string): TerminalTab {
const tabId = entry.tabId ?? getTabIdFromPaneKey(entry.paneKey) ?? entry.paneKey
return {
id: tabId,
ptyId: null,
worktreeId,
title: entry.terminalTitle ?? 'Agent',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: entry.stateStartedAt
}
}
export function retainedAgentEntryFromLive(
state: AppState,
worktreeId: string,
entry: AgentStatusEntry,
agentType: AgentType
): RetainedAgentEntry {
const tab =
findTabForAgentEntry(state, worktreeId, entry) ?? getRetainedFallbackTab(entry, worktreeId)
return {
entry,
worktreeId,
tab,
agentType,
startedAt: entry.stateHistory[0]?.startedAt ?? entry.stateStartedAt
}
}
export function shouldReplaceRetainedWithLive(
retained: RetainedAgentEntry | undefined,
live: RetainedAgentEntry
): boolean {
if (!retained) {
return true
}
if (live.startedAt !== retained.startedAt) {
return live.startedAt > retained.startedAt
}
const retainedSessionId = retained.entry.providerSession?.id
const liveSessionId = live.entry.providerSession?.id
if (retainedSessionId && liveSessionId && retainedSessionId !== liveSessionId) {
return live.entry.updatedAt >= retained.entry.updatedAt
}
return live.entry.updatedAt > retained.entry.updatedAt
}
export function normalizePaneKeySet(
paneKeys: DropAgentStatusByWorktreeOptions['sleepingPaneKeys']
): ReadonlySet<string> | null {
if (!paneKeys) {
return null
}
return paneKeys instanceof Set ? paneKeys : new Set(paneKeys)
}
@@ -0,0 +1,177 @@
import type { AgentStatusSlice } from './agent-status-slice-contract'
import type { AgentStatusRuntime } from './agent-status-runtime'
import type {
AgentProviderSessionRecordMetadata,
AgentProviderSessionRouting,
AgentProviderSessionTiming
} from './agent-status-contract'
import {
agentProviderSessionsEqual,
getAgentResumeArgv,
type ResumableTuiAgent,
type AgentProviderSessionMetadata,
type SleepingAgentSessionRecord
} from '../../../../shared/agent-session-resume'
import { resolveAgentPaneAuthorityKey } from './agent-pane-authority'
import {
findAgentPaneWorktreeId,
getTabIdFromPaneKey,
isRecentlyClosedAgentStatusTab
} from './agent-status-pane-helpers'
import { removePaneKeys } from './agent-status-map-helpers'
import { registryEntryMatchesStatus } from './agent-status-launch-config'
import { copyLaunchConfig } from './agent-status-recovery-helpers'
export function createAgentStatusProviderSessionActions(
runtime: AgentStatusRuntime
): Pick<AgentStatusSlice, 'recordAgentProviderSession'> {
const { get, set, requestFreshness } = runtime
return {
recordAgentProviderSession: (
paneKey: string,
agent: ResumableTuiAgent,
providerSession: AgentProviderSessionMetadata,
timing?: AgentProviderSessionTiming,
routing?: AgentProviderSessionRouting,
metadata?: AgentProviderSessionRecordMetadata
) => {
paneKey = resolveAgentPaneAuthorityKey(paneKey)
const updatedAt = timing?.updatedAt ?? Date.now()
if (
paneKey in get().recentlyRetiredAgentStatusPaneKeys ||
isRecentlyClosedAgentStatusTab(
get().recentlyClosedAgentStatusTabIds,
getTabIdFromPaneKey(paneKey)
) ||
!getAgentResumeArgv(agent, providerSession)
) {
return
}
let removedLiveStatus = false
set((s) => {
const existingStatus = s.agentStatusByPaneKey[paneKey]
const existingRecord = s.sleepingAgentSessionsByPaneKey[paneKey]
if (
(existingStatus && updatedAt < existingStatus.updatedAt) ||
(existingRecord && updatedAt < existingRecord.updatedAt)
) {
return s
}
const tabId = routing?.tabId ?? getTabIdFromPaneKey(paneKey) ?? existingRecord?.tabId
const worktreeId =
routing?.worktreeId ??
existingStatus?.worktreeId ??
existingRecord?.worktreeId ??
findAgentPaneWorktreeId(s, paneKey)
if (!worktreeId) {
return s
}
const registryEntry = s.agentLaunchConfigByPaneKey[paneKey]
const registryMatches = registryEntryMatchesStatus({
entry: registryEntry,
paneKey,
agentType: agent,
tabId,
terminalHandle: undefined,
launchToken: metadata?.launchToken,
providerSession,
existingProviderSession: existingRecord?.providerSession,
providerSessionChanged: false
})
const existingRecordMatchesProviderSession =
existingRecord?.agent === agent &&
agentProviderSessionsEqual(agent, existingRecord.providerSession, providerSession)
// Why: provider-session heartbeats can arrive after the turn is complete; preserve the
// completed checkpoint so a late heartbeat cannot make it eligible for ghost resume.
const preservesCompletedRecoveryRecord =
existingRecordMatchesProviderSession && existingRecord?.state === 'done'
// Why: an explicit quit capture must remain the resume handle until a new provider session replaces it.
const preservesQuitOrigin =
existingRecordMatchesProviderSession && existingRecord?.origin === 'quit'
const launchConfig =
(registryMatches ? registryEntry?.launchConfig : undefined) ??
(existingRecordMatchesProviderSession ? existingRecord.launchConfig : undefined)
const record: SleepingAgentSessionRecord = {
paneKey,
...(tabId ? { tabId } : {}),
worktreeId,
agent,
providerSession,
prompt: '',
// Why: durable process/session identity, not visible turn state; a non-done value keeps cold restore eligible.
state: preservesCompletedRecoveryRecord ? 'done' : 'working',
capturedAt: updatedAt,
updatedAt,
...(existingStatus?.terminalTitle
? { terminalTitle: existingStatus.terminalTitle }
: existingRecord?.terminalTitle
? { terminalTitle: existingRecord.terminalTitle }
: {}),
...(routing?.connectionId !== undefined
? { connectionId: routing.connectionId }
: existingRecord?.connectionId !== undefined
? { connectionId: existingRecord.connectionId }
: {}),
...(launchConfig ? { launchConfig: copyLaunchConfig(launchConfig) } : {}),
...(existingRecordMatchesProviderSession &&
existingRecord.automaticResumeBlockedBy === 'legacy-orchestration-worker'
? { automaticResumeBlockedBy: 'legacy-orchestration-worker' }
: {}),
...(preservesCompletedRecoveryRecord && existingRecord.interrupted !== undefined
? { interrupted: existingRecord.interrupted }
: {}),
origin: preservesQuitOrigin ? 'quit' : 'live'
}
removedLiveStatus = existingStatus !== undefined
const nextLive = removedLiveStatus ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey
if (removedLiveStatus) {
delete nextLive[paneKey]
}
const nextRetained =
paneKey in s.retainedAgentsByPaneKey
? { ...s.retainedAgentsByPaneKey }
: s.retainedAgentsByPaneKey
if (nextRetained !== s.retainedAgentsByPaneKey) {
delete nextRetained[paneKey]
}
// Why: on identity mismatch the sleeping record drops its launch config, so clear the stale
// registry entry too, else a later return to the old identity reuses stale args/env.
let nextLaunchConfigs = s.agentLaunchConfigByPaneKey
if (registryMatches && registryEntry) {
nextLaunchConfigs = {
...nextLaunchConfigs,
[paneKey]: {
...registryEntry,
identity: { ...registryEntry.identity, providerSession }
}
}
} else if (registryEntry) {
nextLaunchConfigs = { ...nextLaunchConfigs }
delete nextLaunchConfigs[paneKey]
}
return {
agentStatusByPaneKey: nextLive,
retainedAgentsByPaneKey: nextRetained,
sleepingAgentSessionsByPaneKey: {
...s.sleepingAgentSessionsByPaneKey,
[paneKey]: record
},
agentLaunchConfigByPaneKey: nextLaunchConfigs,
acknowledgedAgentsByPaneKey: removePaneKeys(
s.acknowledgedAgentsByPaneKey,
new Set([paneKey])
),
unreadAgentCompletionPanes: removePaneKeys(
s.unreadAgentCompletionPanes,
new Set([paneKey])
),
agentStatusEpoch: removedLiveStatus ? s.agentStatusEpoch + 1 : s.agentStatusEpoch,
sortEpoch: removedLiveStatus ? s.sortEpoch + 1 : s.sortEpoch
}
})
if (removedLiveStatus) {
requestFreshness(true)
}
}
}
}
@@ -0,0 +1,198 @@
import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume'
import type { AgentStatusSlice } from './agent-status-slice-contract'
import type { AgentStatusRuntime } from './agent-status-runtime'
import { collectSleepingAgentSessionRecordsForWorktree } from './agent-status-recovery-collection'
import {
removeSleepingRecordsReplacedByManualWorktreeSleep,
sleepingRecordFromEntry
} from './agent-status-recovery-helpers'
import {
recoveryRecordTargetsSameSession,
sleepingRecordsEquivalentIgnoringCaptureTime
} from './agent-status-recovery-equivalence'
import { getLaunchConfigForEntry } from './agent-status-launch-config'
import { findAgentPaneWorktreeId } from './agent-status-pane-helpers'
import { isCompletedPiCompatibleAgentWithLiveRecoveryRecord } from '@/lib/live-resume-anchor-record'
export function createAgentStatusRecoveryActions(
runtime: AgentStatusRuntime
): Pick<
AgentStatusSlice,
| 'captureSleepingAgentSessionsByWorktree'
| 'captureAllSleepingAgentSessions'
| 'clearSleepingAgentSession'
| 'clearSleepingAgentSessionsByPaneKey'
| 'setSleepingAgentAutomaticResumeBlocked'
| 'clearSleepingAgentSessionsByWorktree'
| 'pruneSleepingAgentSessions'
> {
const { set, clearSleepingAgentSessionsByPaneKey } = runtime
return {
captureSleepingAgentSessionsByWorktree: (worktreeId, paneKeys) => {
set((s) => {
const records = collectSleepingAgentSessionRecordsForWorktree(s, worktreeId, {
paneKeys,
captureMode: 'manual-worktree-sleep'
})
const replaced = removeSleepingRecordsReplacedByManualWorktreeSleep(
s.sleepingAgentSessionsByPaneKey,
worktreeId,
paneKeys,
records
)
let next = { ...replaced.records }
let changed = replaced.changed
for (const record of Object.values(records)) {
if (next[record.paneKey] !== record) {
next[record.paneKey] = record
changed = true
}
}
return changed ? { sleepingAgentSessionsByPaneKey: next } : s
})
},
captureAllSleepingAgentSessions: (mode) => {
set((s) => {
const capturedAt = Date.now()
const origin = mode === 'quit' ? ('quit' as const) : ('live' as const)
const next: Record<string, SleepingAgentSessionRecord> = {
...s.sleepingAgentSessionsByPaneKey
}
let changed = false
for (const entry of Object.values(s.agentStatusByPaneKey)) {
if (entry.state === 'done') {
const existing = next[entry.paneKey]
if (
!isCompletedPiCompatibleAgentWithLiveRecoveryRecord(entry, existing) ||
mode === 'periodic'
) {
continue
}
const record = { ...existing, capturedAt, origin }
if (!sleepingRecordsEquivalentIgnoringCaptureTime(existing, record)) {
next[entry.paneKey] = record
changed = true
}
continue
}
const worktreeId = entry.worktreeId ?? findAgentPaneWorktreeId(s, entry.paneKey)
if (!worktreeId) {
continue
}
const record = sleepingRecordFromEntry({
state: s,
entry,
worktreeId,
capturedAt,
launchConfig: getLaunchConfigForEntry(s, entry),
origin
})
const existing = next[entry.paneKey]
if (
mode === 'periodic' &&
existing?.origin === 'quit' &&
record &&
recoveryRecordTargetsSameSession(existing, record)
) {
continue
}
if (record && !sleepingRecordsEquivalentIgnoringCaptureTime(existing, record)) {
next[record.paneKey] = record
changed = true
}
}
return changed ? { sleepingAgentSessionsByPaneKey: next } : s
})
},
clearSleepingAgentSession: (paneKey) => clearSleepingAgentSessionsByPaneKey([paneKey]),
clearSleepingAgentSessionsByPaneKey,
setSleepingAgentAutomaticResumeBlocked: (paneKey, blocked) => {
set((s) => {
const current = s.sleepingAgentSessionsByPaneKey[paneKey]
if (
!current ||
(blocked
? current.automaticResumeBlockedBy === 'legacy-orchestration-worker'
: current.automaticResumeBlockedBy === undefined)
) {
return s
}
const next = { ...current }
if (blocked) {
next.automaticResumeBlockedBy = 'legacy-orchestration-worker'
} else {
delete next.automaticResumeBlockedBy
}
return {
sleepingAgentSessionsByPaneKey: {
...s.sleepingAgentSessionsByPaneKey,
[paneKey]: next
}
}
})
},
clearSleepingAgentSessionsByWorktree: (worktreeId) => {
set((s) => {
let changed = false
const next: Record<string, SleepingAgentSessionRecord> = {}
const removed: string[] = []
for (const [paneKey, record] of Object.entries(s.sleepingAgentSessionsByPaneKey)) {
if (record.worktreeId === worktreeId) {
changed = true
removed.push(paneKey)
} else {
next[paneKey] = record
}
}
if (!changed) {
return s
}
const nextLaunch =
removed.length > 0 ? { ...s.agentLaunchConfigByPaneKey } : s.agentLaunchConfigByPaneKey
for (const paneKey of removed) {
delete nextLaunch[paneKey]
}
return {
sleepingAgentSessionsByPaneKey: next,
...(nextLaunch !== s.agentLaunchConfigByPaneKey
? { agentLaunchConfigByPaneKey: nextLaunch }
: {})
}
})
},
pruneSleepingAgentSessions: (validWorktreeIds) => {
set((s) => {
let changed = false
const next: Record<string, SleepingAgentSessionRecord> = {}
const removed: string[] = []
for (const [paneKey, record] of Object.entries(s.sleepingAgentSessionsByPaneKey)) {
if (!validWorktreeIds.has(record.worktreeId)) {
changed = true
removed.push(paneKey)
} else {
next[paneKey] = record
}
}
if (!changed) {
return s
}
const nextLaunch =
removed.length > 0 ? { ...s.agentLaunchConfigByPaneKey } : s.agentLaunchConfigByPaneKey
for (const paneKey of removed) {
delete nextLaunch[paneKey]
}
return {
sleepingAgentSessionsByPaneKey: next,
...(nextLaunch !== s.agentLaunchConfigByPaneKey
? { agentLaunchConfigByPaneKey: nextLaunch }
: {})
}
})
}
}
}
@@ -0,0 +1,174 @@
import type { AppState } from '../types'
import {
getAgentResumeArgv,
type SleepingAgentSessionRecord
} from '../../../../shared/agent-session-resume'
import type { RetainedAgentEntry } from './agent-status-contract'
import {
normalizePaneKeySet,
paneKeyMatchesAnyTabPrefix,
retainedAgentEntryFromLive
} from './agent-status-pane-helpers'
import {
carryOverAutomaticResumeBlock,
isValidCompletedAgentHibernationEntry,
manualSleepCaptureEntry,
markManualSleepLazyRestore,
normalizeSleepingAgentSessionCollectOptions,
sleepingRecordFromEntry,
type CollectSleepingAgentSessionRecordsOptions
} from './agent-status-recovery-helpers'
import { getLaunchConfigForEntry } from './agent-status-launch-config'
import { isCompletedPiCompatibleAgentWithLiveRecoveryRecord } from '@/lib/live-resume-anchor-record'
export function collectSleepingAgentSessionRecordsForWorktree(
state: AppState,
worktreeId: string,
options?: readonly string[] | CollectSleepingAgentSessionRecordsOptions
): Record<string, SleepingAgentSessionRecord> {
const capturedAt = Date.now()
const collectOptions = normalizeSleepingAgentSessionCollectOptions(options)
const allowedPaneKeys = collectOptions.paneKeys ? new Set(collectOptions.paneKeys) : null
const isManualWorktreeSleep = collectOptions.captureMode === 'manual-worktree-sleep'
const isCompletedAgentHibernation = collectOptions.captureMode === 'completed-agent-hibernation'
const isWorktreeOwnedCapture = isManualWorktreeSleep || isCompletedAgentHibernation
// Why: hibernated completions are intentional worktree-owned records; wake treats
// originless completed records as ambiguous legacy captures.
const origin: SleepingAgentSessionRecord['origin'] | undefined = isWorktreeOwnedCapture
? 'worktree-sleep'
: undefined
const tabPrefixes = (state.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`)
const records: Record<string, SleepingAgentSessionRecord> = {}
const promotedLiveRecoveryPaneKeys = new Set<string>()
if (isManualWorktreeSleep) {
for (const existing of Object.values(state.sleepingAgentSessionsByPaneKey)) {
const liveEntry = state.agentStatusByPaneKey[existing.paneKey]
if (
existing.worktreeId !== worktreeId ||
existing.origin !== 'live' ||
(liveEntry !== undefined &&
!isCompletedPiCompatibleAgentWithLiveRecoveryRecord(liveEntry, existing)) ||
(allowedPaneKeys && !allowedPaneKeys.has(existing.paneKey)) ||
!getAgentResumeArgv(existing.agent, existing.providerSession)
) {
continue
}
// Why: Pi identity is resumable with no turn row and while idle after done, so manual
// sleep must promote both instead of deleting the checkpoint.
records[existing.paneKey] = {
...existing,
state: 'working',
capturedAt,
updatedAt: capturedAt,
origin: 'worktree-sleep'
}
promotedLiveRecoveryPaneKeys.add(existing.paneKey)
}
}
for (const retained of Object.values(state.retainedAgentsByPaneKey)) {
if (isCompletedAgentHibernation) {
continue
}
if (allowedPaneKeys && !allowedPaneKeys.has(retained.entry.paneKey)) {
continue
}
if (retained.worktreeId !== worktreeId) {
continue
}
// Why: the promoted checkpoint carries recovery identity (transcript, connection) a retained
// turn row lacks, so it must not be overwritten by a re-derived record.
if (promotedLiveRecoveryPaneKeys.has(retained.entry.paneKey)) {
continue
}
const record = sleepingRecordFromEntry({
state,
entry: isManualWorktreeSleep
? manualSleepCaptureEntry(retained.entry, capturedAt)
: retained.entry,
worktreeId,
tab: retained.tab,
capturedAt,
launchConfig: getLaunchConfigForEntry(state, retained.entry),
origin
})
if (record) {
if (isManualWorktreeSleep) {
markManualSleepLazyRestore(record)
carryOverAutomaticResumeBlock(
record,
state.sleepingAgentSessionsByPaneKey[retained.entry.paneKey]
)
}
records[record.paneKey] = record
}
}
for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) {
if (allowedPaneKeys && !allowedPaneKeys.has(paneKey)) {
continue
}
// Why: the promoted checkpoint carries recovery identity (transcript, connection) the live
// turn row lacks, so it must not be overwritten by a re-derived record.
if (promotedLiveRecoveryPaneKeys.has(paneKey)) {
continue
}
const belongsToWorktree =
entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes)
if (!belongsToWorktree) {
continue
}
if (isCompletedAgentHibernation && !isValidCompletedAgentHibernationEntry(entry)) {
continue
}
const record = sleepingRecordFromEntry({
state,
entry: isManualWorktreeSleep ? manualSleepCaptureEntry(entry, capturedAt) : entry,
worktreeId,
capturedAt,
launchConfig: getLaunchConfigForEntry(state, entry),
origin
})
if (record) {
if (isManualWorktreeSleep) {
markManualSleepLazyRestore(record)
carryOverAutomaticResumeBlock(record, state.sleepingAgentSessionsByPaneKey[paneKey])
}
records[record.paneKey] = record
}
}
return records
}
export function collectHibernatedCompletionEvidenceForWorktree(
state: AppState,
worktreeId: string,
paneKeys?: readonly string[]
): RetainedAgentEntry[] {
const allowedPaneKeys = normalizePaneKeySet(paneKeys)
if (!allowedPaneKeys || allowedPaneKeys.size === 0) {
return []
}
const tabPrefixes = (state.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`)
const retained: RetainedAgentEntry[] = []
for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) {
const agentType = entry.agentType
if (
!allowedPaneKeys.has(paneKey) ||
entry.state !== 'done' ||
agentType === undefined ||
entry.interrupted === true
) {
continue
}
const belongsToWorktree =
entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes)
if (!belongsToWorktree) {
continue
}
retained.push(retainedAgentEntryFromLive(state, worktreeId, entry, agentType))
}
return retained
}
@@ -0,0 +1,83 @@
import type {
SleepingAgentSessionRecord,
SleepingAgentLaunchConfig
} from '../../../../shared/agent-session-resume'
import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume'
export function launchConfigsEqual(
a: SleepingAgentLaunchConfig | undefined,
b: SleepingAgentLaunchConfig | undefined
): boolean {
if (a === undefined || b === undefined) {
return a === b
}
if (
a.agentCommand !== b.agentCommand ||
a.agentArgs !== b.agentArgs ||
a.ompResumeFilePath !== b.ompResumeFilePath
) {
return false
}
const aKeys = Object.keys(a.agentEnv)
const bKeys = Object.keys(b.agentEnv)
return aKeys.length === bKeys.length && aKeys.every((key) => a.agentEnv[key] === b.agentEnv[key])
}
export function sleepingRecordsEquivalentIgnoringCaptureTime(
existing: SleepingAgentSessionRecord | undefined,
next: SleepingAgentSessionRecord
): boolean {
if (!existing) {
return false
}
return (
existing.paneKey === next.paneKey &&
existing.tabId === next.tabId &&
existing.worktreeId === next.worktreeId &&
existing.agent === next.agent &&
agentProviderSessionsEqual(existing.agent, existing.providerSession, next.providerSession) &&
existing.prompt === next.prompt &&
existing.state === next.state &&
existing.updatedAt === next.updatedAt &&
existing.terminalTitle === next.terminalTitle &&
existing.lastAssistantMessage === next.lastAssistantMessage &&
existing.interrupted === next.interrupted &&
existing.origin === next.origin &&
launchConfigsEqual(existing.launchConfig, next.launchConfig)
)
}
export function recoveryRecordMatches(
existing: SleepingAgentSessionRecord | undefined,
next: SleepingAgentSessionRecord
): boolean {
if (!existing) {
return false
}
// Why: completion or interruption must replace a pre-status working checkpoint.
return (
existing.origin === next.origin &&
existing.agent === next.agent &&
existing.worktreeId === next.worktreeId &&
existing.tabId === next.tabId &&
existing.state === next.state &&
existing.interrupted === next.interrupted &&
agentProviderSessionsEqual(existing.agent, existing.providerSession, next.providerSession) &&
launchConfigsEqual(existing.launchConfig, next.launchConfig)
)
}
export function recoveryRecordTargetsSameSession(
existing: SleepingAgentSessionRecord | undefined,
next: SleepingAgentSessionRecord
): boolean {
if (!existing) {
return false
}
return (
existing.agent === next.agent &&
existing.worktreeId === next.worktreeId &&
existing.tabId === next.tabId &&
agentProviderSessionsEqual(existing.agent, existing.providerSession, next.providerSession)
)
}
@@ -0,0 +1,152 @@
import type { AppState } from '../types'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import {
agentProviderSessionsEqual,
getAgentResumeArgv,
isResumableTuiAgent,
type SleepingAgentLaunchConfig,
type SleepingAgentSessionRecord
} from '../../../../shared/agent-session-resume'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import { findTabForAgentEntry } from './agent-status-pane-helpers'
export function copyLaunchConfig(config: SleepingAgentLaunchConfig): SleepingAgentLaunchConfig {
return {
...(config.agentCommand ? { agentCommand: config.agentCommand } : {}),
agentArgs: config.agentArgs,
agentEnv: { ...config.agentEnv },
...(config.ompResumeFilePath ? { ompResumeFilePath: config.ompResumeFilePath } : {})
}
}
export function sleepingRecordFromEntry(args: {
state: AppState
entry: AgentStatusEntry
worktreeId: string
tab?: TerminalTab
capturedAt: number
launchConfig?: SleepingAgentLaunchConfig
origin?: SleepingAgentSessionRecord['origin']
}): SleepingAgentSessionRecord | null {
const agent = args.entry.agentType
if (
args.entry.terminalResumeEligible === false ||
!isResumableTuiAgent(agent) ||
!args.entry.providerSession
) {
return null
}
if (!getAgentResumeArgv(agent, args.entry.providerSession)) {
return null
}
const tab = args.tab ?? findTabForAgentEntry(args.state, args.worktreeId, args.entry)
return {
paneKey: args.entry.paneKey,
...(tab ? { tabId: tab.id } : {}),
worktreeId: args.worktreeId,
agent,
providerSession: args.entry.providerSession,
...(args.entry.connectionId !== undefined ? { connectionId: args.entry.connectionId } : {}),
prompt: args.entry.prompt,
state: args.entry.state,
capturedAt: args.capturedAt,
updatedAt: args.entry.updatedAt,
...((args.entry.terminalTitle ?? tab?.title)
? { terminalTitle: (args.entry.terminalTitle ?? tab?.title)! }
: {}),
...(args.entry.lastAssistantMessage
? { lastAssistantMessage: args.entry.lastAssistantMessage }
: {}),
...(args.launchConfig ? { launchConfig: copyLaunchConfig(args.launchConfig) } : {}),
...(args.entry.interrupted ? { interrupted: true } : {}),
...(args.origin ? { origin: args.origin } : {})
}
}
export type CollectSleepingAgentSessionRecordsOptions = {
paneKeys?: readonly string[]
captureMode?: 'manual-worktree-sleep' | 'completed-agent-hibernation'
}
export function normalizeSleepingAgentSessionCollectOptions(
options: readonly string[] | CollectSleepingAgentSessionRecordsOptions | undefined
): CollectSleepingAgentSessionRecordsOptions {
if (!options) {
return {}
}
return Array.isArray(options)
? { paneKeys: options }
: (options as CollectSleepingAgentSessionRecordsOptions)
}
export function isValidCompletedAgentHibernationEntry(entry: AgentStatusEntry): boolean {
return entry.state === 'done' && entry.interrupted !== true
}
// Why: a finished pane is passive wake evidence, and a mobile wake background-mounts every passive
// record's tab. Sleeping a workspace must not become "one phone tap respawns all of it" — the pane
// issues its own `--resume` cold restore when its tab is opened instead (#11598).
export function markManualSleepLazyRestore(record: SleepingAgentSessionRecord): void {
if (record.state === 'done') {
record.restoreOnTabOpenOnly = true
}
}
// Why: `live`/legacy rows are provisional checkpoints a fresh capture supersedes; an explicit
// sleep or quit capture is the pane's only resume handle once its live row is gone.
export function isDurableSleepingCapture(record: SleepingAgentSessionRecord): boolean {
return record.origin === 'worktree-sleep' || record.origin === 'quit'
}
// Why: manual sleep kills the pty either way, so the record carries resume identity, not the dead
// turn's interrupt flag — and an explicitly slept workspace is never stale at wake, so a row the
// user is deliberately sleeping must not trip the wake-side staleness discard. `state` is preserved
// so a done pane wakes lazily in place instead of spawning a new tab.
export function manualSleepCaptureEntry(
entry: AgentStatusEntry,
capturedAt: number
): AgentStatusEntry {
return { ...entry, updatedAt: capturedAt, interrupted: false }
}
// Why: capture recreates a record the manual-sleep wipe would otherwise remove, so a deliberately
// blocked worker must not become auto-resumable at wake.
export function carryOverAutomaticResumeBlock(
record: SleepingAgentSessionRecord,
previous: SleepingAgentSessionRecord | undefined
): void {
if (
previous?.automaticResumeBlockedBy === 'legacy-orchestration-worker' &&
previous.agent === record.agent &&
agentProviderSessionsEqual(record.agent, previous.providerSession, record.providerSession)
) {
record.automaticResumeBlockedBy = previous.automaticResumeBlockedBy
}
}
export function removeSleepingRecordsReplacedByManualWorktreeSleep(
records: Record<string, SleepingAgentSessionRecord>,
worktreeId: string,
paneKeys?: readonly string[],
replacements?: Readonly<Record<string, SleepingAgentSessionRecord>>
): { records: Record<string, SleepingAgentSessionRecord>; changed: boolean } {
const allowedPaneKeys = paneKeys ? new Set(paneKeys) : null
let next = records
let changed = false
for (const [paneKey, record] of Object.entries(records)) {
if (record.worktreeId !== worktreeId || (allowedPaneKeys && !allowedPaneKeys.has(paneKey))) {
continue
}
// Why: a repeat sleep must not delete a durable record this capture cannot re-derive — the
// pane was never woken, so it has no live status row to rebuild it from (#11598).
if (!replacements?.[paneKey] && isDurableSleepingCapture(record)) {
continue
}
if (next === records) {
next = { ...records }
}
delete next[paneKey]
changed = true
}
return { records: next, changed }
}
@@ -0,0 +1,141 @@
import type { RetainedAgentEntry } from './agent-status-contract'
import type { AgentStatusSlice } from './agent-status-slice-contract'
import type { AgentStatusRuntime } from './agent-status-runtime'
import { mergeCurrentOrchestrationContext } from './agent-status-map-helpers'
import { capRetainedAgents } from './agent-status-pane-helpers'
export function createAgentStatusRetentionActions(
runtime: AgentStatusRuntime
): Pick<
AgentStatusSlice,
| 'retainAgents'
| 'dismissRetainedAgent'
| 'dismissRetainedAgentsByWorktree'
| 'pruneRetainedAgents'
| 'clearRetentionSuppressedPaneKeys'
> {
const { set } = runtime
return {
retainAgents: (entries: RetainedAgentEntry[]) => {
if (entries.length === 0) {
return
}
set((s) => {
let changed = false
for (const retained of entries) {
if (s.retainedAgentsByPaneKey[retained.entry.paneKey] !== retained) {
changed = true
break
}
}
if (!changed) {
return s
}
const next = { ...s.retainedAgentsByPaneKey }
for (const retained of entries) {
const runtimeOrchestration = s.runtimeAgentOrchestrationByPaneKey[retained.entry.paneKey]
const mergedOrchestration = runtimeOrchestration
? mergeCurrentOrchestrationContext(retained.entry.orchestration, runtimeOrchestration)
: retained.entry.orchestration
const entry =
mergedOrchestration !== retained.entry.orchestration
? { ...retained.entry, orchestration: mergedOrchestration }
: retained.entry
next[retained.entry.paneKey] =
entry === retained.entry ? retained : { ...retained, entry }
}
return { retainedAgentsByPaneKey: capRetainedAgents(next) }
})
},
dismissRetainedAgent: (paneKey) => {
set((s) => {
if (!(paneKey in s.retainedAgentsByPaneKey)) {
return s
}
const next = { ...s.retainedAgentsByPaneKey }
delete next[paneKey]
const hasLive = paneKey in s.agentStatusByPaneKey
if (!hasLive || paneKey in s.retentionSuppressedPaneKeys) {
return { retainedAgentsByPaneKey: next }
}
return {
retainedAgentsByPaneKey: next,
retentionSuppressedPaneKeys: {
...s.retentionSuppressedPaneKeys,
[paneKey]: true
}
}
})
},
dismissRetainedAgentsByWorktree: (worktreeId) => {
const dismissedPaneKeys: string[] = []
set((s) => {
let changed = false
const next: Record<string, RetainedAgentEntry> = {}
const toSuppress: string[] = []
for (const [key, retained] of Object.entries(s.retainedAgentsByPaneKey)) {
if (retained.worktreeId === worktreeId) {
changed = true
dismissedPaneKeys.push(key)
if (key in s.agentStatusByPaneKey && !(key in s.retentionSuppressedPaneKeys)) {
toSuppress.push(key)
}
} else {
next[key] = retained
}
}
if (!changed) {
return s
}
if (toSuppress.length === 0) {
return { retainedAgentsByPaneKey: next }
}
const nextSuppressed = { ...s.retentionSuppressedPaneKeys }
for (const key of toSuppress) {
nextSuppressed[key] = true
}
return {
retainedAgentsByPaneKey: next,
retentionSuppressedPaneKeys: nextSuppressed
}
})
if (typeof window !== 'undefined') {
for (const paneKey of dismissedPaneKeys) {
window.api?.agentStatus?.drop?.(paneKey)
}
}
},
pruneRetainedAgents: (validWorktreeIds) => {
set((s) => {
let changed = false
const next: Record<string, RetainedAgentEntry> = {}
for (const [key, retained] of Object.entries(s.retainedAgentsByPaneKey)) {
if (!validWorktreeIds.has(retained.worktreeId)) {
changed = true
} else {
next[key] = retained
}
}
return changed ? { retainedAgentsByPaneKey: next } : s
})
},
clearRetentionSuppressedPaneKeys: (paneKeys) => {
set((s) => {
let changed = false
const next = { ...s.retentionSuppressedPaneKeys }
for (const paneKey of paneKeys) {
if (!(paneKey in next)) {
continue
}
delete next[paneKey]
changed = true
}
return changed ? { retentionSuppressedPaneKeys: next } : s
})
}
}
}
@@ -0,0 +1,224 @@
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { AgentStatusBatchTransaction, AgentStatusBatchUpdate } from './agent-status-contract'
import type { AgentStatusSlice } from './agent-status-slice-contract'
import type { GeneratedTabTitleUpdate } from './terminal-tab-title-batch'
import { createFreshnessScheduler } from './agent-status-freshness-scheduler'
export type AgentStatusStateUpdate =
| AppState
| Partial<AppState>
| ((state: AppState) => AppState | Partial<AppState>)
export type AgentStatusRuntime = {
get: () => AppState
set: (update: AgentStatusStateUpdate) => void
runAfterCommit: (effect: () => void) => void
applyGeneratedTabTitleUpdate: (update: GeneratedTabTitleUpdate) => void
requestFreshness: (acceptedInBatch: boolean) => void
freshness: ReturnType<typeof createFreshnessScheduler>
transactAgentStatuses: <Result>(
operation: (transaction: AgentStatusBatchTransaction) => Result
) => Result
clearSleepingAgentSessionsByPaneKey: (paneKeys: readonly string[]) => void
}
export function createAgentStatusRuntime(
storeSet: Parameters<StateCreator<AppState>>[0],
storeGet: Parameters<StateCreator<AppState>>[1],
getActions: () => Pick<AgentStatusSlice, 'setAgentStatus' | 'recordAgentProviderSession'>
): AgentStatusRuntime {
let batchedAgentStatusState: AppState | null = null
let batchedAgentStatusEffects: (() => void)[] | null = null
let batchedGeneratedTabTitleUpdates: GeneratedTabTitleUpdate[] | null = null
let batchedAgentStatusFreshnessRequested = false
const get = (): AppState => batchedAgentStatusState ?? storeGet()
// Deliberately narrower than zustand's `set`: no `replace` parameter, so no call site in
// this slice can compile into a REPLACE the batch commit is unable to express.
const set = (update: AgentStatusStateUpdate): void => {
if (batchedAgentStatusState === null) {
storeSet(update, false)
return
}
const nextState = typeof update === 'function' ? update(batchedAgentStatusState) : update
if (Object.is(nextState, batchedAgentStatusState)) {
return
}
batchedAgentStatusState = Object.assign({}, batchedAgentStatusState, nextState)
}
const runAfterCommit = (effect: () => void): void => {
if (batchedAgentStatusEffects) {
batchedAgentStatusEffects.push(effect)
return
}
effect()
}
const applyGeneratedTabTitleUpdate = (update: GeneratedTabTitleUpdate): void => {
if (batchedGeneratedTabTitleUpdates) {
batchedGeneratedTabTitleUpdates.push(update)
return
}
if (update.options) {
get().setGeneratedTabTitleFromAgentPrompt(update.paneKey, update.prompt, update.options)
} else {
get().setGeneratedTabTitleFromAgentPrompt(update.paneKey, update.prompt)
}
}
const requestFreshness = (acceptedInBatch: boolean): void => {
if (batchedAgentStatusState !== null) {
batchedAgentStatusFreshnessRequested ||= acceptedInBatch
return
}
freshness.scheduleDeferred()
}
const applyBatchedAgentStatusUpdate = (update: AgentStatusBatchUpdate): boolean => {
const stateBeforeUpdate = batchedAgentStatusState
if (!stateBeforeUpdate) {
return false
}
const actions = getActions()
if (update.kind === 'providerSession') {
actions.recordAgentProviderSession(
update.paneKey,
update.agent,
update.providerSession,
update.timing,
update.routing,
update.metadata
)
} else {
actions.setAgentStatus(
update.paneKey,
update.payload,
update.terminalTitle,
update.timing,
update.routing,
update.metadata
)
}
return batchedAgentStatusState !== stateBeforeUpdate
}
const batchTransaction: AgentStatusBatchTransaction = {
getState: get,
apply: applyBatchedAgentStatusUpdate,
afterCommit: runAfterCommit
}
const transactAgentStatuses = <Result>(
operation: (transaction: AgentStatusBatchTransaction) => Result
): Result => {
if (batchedAgentStatusState) {
return operation(batchTransaction)
}
const initialState = storeGet()
batchedAgentStatusState = initialState
batchedAgentStatusEffects = []
batchedGeneratedTabTitleUpdates = []
try {
const result = operation(batchTransaction)
const nextState = batchedAgentStatusState
const effects = batchedAgentStatusEffects
const generatedTabTitleUpdates = batchedGeneratedTabTitleUpdates
const freshnessRequested = batchedAgentStatusFreshnessRequested
batchedAgentStatusState = null
batchedAgentStatusEffects = null
batchedGeneratedTabTitleUpdates = null
batchedAgentStatusFreshnessRequested = false
if (nextState !== initialState) {
storeSet(buildAgentStatusBatchPatch(initialState, nextState), false)
}
if (generatedTabTitleUpdates.length > 0) {
storeGet().setGeneratedTabTitlesFromAgentPrompts(generatedTabTitleUpdates)
}
if (freshnessRequested) {
freshness.scheduleDeferred()
}
for (const effect of effects) {
effect()
}
return result
} finally {
batchedAgentStatusState = null
batchedAgentStatusEffects = null
batchedGeneratedTabTitleUpdates = null
batchedAgentStatusFreshnessRequested = false
}
}
const freshness = createFreshnessScheduler({
getEntries: () => Object.values(get().agentStatusByPaneKey),
bumpEpochs: () => {
// Why: freshness is time-based — bump both epochs at the stale boundary to force selector
// recompute and re-sort even with no new output, since staleness can change worktree ordering.
set((s) => ({
agentStatusEpoch: s.agentStatusEpoch + 1,
sortEpoch: s.sortEpoch + 1
}))
}
})
const clearSleepingAgentSessionsByPaneKey = (paneKeys: readonly string[]): void => {
if (paneKeys.length === 0) {
return
}
const uniquePaneKeys = new Set(paneKeys)
set((s) => {
let nextSleeping = s.sleepingAgentSessionsByPaneKey
let nextLaunchConfigs = s.agentLaunchConfigByPaneKey
for (const paneKey of uniquePaneKeys) {
if (paneKey in nextSleeping) {
if (nextSleeping === s.sleepingAgentSessionsByPaneKey) {
nextSleeping = { ...nextSleeping }
}
delete nextSleeping[paneKey]
}
if (paneKey in nextLaunchConfigs) {
if (nextLaunchConfigs === s.agentLaunchConfigByPaneKey) {
nextLaunchConfigs = { ...nextLaunchConfigs }
}
delete nextLaunchConfigs[paneKey]
}
}
if (
nextSleeping === s.sleepingAgentSessionsByPaneKey &&
nextLaunchConfigs === s.agentLaunchConfigByPaneKey
) {
return s
}
return {
sleepingAgentSessionsByPaneKey: nextSleeping,
agentLaunchConfigByPaneKey: nextLaunchConfigs
}
})
}
return {
get,
set,
runAfterCommit,
applyGeneratedTabTitleUpdate,
requestFreshness,
freshness,
transactAgentStatuses,
clearSleepingAgentSessionsByPaneKey
}
}
function buildAgentStatusBatchPatch(
initialState: AppState,
nextState: AppState
): Partial<AppState> {
const patch: Record<string, unknown> = {}
for (const key of Object.keys(nextState) as (keyof AppState)[]) {
if (!Object.is(nextState[key], initialState[key])) {
patch[key as string] = nextState[key]
}
}
return patch as Partial<AppState>
}
@@ -0,0 +1,178 @@
import type {
AgentLaunchConfigRegistryEntry,
AgentLaunchConfigRegistrationMetadata,
AgentLaunchConfigStatusMetadata,
AgentProviderSessionRecordMetadata,
AgentProviderSessionRouting,
AgentProviderSessionTiming,
AgentStatusBatchTransaction,
AgentStatusBatchUpdate,
AgentStatusMetadata,
AgentStatusPayload,
AgentStatusRouting,
AgentStatusTiming,
AgentProviderSessionMetadata,
DropAgentStatusByTabPrefixOptions,
DropAgentStatusByWorktreeOptions,
DropHibernatedAgentPaneOptions,
RetainedAgentEntry,
AllAgentSessionCaptureMode
} from './agent-status-contract'
import type {
AgentStatusEntry,
AgentStatusOrchestrationContext,
MigrationUnsupportedPtyEntry
} from '../../../../shared/agent-status-types'
import type {
ResumableTuiAgent,
SleepingAgentLaunchConfig,
SleepingAgentSessionRecord
} from '../../../../shared/agent-session-resume'
export type AgentStatusSlice = {
/** Explicit agent status entries keyed by `${tabId}:${leafId}`; real-time only, not persisted. */
agentStatusByPaneKey: Record<string, AgentStatusEntry>
/** Main-synced dispatch metadata for live panes that may only have title-derived status in the renderer. */
runtimeAgentOrchestrationByPaneKey: Record<string, AgentStatusOrchestrationContext>
/** PTYs still reporting legacy numeric pane keys but with registry-backed UUID proof; stored separately from normal hook-reported status. */
migrationUnsupportedByPtyId: Record<string, MigrationUnsupportedPtyEntry>
/** Monotonic tick that advances when agent-status freshness boundaries pass. */
agentStatusEpoch: number
/** SSH connections whose transient rows were cleared and must reject renderer callbacks
* until a later reconnect establishes a new connection lifecycle. */
transientClearedAgentStatusConnectionIds: Record<string, true>
/** Arm the shared freshness timer after an external mirror writes live rows. */
scheduleAgentStatusFreshness: () => void
/** Retained "done" snapshots of agents gone from `agentStatusByPaneKey`, keyed by paneKey so pane re-appearance overwrites; shared by dashboard and sidebar hover. */
retainedAgentsByPaneKey: Record<string, RetainedAgentEntry>
/** Durable agent sessions captured on sleep (not live rows); power the one-click CLI resume on wake. */
sleepingAgentSessionsByPaneKey: Record<string, SleepingAgentSessionRecord>
/** Ephemeral launch snapshots keyed by pane; hook payloads lack Orca launch settings, so the renderer supplies them from startup. */
agentLaunchConfigByPaneKey: Record<string, AgentLaunchConfigRegistryEntry>
/** Pane keys explicitly torn down, forbidden from re-retention on next disappearance; a one-shot suppressor consumed by the retention sync. */
retentionSuppressedPaneKeys: Record<string, true>
/** Terminal tabs explicitly closed this session; used to drop late in-flight IPC statuses and stale main-cache replays. */
recentlyClosedAgentStatusTabIds: Record<string, true>
/** Exact pane authorities retired while sibling panes in the tab stay live. */
recentlyRetiredAgentStatusPaneKeys: Record<string, true>
retireAgentPaneAuthority: (
paneKey: string,
options?: { preserveSleepingAgentSession?: boolean }
) => void
/** Lift a pane's retirement fence once a live PTY re-attaches to it. Closed tabs stay retired. */
restoreAgentPaneAuthority: (paneKey: string) => void
transferAgentPaneAuthority: (args: {
fromPaneKey: string
toPaneKey: string
ptyId?: string | null
}) => void
/** Update or insert an agent status entry from a status payload. */
setAgentStatus: (
paneKey: string,
payload: AgentStatusPayload,
terminalTitle?: string,
timing?: AgentStatusTiming,
routing?: AgentStatusRouting,
metadata?: AgentStatusMetadata
) => void
/** Apply ordered status updates as one status publication (generated titles and tab
* titles still publish after it — three total, not 2N). */
setAgentStatuses: (updates: readonly AgentStatusBatchUpdate[]) => boolean[]
/** Fold caller-derived updates against exact staged state, committing one status publication. */
transactAgentStatuses: <Result>(
operation: (transaction: AgentStatusBatchTransaction) => Result
) => Result
/** Record resume identity without creating a visible turn-status row. */
recordAgentProviderSession: (
paneKey: string,
agent: ResumableTuiAgent,
providerSession: AgentProviderSessionMetadata,
timing?: AgentProviderSessionTiming,
routing?: AgentProviderSessionRouting,
metadata?: AgentProviderSessionRecordMetadata
) => void
registerAgentLaunchConfig: (
paneKey: string,
launchConfig: SleepingAgentLaunchConfig,
metadata?: AgentLaunchConfigRegistrationMetadata
) => void
getAgentLaunchConfigForStatusEntry: (
entry: AgentStatusEntry
) => SleepingAgentLaunchConfig | undefined
getAgentLaunchConfigForStatusMetadata: (
metadata: AgentLaunchConfigStatusMetadata
) => SleepingAgentLaunchConfig | undefined
clearAgentLaunchConfig: (paneKey: string) => void
setRuntimeAgentOrchestrationByPaneKey: (
entries: Record<string, AgentStatusOrchestrationContext>
) => void
setMigrationUnsupportedPty: (entry: MigrationUnsupportedPtyEntry) => void
clearMigrationUnsupportedPty: (ptyId: string) => void
/** Remove a single entry (e.g., when a pane's terminal exits). */
removeAgentStatus: (paneKey: string) => void
/** Remove all entries whose paneKey starts with the given prefix (tab close prefix-sweep). */
removeAgentStatusByTabPrefix: (tabIdPrefix: string) => void
/** Remove stale live rows while preserving pane launch and resume identity. */
clearTransientAgentStatuses: (connectionId: string, clearedAt: number) => void
/** Remove a single entry AND suppress re-retention on its next disappearance (user-initiated teardown: X button, pane close). */
dropAgentStatus: (paneKey: string) => void
/** Remove all entries under a tab AND suppress re-retention for each (tab close — no rows may reappear). */
dropAgentStatusByTabPrefix: (
tabIdPrefix: string,
opts?: DropAgentStatusByTabPrefixOptions
) => void
/** Remove one auto-hibernated completed-agent pane while preserving sibling live/retained rows in the same worktree. */
dropHibernatedAgentStatusPane: (
worktreeId: string,
paneKey: string,
opts?: DropHibernatedAgentPaneOptions
) => void
/** Remove all entries for a worktree AND suppress re-retention for live rows (worktree sleep/remove).
* Sweeps live rows by tab prefix and by main-stamped worktree attribution so worker rows that arrive before their tab don't survive. */
dropAgentStatusByWorktree: (worktreeId: string, opts?: DropAgentStatusByWorktreeOptions) => void
captureSleepingAgentSessionsByWorktree: (worktreeId: string, paneKeys?: string[]) => void
/** Capture resumable agent sessions across every worktree for crash recovery or quit; mode sets live/quit precedence. */
captureAllSleepingAgentSessions: (mode: AllAgentSessionCaptureMode) => void
clearSleepingAgentSession: (paneKey: string) => void
clearSleepingAgentSessionsByPaneKey: (paneKeys: readonly string[]) => void
setSleepingAgentAutomaticResumeBlocked: (paneKey: string, blocked: boolean) => void
clearSleepingAgentSessionsByWorktree: (worktreeId: string) => void
pruneSleepingAgentSessions: (validWorktreeIds: Set<string>) => void
/** Retain agent snapshots. Accepts an array so simultaneous disappearances produce a single set() with no mid-loop intermediate states. */
retainAgents: (entries: RetainedAgentEntry[]) => void
/** Dismiss a retained entry by its paneKey. */
dismissRetainedAgent: (paneKey: string) => void
/** Dismiss all retained entries belonging to a worktree. */
dismissRetainedAgentsByWorktree: (worktreeId: string) => void
/** Prune retained entries whose worktreeId is not in the given set. */
pruneRetainedAgents: (validWorktreeIds: Set<string>) => void
/** Clear one-shot teardown suppressors after the retention sync declines to retain the row. */
clearRetentionSuppressedPaneKeys: (paneKeys: string[]) => void
}
@@ -0,0 +1,157 @@
import type { DropAgentStatusByWorktreeOptions, RetainedAgentEntry } from './agent-status-contract'
import type { AgentStatusSlice } from './agent-status-slice-contract'
import type { AgentStatusRuntime } from './agent-status-runtime'
import { pruneMigrationUnsupportedEntries } from './agent-status-map-helpers'
import {
normalizePaneKeySet,
paneKeyMatchesAnyTabPrefix,
retainedAgentEntryFromLive,
shouldReplaceRetainedWithLive
} from './agent-status-pane-helpers'
export function createAgentStatusWorktreeDropActions(
runtime: AgentStatusRuntime
): Pick<AgentStatusSlice, 'dropAgentStatusByWorktree'> {
const { set, freshness } = runtime
return {
dropAgentStatusByWorktree: (worktreeId, opts?: DropAgentStatusByWorktreeOptions) => {
let hadLive = false
set((s) => {
const tabPrefixes = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`)
const liveEntries = Object.entries(s.agentStatusByPaneKey).filter(
([paneKey, entry]) =>
entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes)
)
const liveKeys = liveEntries.map(([paneKey]) => paneKey)
const liveKeySet = new Set(liveKeys)
const launchConfigKeys = Object.keys(s.agentLaunchConfigByPaneKey).filter(
(paneKey) => paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) || liveKeySet.has(paneKey)
)
const retainedKeys = Object.entries(s.retainedAgentsByPaneKey)
.filter(
([paneKey, retained]) =>
retained.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes)
)
.map(([paneKey]) => paneKey)
const retainedKeySet = new Set(retainedKeys)
const migrationUnsupported = pruneMigrationUnsupportedEntries(
s.migrationUnsupportedByPtyId,
(entry) =>
entry.worktreeId === worktreeId ||
(entry.paneKey ? paneKeyMatchesAnyTabPrefix(entry.paneKey, tabPrefixes) : false)
)
const allowedPaneKeys = normalizePaneKeySet(opts?.sleepingPaneKeys)
const preserveHibernatedEvidence =
opts?.shutdownReason === 'auto-hibernate-completed-agent' &&
allowedPaneKeys !== null &&
allowedPaneKeys.size > 0
const liveEntryByPaneKey = new Map(liveEntries)
const retainedEvidence = new Map<string, RetainedAgentEntry>()
if (preserveHibernatedEvidence) {
for (const retained of opts?.retainedCompletionEvidence ?? []) {
if (
allowedPaneKeys.has(retained.entry.paneKey) &&
!liveEntryByPaneKey.has(retained.entry.paneKey) &&
shouldReplaceRetainedWithLive(retainedEvidence.get(retained.entry.paneKey), retained)
) {
retainedEvidence.set(retained.entry.paneKey, retained)
}
}
for (const [paneKey, entry] of liveEntries) {
if (
allowedPaneKeys.has(paneKey) &&
entry.state === 'done' &&
entry.agentType !== undefined &&
entry.interrupted !== true
) {
retainedEvidence.set(
paneKey,
retainedAgentEntryFromLive(s, worktreeId, entry, entry.agentType)
)
}
}
}
const retainedEvidenceKeys = new Set(retainedEvidence.keys())
// Keep acknowledgement for completion evidence so a slept card does not turn bold again.
let nextAck = s.acknowledgedAgentsByPaneKey
const ackKeys = Object.keys(nextAck).filter(
(key) =>
!retainedEvidenceKeys.has(key) &&
(paneKeyMatchesAnyTabPrefix(key, tabPrefixes) ||
liveKeySet.has(key) ||
retainedKeySet.has(key))
)
if (ackKeys.length > 0) {
nextAck = { ...nextAck }
for (const key of ackKeys) {
delete nextAck[key]
}
}
if (
liveKeys.length === 0 &&
launchConfigKeys.length === 0 &&
retainedKeys.length === 0 &&
retainedEvidence.size === 0 &&
!migrationUnsupported.changed
) {
return nextAck !== s.acknowledgedAgentsByPaneKey
? { acknowledgedAgentsByPaneKey: nextAck }
: s
}
hadLive = liveKeys.length > 0
const nextLive =
liveKeys.length > 0 ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey
for (const key of liveKeys) {
delete nextLive[key]
}
const nextLaunchConfigs =
launchConfigKeys.length > 0
? { ...s.agentLaunchConfigByPaneKey }
: s.agentLaunchConfigByPaneKey
for (const key of launchConfigKeys) {
delete nextLaunchConfigs[key]
}
const nextRetained =
retainedKeys.length > 0 || retainedEvidence.size > 0
? { ...s.retainedAgentsByPaneKey }
: s.retainedAgentsByPaneKey
for (const key of retainedKeys) {
if (!retainedEvidenceKeys.has(key)) {
delete nextRetained[key]
}
}
for (const [paneKey, retained] of retainedEvidence) {
if (shouldReplaceRetainedWithLive(nextRetained[paneKey], retained)) {
nextRetained[paneKey] = retained
}
}
const suppressorAdds = liveKeys.filter(
(key) => !retainedEvidenceKeys.has(key) && !(key in s.retentionSuppressedPaneKeys)
)
let nextRetentionSuppressedPaneKeys = s.retentionSuppressedPaneKeys
if (suppressorAdds.length > 0) {
nextRetentionSuppressedPaneKeys = { ...s.retentionSuppressedPaneKeys }
for (const key of suppressorAdds) {
nextRetentionSuppressedPaneKeys[key] = true
}
}
return {
agentStatusByPaneKey: nextLive,
agentLaunchConfigByPaneKey: nextLaunchConfigs,
retainedAgentsByPaneKey: nextRetained,
migrationUnsupportedByPtyId: migrationUnsupported.next,
retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys,
...(nextAck !== s.acknowledgedAgentsByPaneKey
? { acknowledgedAgentsByPaneKey: nextAck }
: {}),
agentStatusEpoch:
hadLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch,
sortEpoch: hadLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch
}
})
if (hadLive) {
freshness.scheduleDeferred()
}
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,250 @@
import type {
BrowserSlice,
BrowserSliceGet,
BrowserSliceSet,
RemoteBrowserPageHandle
} from './browser-slice-contract'
import type { BrowserWorkspace } from '../../../../../shared/browser-workspace-types'
import { findWorkspace } from '../browser-page-records'
import { pickNeighbor } from '../tab-group-state'
import { getRecentlyClosedTabPosition, pushRecentlyClosedTabKind } from '../recently-closed-tabs'
import { getFallbackTabTypeForWorktree, isLocalBrowserPageOwner } from './browser-host-state'
import { closeRemoteBrowserPageInOwningEnvironment } from './browser-remote-close'
import { releaseDocPreviewGrant } from '@/lib/doc-preview-grants'
import { destroyWorkspaceWebviews } from '../browser-webview-cleanup'
export function createBrowserCloseActions(
set: BrowserSliceSet,
get: BrowserSliceGet
): Pick<BrowserSlice, 'closeBrowserTab' | 'shutdownWorktreeBrowsers'> {
return {
closeBrowserTab: (tabId, options) => {
// Why: a cleanup close unwinds a tab that never finished being created — it owns no host
// page to close and must not enter the reopen stack as if the user had closed something.
const isCleanup = options?.reason === 'cleanup'
let remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = []
// Why collected rather than released inside the reducer: revoking is main-process work, and a
// grant is the only authority the preview scheme honors — a closed document must stop being
// readable, and it must stop being readable even if the reducer bails out below.
let docPageIdsToRelease: string[] = []
let activeBrowserWorktreeIdToNotify: string | null = null
set((s) => {
let owningWorktreeId: string | null = null
let closedWorkspace: BrowserWorkspace | null = null
const nextBrowserTabsByWorktree: Record<string, BrowserWorkspace[]> = {}
for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) {
const removedTab = tabs.find((tab) => tab.id === tabId) ?? null
const filtered = tabs.filter((tab) => tab.id !== tabId)
if (filtered.length !== tabs.length) {
owningWorktreeId = worktreeId
closedWorkspace = removedTab
}
if (filtered.length > 0) {
nextBrowserTabsByWorktree[worktreeId] = filtered
}
}
if (!owningWorktreeId || !closedWorkspace) {
return s
}
const closedPages = s.browserPagesByWorkspace[tabId] ?? []
const nextBrowserPagesByWorkspace = { ...s.browserPagesByWorkspace }
delete nextBrowserPagesByWorkspace[tabId]
const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId }
const nextBrowserCertificateFailuresByPageId = {
...s.browserCertificateFailuresByPageId
}
for (const page of closedPages) {
delete nextBrowserAnnotationsByPageId[page.id]
delete nextBrowserCertificateFailuresByPageId[page.id]
}
docPageIdsToRelease = closedPages.filter((page) => page.docLocation).map((page) => page.id)
remotePagesToClose = isCleanup
? []
: closedPages.flatMap((page) => {
const handle = s.remoteBrowserPageHandlesByPageId[page.id]
return handle ? [{ worktreeId: page.worktreeId, handle }] : []
})
const nextRemoteBrowserPageHandlesByPageId = {
...s.remoteBrowserPageHandlesByPageId
}
for (const page of closedPages) {
delete nextRemoteBrowserPageHandlesByPageId[page.id]
}
const remainingBrowserTabs = nextBrowserTabsByWorktree[owningWorktreeId] ?? []
const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree }
if (nextActiveBrowserTabIdByWorktree[owningWorktreeId] === tabId) {
const neighborId = pickNeighbor(s.tabBarOrderByWorktree[owningWorktreeId] ?? [], tabId)
nextActiveBrowserTabIdByWorktree[owningWorktreeId] =
(neighborId && remainingBrowserTabs.some((tab) => tab.id === neighborId)
? neighborId
: remainingBrowserTabs[0]?.id) ?? null
}
const nextTabBarOrder = {
...s.tabBarOrderByWorktree,
[owningWorktreeId]: (s.tabBarOrderByWorktree[owningWorktreeId] ?? []).filter(
(entryId) => entryId !== tabId
)
}
const isActiveTabInOwningWorktree =
s.activeWorktreeId === owningWorktreeId && s.activeBrowserTabId === tabId
if (isActiveTabInOwningWorktree) {
activeBrowserWorktreeIdToNotify = owningWorktreeId
}
const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree }
let nextActiveTabType = s.activeTabType
if (remainingBrowserTabs.length === 0) {
const fallbackTabType = getFallbackTabTypeForWorktree(
owningWorktreeId,
s.openFiles,
s.tabsByWorktree
)
nextActiveTabTypeByWorktree[owningWorktreeId] = fallbackTabType
if (isActiveTabInOwningWorktree && s.activeTabType === 'browser') {
nextActiveTabType = fallbackTabType
}
}
const nextRecentlyClosedBrowserTabsByWorktree = { ...s.recentlyClosedBrowserTabsByWorktree }
if (!isCleanup) {
const existingSnapshots = nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] ?? []
const position = getRecentlyClosedTabPosition(s, owningWorktreeId, tabId)
nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] = [
{
workspace: closedWorkspace,
pages: closedPages,
...(position ? { position } : {})
},
...existingSnapshots.filter((entry) => entry.workspace.id !== closedWorkspace.id)
].slice(0, 10)
}
const nextRecentlyClosedTabKindsByWorktree = isCleanup
? s.recentlyClosedTabKindsByWorktree
: pushRecentlyClosedTabKind(
s.recentlyClosedTabKindsByWorktree,
owningWorktreeId,
'browser'
)
const nextRecentlyClosedBrowserPagesByWorkspace = {
...s.recentlyClosedBrowserPagesByWorkspace
}
delete nextRecentlyClosedBrowserPagesByWorkspace[tabId]
const nextPendingAddressBarFocusByPageId = Object.fromEntries(
Object.entries(s.pendingAddressBarFocusByPageId).filter(
([pageId]) => !closedPages.some((page) => page.id === pageId)
)
)
const nextPendingAddressBarFocusByTabId = Object.fromEntries(
Object.entries(s.pendingAddressBarFocusByTabId).filter(
([focusId]) => focusId !== tabId && !closedPages.some((page) => page.id === focusId)
)
)
return {
browserTabsByWorktree: nextBrowserTabsByWorktree,
browserPagesByWorkspace: nextBrowserPagesByWorkspace,
activeBrowserTabId:
s.activeBrowserTabId === tabId
? (nextActiveBrowserTabIdByWorktree[owningWorktreeId] ?? null)
: s.activeBrowserTabId,
activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree,
tabBarOrderByWorktree: nextTabBarOrder,
activeTabType: nextActiveTabType,
pendingAddressBarFocusByPageId: nextPendingAddressBarFocusByPageId,
pendingAddressBarFocusByTabId: nextPendingAddressBarFocusByTabId,
activeTabTypeByWorktree: nextActiveTabTypeByWorktree,
recentlyClosedBrowserTabsByWorktree: nextRecentlyClosedBrowserTabsByWorktree,
recentlyClosedTabKindsByWorktree: nextRecentlyClosedTabKindsByWorktree,
recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace,
remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId,
browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId,
browserAnnotationsByPageId: nextBrowserAnnotationsByPageId
}
})
for (const remotePage of remotePagesToClose) {
closeRemoteBrowserPageInOwningEnvironment(
remotePage.worktreeId,
remotePage.handle,
get().recordClientHostedBrowserCloseIntents
)
}
for (const docPageId of docPageIdsToRelease) {
releaseDocPreviewGrant(docPageId)
}
for (const tabs of Object.values(get().unifiedTabsByWorktree)) {
const workspaceItem = tabs.find(
(entry) => entry.contentType === 'browser' && entry.entityId === tabId
)
if (workspaceItem) {
get().closeUnifiedTab(
workspaceItem.id,
isCleanup ? { preserveWorktreeSelection: true, recordInteraction: false } : undefined
)
}
}
// Why: announce the MRU page before guest teardown so bridge fallback cannot choose registration order.
if (activeBrowserWorktreeIdToNotify) {
const state = get()
const activeWorkspaceId =
state.activeBrowserTabIdByWorktree[activeBrowserWorktreeIdToNotify]
const activeWorkspace = activeWorkspaceId
? findWorkspace(state.browserTabsByWorktree, activeWorkspaceId)
: null
const activePage = activeWorkspace?.activePageId
? (state.browserPagesByWorkspace[activeWorkspace.id] ?? []).find(
(page) => page.id === activeWorkspace.activePageId
)
: undefined
if (
activeWorkspace?.activePageId &&
isLocalBrowserPageOwner(
state,
activeBrowserWorktreeIdToNotify,
activePage?.browserRuntimeEnvironmentId
) &&
typeof window !== 'undefined' &&
window.api?.browser
) {
window.api.browser
.notifyActiveTabChanged({ browserPageId: activeWorkspace.activePageId })
.catch(() => {})
}
}
},
shutdownWorktreeBrowsers: async (worktreeId) => {
const workspaces = get().browserTabsByWorktree[worktreeId] ?? []
// Why: snapshot before the loop — closeBrowserTab empties the array, so set() below couldn't recompute hadBrowserTabs.
const hadBrowserTabs = workspaces.length > 0
for (const workspace of workspaces) {
const browserPagesByWorkspace = get().browserPagesByWorkspace
get().closeBrowserTab(workspace.id)
destroyWorkspaceWebviews(browserPagesByWorkspace, workspace.id)
}
set((s) => {
const nextBrowserTabsByWorktree = { ...s.browserTabsByWorktree }
delete nextBrowserTabsByWorktree[worktreeId]
const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree }
delete nextActiveBrowserTabIdByWorktree[worktreeId]
// Why: reset the global browser surface only when the shut-down worktree is the active one AND had tabs.
const shouldResetGlobalBrowser = s.activeWorktreeId === worktreeId && hadBrowserTabs
return {
browserTabsByWorktree: nextBrowserTabsByWorktree,
activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree,
...(shouldResetGlobalBrowser
? { activeBrowserTabId: null, activeTabType: 'terminal' as const }
: {})
}
})
}
}
}
@@ -0,0 +1,193 @@
import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract'
import type { BrowserCookieImportResult } from '../../../../../shared/browser-workspace-types'
import type {
BrowserProfileImportFromBrowserResult,
BrowserProfileClearDefaultCookiesResult
} from '../../../../../shared/runtime-types'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { selectExecutionHostDisplayLabel } from '@/lib/execution-host-display-label'
import {
getBrowserSettingsHostId,
getBrowserSettingsRuntimeEnvironmentId,
browserImportStateForHostUpdate,
retainCookieImportExecutionHost
} from './browser-host-state'
export function createBrowserCookieImportActions(
set: BrowserSliceSet,
get: BrowserSliceGet
): Pick<BrowserSlice, 'importCookiesFromBrowser' | 'clearDefaultSessionCookies'> {
return {
importCookiesFromBrowser: async (profileId, browserFamily, browserProfile?) => {
const initialState = get()
const hostId = getBrowserSettingsHostId(initialState)
const executionHostLabel = selectExecutionHostDisplayLabel(initialState, hostId)
const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(initialState)
if (runtimeEnvironmentId) {
set((state) =>
browserImportStateForHostUpdate(state, hostId, {
profileId,
status: 'importing',
summary: null,
error: null
})
)
// Why: tracked across the try so a failed fallback RPC still reports the machine it ran on.
let ranOnClient = false
try {
// Why: client-hosted pages render on this desktop, so their logins live
// here -- detecting and importing on the headless remote finds nothing.
const clientHostResult = await window.api.browser.sessionImportFromBrowserForClientHost({
environmentId: runtimeEnvironmentId,
profileId,
browserFamily,
browserProfile
})
ranOnClient = clientHostResult != null
const result =
clientHostResult ??
(await callRuntimeRpc<BrowserProfileImportFromBrowserResult>(
{ kind: 'environment', environmentId: runtimeEnvironmentId },
'browser.profileImportFromBrowser',
{ profileId, browserFamily, browserProfile, supportsPartitionSkippedCookies: true },
{ timeoutMs: 30_000 }
))
if (result.ok) {
set((state) =>
browserImportStateForHostUpdate(state, hostId, {
profileId,
status: 'success',
summary: result.summary,
error: null
})
)
if (getBrowserSettingsHostId(get()) === hostId) {
await get()
.fetchBrowserSessionProfiles()
.catch(() => {})
}
} else {
set((state) =>
browserImportStateForHostUpdate(state, hostId, {
profileId,
status: 'error',
summary: null,
error: result.reason
})
)
}
return retainCookieImportExecutionHost(
result,
hostId,
executionHostLabel,
ranOnClient ? 'client' : 'remote'
)
} catch (err) {
const reason = String((err as Error)?.message ?? err)
set((state) =>
browserImportStateForHostUpdate(state, hostId, {
profileId,
status: 'error',
summary: null,
error: reason
})
)
return retainCookieImportExecutionHost(
{ ok: false as const, reason },
hostId,
executionHostLabel,
ranOnClient ? 'client' : 'remote'
)
}
}
set((state) =>
browserImportStateForHostUpdate(state, hostId, {
profileId,
status: 'importing',
summary: null,
error: null
})
)
try {
const result = (await window.api.browser.sessionImportFromBrowser({
profileId,
browserFamily,
browserProfile
})) as BrowserCookieImportResult
if (result.ok) {
get().recordFeatureInteraction?.('cookie-import')
set((state) =>
browserImportStateForHostUpdate(state, hostId, {
profileId,
status: 'success',
summary: result.summary,
error: null
})
)
if (getBrowserSettingsHostId(get()) === hostId) {
await get()
.fetchBrowserSessionProfiles()
.catch(() => {})
}
} else {
set((state) =>
browserImportStateForHostUpdate(state, hostId, {
profileId,
status: 'error',
summary: null,
error: result.reason
})
)
}
return retainCookieImportExecutionHost(result, hostId, executionHostLabel, 'client')
} catch (err) {
const reason = String((err as Error)?.message ?? err)
set((state) =>
browserImportStateForHostUpdate(state, hostId, {
profileId,
status: 'error',
summary: null,
error: reason
})
)
return retainCookieImportExecutionHost(
{ ok: false as const, reason },
hostId,
executionHostLabel,
'client'
)
}
},
clearDefaultSessionCookies: async () => {
const hostId = getBrowserSettingsHostId(get())
const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get())
if (runtimeEnvironmentId) {
try {
const result = await callRuntimeRpc<BrowserProfileClearDefaultCookiesResult>(
{ kind: 'environment', environmentId: runtimeEnvironmentId },
'browser.profileClearDefaultCookies',
undefined,
{ timeoutMs: 15_000 }
)
if (result.cleared && getBrowserSettingsHostId(get()) === hostId) {
await get().fetchBrowserSessionProfiles()
}
return result.cleared
} catch {
return false
}
}
try {
const ok = await window.api.browser.sessionClearDefaultCookies()
if (ok && getBrowserSettingsHostId(get()) === hostId) {
get().recordFeatureInteraction?.('cookie-import')
await get().fetchBrowserSessionProfiles()
}
return ok
} catch {
return false
}
}
}
}
@@ -0,0 +1,99 @@
import type { BrowserHistoryEntry } from '../../../../../shared/browser-workspace-types'
import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract'
import type { WorkspaceDocHistoryEntry } from '../../../../../shared/workspace-doc-history'
import { redactKagiSessionToken } from '../../../../../shared/browser-url'
import {
MAX_BROWSER_HISTORY_ENTRIES,
normalizeBrowserHistoryUrl
} from '../../../../../shared/workspace-session-browser-history'
import {
MAX_WORKSPACE_DOC_HISTORY_ENTRIES,
normalizeWorkspaceDocHistoryEntries,
normalizeWorkspaceDocHistoryTitle
} from '../../../../../shared/workspace-doc-history'
import { browserPageDocLocationsEqual } from '../../../../../shared/browser-page-doc-location'
import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/constants'
export function createBrowserHistoryActions(
set: BrowserSliceSet,
_get: BrowserSliceGet
): Pick<
BrowserSlice,
'recordWorkspaceDocVisit' | 'addBrowserHistoryEntry' | 'clearBrowserHistory'
> {
return {
recordWorkspaceDocVisit: (docLocation, title, options) => {
const bump = options?.bump ?? true
set((s) => {
const now = Date.now()
const existing = s.workspaceDocHistory.find((entry) =>
browserPageDocLocationsEqual(entry.docLocation, docLocation)
)
if (!existing && !bump) {
// A title refresh for a document never visited records nothing.
return s
}
const normalizedTitle = normalizeWorkspaceDocHistoryTitle(
title ?? existing?.title,
docLocation
)
const next: WorkspaceDocHistoryEntry[] = existing
? s.workspaceDocHistory.map((entry) =>
entry === existing
? {
...entry,
title: normalizedTitle,
...(bump ? { lastVisitedAt: now, visitCount: entry.visitCount + 1 } : {})
}
: entry
)
: [
{ docLocation, title: normalizedTitle, lastVisitedAt: now, visitCount: 1 },
...s.workspaceDocHistory
]
return {
workspaceDocHistory:
next.length > MAX_WORKSPACE_DOC_HISTORY_ENTRIES
? normalizeWorkspaceDocHistoryEntries(next)
: next
}
})
},
addBrowserHistoryEntry: (url, title) => {
const safeUrl = redactKagiSessionToken(url)
if (safeUrl === ORCA_BROWSER_BLANK_URL || safeUrl === 'about:blank' || !safeUrl) {
return
}
const normalized = normalizeBrowserHistoryUrl(safeUrl)
set((s) => {
const existing = s.browserUrlHistory.find((entry) => entry.normalizedUrl === normalized)
let next: BrowserHistoryEntry[] = existing
? s.browserUrlHistory.map((entry) =>
entry === existing
? { ...entry, title, lastVisitedAt: Date.now(), visitCount: entry.visitCount + 1 }
: entry
)
: [
{
url: safeUrl,
normalizedUrl: normalized,
title,
lastVisitedAt: Date.now(),
visitCount: 1
},
...s.browserUrlHistory
]
if (next.length > MAX_BROWSER_HISTORY_ENTRIES) {
next = next
.sort((a, b) => b.lastVisitedAt - a.lastVisitedAt)
.slice(0, MAX_BROWSER_HISTORY_ENTRIES)
}
return { browserUrlHistory: next }
})
},
// One clear for both sources: the dropdown presents them as one history.
clearBrowserHistory: () => set({ browserUrlHistory: [], workspaceDocHistory: [] })
}
}
@@ -0,0 +1,70 @@
import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract'
import {
clearClientHostedBrowserCloseIntents,
recordClientHostedBrowserCloseIntents
} from '@/runtime/client-hosted-browser-close-intents'
import { getBrowserSettingsHostId } from './browser-host-state'
import { parseExecutionHostId } from '../../../../../shared/execution-host'
export function createBrowserHostActions(
set: BrowserSliceSet,
get: BrowserSliceGet
): Pick<
BrowserSlice,
| 'recordClientHostedBrowserCloseIntents'
| 'clearClientHostedBrowserCloseIntents'
| 'setBrowserSessionHostId'
| 'setDefaultBrowserSessionProfileId'
> {
return {
recordClientHostedBrowserCloseIntents: (closes) => {
set((s) => {
const next = recordClientHostedBrowserCloseIntents(
s.clientHostedBrowserCloseIntentsByEnvironment,
closes,
Date.now()
)
return next ? { clientHostedBrowserCloseIntentsByEnvironment: next } : {}
})
},
clearClientHostedBrowserCloseIntents: (environmentId, browserPageIds) => {
set((s) => {
const next = clearClientHostedBrowserCloseIntents(
s.clientHostedBrowserCloseIntentsByEnvironment,
{ environmentId, browserPageIds, now: Date.now() }
)
return next ? { clientHostedBrowserCloseIntentsByEnvironment: next } : {}
})
},
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,
detectedBrowsersHost: null
}))
await Promise.all([get().fetchBrowserSessionProfiles(), get().fetchDetectedBrowsers()])
},
setDefaultBrowserSessionProfileId: (profileId) => {
set((s) => ({
defaultBrowserSessionProfileId: profileId,
defaultBrowserSessionProfileIdByHostId: {
...s.defaultBrowserSessionProfileIdByHostId,
[getBrowserSettingsHostId(s)]: profileId
}
}))
}
}
}
@@ -0,0 +1,175 @@
import type { AppState } from '../../types'
import type {
BrowserCookieImportResult,
BrowserPage,
BrowserSessionProfile
} from '../../../../../shared/browser-workspace-types'
import {
getSettingsFocusedExecutionHostId,
LOCAL_EXECUTION_HOST_ID,
parseExecutionHostId,
toRuntimeExecutionHostId,
type ExecutionHostId
} from '../../../../../shared/execution-host'
import type {
BrowserSlice,
BrowserCookieImportExecutionResult,
RemoteBrowserPageHandle
} from './browser-slice-contract'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
/** Rebuild the remote page handles a restored session implies. No placement is seeded: the
* persisted generations belong to the host lease that died with the last run, and the first host
* snapshot is what supplies the live one.
*
* Client-hosted rows only. A server-hosted page lives on the runtime, which may have restarted or
* been redeployed while this desktop was closed; a seeded handle sends its pane down the adopt
* branch, and the browser_tab_not_found that comes back deletes the row. With no handle the pane
* creates a fresh page at the URL the row persisted, which is what the user left behind. */
export function buildRestoredRemoteBrowserPageHandles(
browserPagesByWorkspace: Record<string, BrowserPage[]>
): Record<string, RemoteBrowserPageHandle> {
const handles: Record<string, RemoteBrowserPageHandle> = {}
for (const pages of Object.values(browserPagesByWorkspace)) {
for (const page of pages) {
if (
!page.browserRuntimeEnvironmentId ||
!page.remoteBrowserPageId ||
!page.remoteBrowserPageClientHosted
) {
continue
}
handles[page.id] = {
environmentId: page.browserRuntimeEnvironmentId,
remotePageId: page.remoteBrowserPageId,
restoredFromSession: true,
restoredClientHosted: true
}
}
}
return handles
}
export function retainCookieImportExecutionHost(
result: BrowserCookieImportResult,
executionHostId: ExecutionHostId,
executionHostLabel: string,
executionMachine: 'client' | 'remote'
): BrowserCookieImportExecutionResult {
return {
...result,
executionHostId,
executionHostLabel,
executionMachine,
executionRemoteEnvironment: parseExecutionHostId(executionHostId)?.kind === 'runtime'
}
}
export function getBrowserSettingsHostId(
state: Pick<AppState, 'browserSessionHostIdOverride' | 'settings'>
): ExecutionHostId {
return state.browserSessionHostIdOverride ?? getSettingsFocusedExecutionHostId(state.settings)
}
export function getBrowserSettingsRuntimeEnvironmentId(
state: Pick<AppState, 'browserSessionHostIdOverride' | 'settings'>
): string | null {
const parsed = parseExecutionHostId(getBrowserSettingsHostId(state))
return parsed?.kind === 'runtime' ? parsed.environmentId : null
}
export function getBrowserWorktreeHostId(state: AppState, worktreeId: string): ExecutionHostId {
return getExecutionHostIdForWorktree(state, worktreeId)
}
export function getBrowserSessionProfileHostId(
state: AppState,
worktreeId: string,
browserRuntimeEnvironmentId: string | null | undefined
): ExecutionHostId {
if (browserRuntimeEnvironmentId === null) {
return LOCAL_EXECUTION_HOST_ID
}
if (browserRuntimeEnvironmentId !== undefined) {
const runtimeEnvironmentId = browserRuntimeEnvironmentId.trim()
return runtimeEnvironmentId
? toRuntimeExecutionHostId(runtimeEnvironmentId)
: LOCAL_EXECUTION_HOST_ID
}
return getBrowserWorktreeHostId(state, worktreeId)
}
export function isLocalBrowserPageOwner(
state: AppState,
worktreeId: string,
browserRuntimeEnvironmentId: string | null | undefined
): boolean {
return (
parseExecutionHostId(
getBrowserSessionProfileHostId(state, worktreeId, browserRuntimeEnvironmentId)
)?.kind !== 'runtime'
)
}
export function profileListByHostUpdate(
state: Pick<
AppState,
'browserSessionHostIdOverride' | 'browserSessionProfilesByHostId' | 'settings'
>,
profiles: BrowserSessionProfile[],
hostId: ExecutionHostId = getBrowserSettingsHostId(state)
): Partial<BrowserSlice> {
return {
...(getBrowserSettingsHostId(state) === hostId ? { browserSessionProfiles: profiles } : {}),
browserSessionProfilesByHostId: {
...state.browserSessionProfilesByHostId,
[hostId]: profiles
}
}
}
export function getBrowserProfilesForHost(
state: AppState,
hostId: ExecutionHostId
): BrowserSessionProfile[] {
return (
state.browserSessionProfilesByHostId[hostId] ??
(getBrowserSettingsHostId(state) === hostId ? state.browserSessionProfiles : [])
)
}
export function getDefaultBrowserProfileForHost(
state: AppState,
hostId: ExecutionHostId
): string | null {
return (
state.defaultBrowserSessionProfileIdByHostId[hostId] ??
(getBrowserSettingsHostId(state) === hostId ? state.defaultBrowserSessionProfileId : null)
)
}
export function browserImportStateForHostUpdate(
state: AppState,
hostId: ExecutionHostId,
browserSessionImportState: BrowserSlice['browserSessionImportState']
): Partial<BrowserSlice> {
return getBrowserSettingsHostId(state) === hostId ? { browserSessionImportState } : {}
}
export function getFallbackTabTypeForWorktree(
worktreeId: string,
openFiles: AppState['openFiles'],
terminalTabsByWorktree: AppState['tabsByWorktree'],
browserTabsByWorktree?: AppState['browserTabsByWorktree']
): AppState['activeTabType'] {
if (openFiles.some((file) => file.worktreeId === worktreeId)) {
return 'editor'
}
if ((browserTabsByWorktree?.[worktreeId] ?? []).length > 0) {
return 'browser'
}
if ((terminalTabsByWorktree[worktreeId] ?? []).length > 0) {
return 'terminal'
}
return 'terminal'
}
@@ -0,0 +1,264 @@
import type { BrowserPage, BrowserWorkspace } from '../../../../../shared/browser-workspace-types'
import { createBrowserUuid } from '@/lib/browser-uuid'
import {
FLOATING_TERMINAL_WORKTREE_ID,
ORCA_BROWSER_BLANK_URL
} from '../../../../../shared/constants'
import { folderWorkspaceKey } from '../../../../../shared/workspace-scope'
import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract'
import { addAdditionalValidWorkspaceKeys } from '@/lib/workspace-session-hydration-keys'
import { buildValidWorktreeIdsForSessionHydration } from '../degraded-repo-worktree-validity'
import { destroyWorkspaceWebviews } from '../browser-webview-cleanup'
import { mirrorWorkspaceFromActivePage, normalizeUrl } from '../browser-page-records'
import {
buildRestoredRemoteBrowserPageHandles,
getFallbackTabTypeForWorktree
} from './browser-host-state'
import { ensureBrowserClientHostsForRestoredPages } from '@/runtime/restored-client-hosted-browser-host-attach'
import { normalizeBrowserHistoryEntries } from '../../../../../shared/workspace-session-browser-history'
import { normalizeWorkspaceDocHistoryEntries } from '../../../../../shared/workspace-doc-history'
export function createBrowserHydrationActions(
set: BrowserSliceSet,
get: BrowserSliceGet
): Pick<BrowserSlice, 'hydrateBrowserSession' | 'switchBrowserTabProfile'> {
return {
hydrateBrowserSession: (session, options) => {
const persistedTabsByWorktree = session.browserTabsByWorktree ?? {}
const currentState = get()
const validWorktreeIdsForCleanup = buildValidWorktreeIdsForSessionHydration(
currentState,
Object.keys(persistedTabsByWorktree)
)
validWorktreeIdsForCleanup.add(FLOATING_TERMINAL_WORKTREE_ID)
for (const workspace of currentState.folderWorkspaces) {
validWorktreeIdsForCleanup.add(folderWorkspaceKey(workspace.id))
}
addAdditionalValidWorkspaceKeys(validWorktreeIdsForCleanup, options)
// Why: destroy dropped workspaces' webviews before the pure reducer; no-op today (boot registry empty), defends future re-hydration callers.
const droppedWorkspaceIds: string[] = []
for (const [worktreeId, tabs] of Object.entries(persistedTabsByWorktree)) {
if (!validWorktreeIdsForCleanup.has(worktreeId)) {
for (const tab of tabs) {
droppedWorkspaceIds.push(tab.id)
}
}
}
for (const workspaceId of droppedWorkspaceIds) {
destroyWorkspaceWebviews(currentState.browserPagesByWorkspace, workspaceId)
}
set((s) => {
const persistedPagesByWorkspace = session.browserPagesByWorkspace ?? {}
const persistedActiveBrowserTabIdByWorktree = session.activeBrowserTabIdByWorktree ?? {}
const persistedActiveTabTypeByWorktree = session.activeTabTypeByWorktree ?? {}
const validWorktreeIds = buildValidWorktreeIdsForSessionHydration(
s,
Object.keys(persistedTabsByWorktree)
)
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
for (const workspace of s.folderWorkspaces) {
validWorktreeIds.add(folderWorkspaceKey(workspace.id))
}
addAdditionalValidWorkspaceKeys(validWorktreeIds, options)
const browserTabsByWorktree: Record<string, BrowserWorkspace[]> = {}
const browserPagesByWorkspace: Record<string, BrowserPage[]> = {}
for (const [worktreeId, tabs] of Object.entries(persistedTabsByWorktree)) {
if (!validWorktreeIds.has(worktreeId)) {
continue
}
const hydratedTabs: BrowserWorkspace[] = []
for (const tab of tabs) {
// Salvage can leave an empty page array; hydrate it like a missing array.
const storedPages = persistedPagesByWorkspace[tab.id]
const persistedPages = storedPages?.length
? storedPages
: [
{
id: createBrowserUuid(),
workspaceId: tab.id,
worktreeId,
url: normalizeUrl(tab.url),
title: tab.title,
loading: false,
faviconUrl: tab.faviconUrl ?? null,
canGoBack: tab.canGoBack,
canGoForward: tab.canGoForward,
loadError: tab.loadError ?? null,
createdAt: tab.createdAt,
// Why the tab's copy is authority here: this branch runs when the page array was
// salvaged away, and without it a restored document page comes back as a blank
// New Tab under a strip entry still naming the document.
docLocation: tab.docLocation ?? null
} satisfies BrowserPage
]
const nextPages = persistedPages.map((page) => {
// Why: in-memory hydration callers can bypass the persistence schema's unknown-key stripping.
const { allowWindowClose: _legacyAllowWindowClose, ...persistedPage } =
page as typeof page & {
allowWindowClose?: boolean
}
return {
...persistedPage,
workspaceId: tab.id,
worktreeId,
// Why re-asserted on restore: the same invariant creation enforces. A session written
// by an older or hand-edited build could carry a grant URL here, and it would name a
// grant that died with the process that minted it.
url: page.docLocation ? ORCA_BROWSER_BLANK_URL : normalizeUrl(page.url),
loading: false,
loadError: page.loadError ?? null
}
})
browserPagesByWorkspace[tab.id] = nextPages
hydratedTabs.push(
mirrorWorkspaceFromActivePage(
{
...tab,
activePageId: nextPages.some((page) => page.id === tab.activePageId)
? (tab.activePageId ?? nextPages[0]?.id ?? null)
: (nextPages[0]?.id ?? null),
pageIds: nextPages.map((page) => page.id)
},
nextPages
)
)
}
if (hydratedTabs.length > 0) {
browserTabsByWorktree[worktreeId] = hydratedTabs
}
}
const validBrowserTabIds = new Set(
Object.values(browserTabsByWorktree)
.flat()
.map((tab) => tab.id)
)
const activeBrowserTabIdByWorktree: Record<string, string | null> = {}
for (const [worktreeId, tabs] of Object.entries(browserTabsByWorktree)) {
const persistedTabId = persistedActiveBrowserTabIdByWorktree[worktreeId]
activeBrowserTabIdByWorktree[worktreeId] =
persistedTabId && validBrowserTabIds.has(persistedTabId)
? persistedTabId
: (tabs[0]?.id ?? null)
}
const activeWorktreeId = s.activeWorktreeId
const activeBrowserTabId =
activeWorktreeId && activeBrowserTabIdByWorktree[activeWorktreeId]
? activeBrowserTabIdByWorktree[activeWorktreeId]
: null
const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree }
for (const worktreeId of validWorktreeIds) {
const hasBrowserTabs = (browserTabsByWorktree[worktreeId] ?? []).length > 0
if (
persistedActiveTabTypeByWorktree[worktreeId] === 'browser' &&
hasBrowserTabs &&
!nextActiveTabTypeByWorktree[worktreeId]
) {
nextActiveTabTypeByWorktree[worktreeId] = 'browser'
continue
}
if (nextActiveTabTypeByWorktree[worktreeId] === 'browser' && !hasBrowserTabs) {
nextActiveTabTypeByWorktree[worktreeId] = getFallbackTabTypeForWorktree(
worktreeId,
s.openFiles,
s.tabsByWorktree,
browserTabsByWorktree
)
}
}
const activeTabType = (() => {
if (!activeWorktreeId) {
return s.activeTabType
}
const restoredTabType = nextActiveTabTypeByWorktree[activeWorktreeId]
if (restoredTabType === 'browser' && activeBrowserTabId) {
return 'browser'
}
if (
restoredTabType === 'editor' &&
s.openFiles.some((file) => file.worktreeId === activeWorktreeId)
) {
return 'editor'
}
return getFallbackTabTypeForWorktree(
activeWorktreeId,
s.openFiles,
s.tabsByWorktree,
browserTabsByWorktree
)
})()
return {
browserTabsByWorktree,
browserPagesByWorkspace,
activeBrowserTabIdByWorktree,
activeBrowserTabId,
activeTabTypeByWorktree: nextActiveTabTypeByWorktree,
activeTabType,
remoteBrowserPageHandlesByPageId:
buildRestoredRemoteBrowserPageHandles(browserPagesByWorkspace),
browserCertificateFailuresByPageId: {},
browserAnnotationsByPageId: {},
browserUrlHistory: normalizeBrowserHistoryEntries(session.browserUrlHistory ?? []),
workspaceDocHistory: normalizeWorkspaceDocHistoryEntries(
session.workspaceDocHistory ?? []
),
// Why restored before the rows are: a close the host never heard must outlive the relaunch
// that also restores the row it closed, or the restore silently wins.
clientHostedBrowserCloseIntentsByEnvironment:
session.clientHostedBrowserCloseIntentsByEnvironment ?? {}
}
})
const state = get()
// Why here and not in the startup chain: the seeded handles are the only record that this
// desktop was hosting pages, and the runtime only hands them back once it sees an attach.
void ensureBrowserClientHostsForRestoredPages(state)
for (const [worktreeId, browserTabs] of Object.entries(state.browserTabsByWorktree)) {
for (const bt of browserTabs) {
const exists = (state.unifiedTabsByWorktree[worktreeId] ?? []).some(
(t) => t.contentType === 'browser' && t.entityId === bt.id
)
if (!exists) {
state.createUnifiedTab(worktreeId, 'browser', {
entityId: bt.id,
label: bt.title,
recordInteraction: false
})
}
}
}
},
switchBrowserTabProfile: (workspaceId, profileId, sessionPartition) => {
set((s) => {
for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) {
const tabIndex = tabs.findIndex((t) => t.id === workspaceId)
if (tabIndex !== -1) {
const updatedTabs = [...tabs]
updatedTabs[tabIndex] = {
...updatedTabs[tabIndex],
sessionProfileId: profileId,
sessionPartition: sessionPartition ?? null
}
return {
browserTabsByWorktree: {
...s.browserTabsByWorktree,
[worktreeId]: updatedTabs
}
}
}
}
return {}
})
}
}
}
@@ -0,0 +1,18 @@
import { GRAB_BUDGET, type BrowserPageAnnotation } from '../../../../../shared/browser-grab-types'
export function sanitizeBrowserPageAnnotation(
annotation: BrowserPageAnnotation
): BrowserPageAnnotation {
return {
...annotation,
comment:
annotation.comment.length > GRAB_BUDGET.annotationCommentMaxLength
? annotation.comment.slice(0, GRAB_BUDGET.annotationCommentMaxLength)
: annotation.comment,
payload: {
...annotation.payload,
// Why: annotations persist to disk; null the transient screenshot to avoid retaining megabytes per note.
screenshot: null
}
}
}
@@ -0,0 +1,212 @@
import type {
BrowserSlice,
BrowserSliceGet,
BrowserSliceSet,
RemoteBrowserPageHandle
} from './browser-slice-contract'
import type { BrowserPage } from '../../../../../shared/browser-workspace-types'
import { planBrowserPageConversion } from '../browser-page-conversion'
import { findPage, findWorkspace, mirrorWorkspaceFromActivePage } from '../browser-page-records'
import { assertManagedBrowserMaterializationAllowed } from '@/lib/client-creation-action-policy'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { closeRemoteBrowserPageInOwningEnvironment } from './browser-remote-close'
import { releaseDocPreviewGrant } from '@/lib/doc-preview-grants'
import { isLocalBrowserPageOwner } from './browser-host-state'
export function createBrowserPageConversionActions(
set: BrowserSliceSet,
get: BrowserSliceGet
): Pick<BrowserSlice, 'convertBrowserPage' | 'reopenClosedBrowserPage' | 'setActiveBrowserPage'> {
return {
convertBrowserPage: (pageId, target, options) => {
// Why the same assert as createBrowserTab: a conversion materializes a browser surface the
// same way a creation does, and a paired web client that cannot host one must refuse here too.
// Ownership is resolved ONCE, on the plan's own terms — property-present-undefined means
// worktree-inferred — so the assert and the plan cannot disagree about what is being built.
const oldPageForOwnership = findPage(get().browserPagesByWorkspace, pageId)
const declaredOwnership =
target.kind === 'workspace-doc'
? null
: 'browserRuntimeEnvironmentId' in target
? target.browserRuntimeEnvironmentId
: (oldPageForOwnership?.browserRuntimeEnvironmentId ?? null)
assertManagedBrowserMaterializationAllowed(
get(),
declaredOwnership !== undefined
? declaredOwnership
: oldPageForOwnership
? (getRuntimeEnvironmentIdForWorktree(get(), oldPageForOwnership.worktreeId) ?? null)
: null
)
let converted: BrowserPage | null = null
let docPageIdToRelease: string | null = null
let remotePageToClose: { worktreeId: string; handle: RemoteBrowserPageHandle } | null = null
set((s) => {
const plan = planBrowserPageConversion(s, pageId, target, options)
if (!plan) {
return s
}
converted = plan.newPage
// Why collected rather than released inside the reducer: revoking is main-process work, and
// it must happen exactly once even if a later set() retries the reducer.
if (plan.oldPage.docLocation) {
docPageIdToRelease = plan.oldPage.id
}
const remoteHandle = s.remoteBrowserPageHandlesByPageId[plan.oldPage.id]
if (remoteHandle) {
remotePageToClose = { worktreeId: plan.oldPage.worktreeId, handle: remoteHandle }
}
const nextRemoteBrowserPageHandlesByPageId = { ...s.remoteBrowserPageHandlesByPageId }
delete nextRemoteBrowserPageHandlesByPageId[plan.oldPage.id]
const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId }
delete nextBrowserAnnotationsByPageId[plan.oldPage.id]
const nextBrowserCertificateFailuresByPageId = { ...s.browserCertificateFailuresByPageId }
delete nextBrowserCertificateFailuresByPageId[plan.oldPage.id]
return {
browserPagesByWorkspace: {
...s.browserPagesByWorkspace,
[plan.workspace.id]: plan.nextPages
},
browserTabsByWorktree: {
...s.browserTabsByWorktree,
[plan.workspace.worktreeId]: (
s.browserTabsByWorktree[plan.workspace.worktreeId] ?? []
).map((tab) => (tab.id === plan.workspace.id ? plan.nextWorkspace : tab))
},
pendingAddressBarFocusByPageId: Object.fromEntries(
Object.entries(s.pendingAddressBarFocusByPageId).filter(
([pendingPageId]) => pendingPageId !== plan.oldPage.id
)
),
pendingAddressBarFocusByTabId: Object.fromEntries(
Object.entries(s.pendingAddressBarFocusByTabId).filter(
([pendingPageId]) => pendingPageId !== plan.oldPage.id
)
),
remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId,
browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId,
browserAnnotationsByPageId: nextBrowserAnnotationsByPageId
}
})
// Why the casts: the assignments happen inside set()'s callback, which TS's flow analysis does
// not track, so the initializers' null narrowing would otherwise read these as never.
const newPage = converted as BrowserPage | null
const remoteClose = remotePageToClose as {
worktreeId: string
handle: RemoteBrowserPageHandle
} | null
if (!newPage) {
return null
}
if (remoteClose) {
closeRemoteBrowserPageInOwningEnvironment(
remoteClose.worktreeId,
remoteClose.handle,
get().recordClientHostedBrowserCloseIntents
)
}
// Why after the reducer: a closed document must stop being readable, and the grant is the only
// authority the preview scheme honors — but the store row has to stop naming it first.
if (docPageIdToRelease) {
releaseDocPreviewGrant(docPageIdToRelease)
}
const workspaceAfter = findWorkspace(get().browserTabsByWorktree, newPage.workspaceId)
if (workspaceAfter?.activePageId === newPage.id) {
const item = Object.values(get().unifiedTabsByWorktree)
.flat()
.find(
(entry) => entry.contentType === 'browser' && entry.entityId === newPage.workspaceId
)
if (item) {
get().setTabLabel(item.id, newPage.title)
}
}
return newPage
},
reopenClosedBrowserPage: (workspaceId) => {
// Why: read and pop atomically inside set() so two rapid Cmd+Shift+T presses can't both restore the same page (TOCTOU).
let pageToRestore: BrowserPage | undefined
set((s) => {
const recentlyClosed = s.recentlyClosedBrowserPagesByWorkspace[workspaceId] ?? []
pageToRestore = recentlyClosed[0]
if (!pageToRestore) {
return s
}
return {
recentlyClosedBrowserPagesByWorkspace: {
...s.recentlyClosedBrowserPagesByWorkspace,
[workspaceId]: recentlyClosed.slice(1)
}
}
})
if (!pageToRestore) {
return null
}
return get().createBrowserPage(workspaceId, pageToRestore.url, {
title: pageToRestore.title,
activate: true,
browserRuntimeEnvironmentId: pageToRestore.browserRuntimeEnvironmentId,
...(pageToRestore.docLocation ? { docLocation: pageToRestore.docLocation } : {})
})
},
setActiveBrowserPage: (workspaceId, pageId) => {
set((s) => {
const workspace = findWorkspace(s.browserTabsByWorktree, workspaceId)
if (!workspace) {
return s
}
const pages = s.browserPagesByWorkspace[workspaceId] ?? []
if (!pages.some((page) => page.id === pageId)) {
return s
}
const nextWorkspace = mirrorWorkspaceFromActivePage(
{
...workspace,
activePageId: pageId
},
pages
)
return {
browserTabsByWorktree: {
...s.browserTabsByWorktree,
[workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map(
(tab) => (tab.id === workspaceId ? nextWorkspace : tab)
)
}
}
})
// 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 (
workspace &&
isLocalBrowserPageOwner(
get(),
workspace.worktreeId,
activePage?.browserRuntimeEnvironmentId
) &&
typeof window !== 'undefined' &&
window.api?.browser
) {
window.api.browser.notifyActiveTabChanged({ browserPageId: pageId }).catch(() => {})
}
if (!workspace) {
return
}
const item = Object.values(get().unifiedTabsByWorktree)
.flat()
.find((entry) => entry.contentType === 'browser' && entry.entityId === workspaceId)
if (item) {
get().setTabLabel(item.id, workspace.title)
}
}
}
}
@@ -0,0 +1,202 @@
import type {
BrowserSlice,
BrowserSliceGet,
BrowserSliceSet,
RemoteBrowserPageHandle
} from './browser-slice-contract'
import {
buildBrowserPage,
findPage,
findWorkspace,
mirrorWorkspaceFromActivePage
} from '../browser-page-records'
import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/constants'
import { closeRemoteBrowserPageInOwningEnvironment } from './browser-remote-close'
import { releaseDocPreviewGrant } from '@/lib/doc-preview-grants'
export function createBrowserPageCreateActions(
set: BrowserSliceSet,
get: BrowserSliceGet
): Pick<BrowserSlice, 'createBrowserPage' | 'closeBrowserPage'> {
return {
createBrowserPage: (workspaceId, url, options) => {
const workspace = findWorkspace(get().browserTabsByWorktree, workspaceId)
if (!workspace) {
return null
}
const page = buildBrowserPage(
workspaceId,
workspace.worktreeId,
url,
options?.title,
options?.browserRuntimeEnvironmentId,
undefined,
options?.docLocation
)
set((s) => {
const pages = s.browserPagesByWorkspace[workspaceId] ?? []
const shouldActivate = options?.activate ?? true
const nextPages = [...pages, page]
const nextWorkspace = mirrorWorkspaceFromActivePage(
{
...workspace,
activePageId: shouldActivate ? page.id : (workspace.activePageId ?? page.id),
pageIds: nextPages.map((entry) => entry.id)
},
nextPages
)
const shouldUpdateGlobalActiveSurface =
shouldActivate &&
s.activeWorktreeId === workspace.worktreeId &&
s.activeBrowserTabIdByWorktree[workspace.worktreeId] === workspaceId
const shouldFocusAddressBar =
shouldUpdateGlobalActiveSurface &&
!page.docLocation &&
(page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL)
return {
browserPagesByWorkspace: {
...s.browserPagesByWorkspace,
[workspaceId]: nextPages
},
browserTabsByWorktree: {
...s.browserTabsByWorktree,
[workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map(
(tab) => (tab.id === workspaceId ? nextWorkspace : tab)
)
},
pendingAddressBarFocusByPageId: shouldFocusAddressBar
? {
...s.pendingAddressBarFocusByPageId,
[page.id]: true
}
: s.pendingAddressBarFocusByPageId,
pendingAddressBarFocusByTabId: shouldFocusAddressBar
? {
...s.pendingAddressBarFocusByTabId,
[page.id]: true
}
: s.pendingAddressBarFocusByTabId
}
})
const nextWorkspace = findWorkspace(get().browserTabsByWorktree, workspaceId)
if (nextWorkspace?.activePageId === page.id) {
const item = Object.values(get().unifiedTabsByWorktree)
.flat()
.find((entry) => entry.contentType === 'browser' && entry.entityId === workspaceId)
if (item) {
get().setTabLabel(item.id, page.title)
}
}
return page
},
closeBrowserPage: (pageId) => {
let closedWorkspaceIdForLabel: string | null = null
let docPageIdToRelease: string | null = null
const remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = []
set((s) => {
const page = findPage(s.browserPagesByWorkspace, pageId)
if (!page) {
return s
}
const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId)
if (!workspace) {
return s
}
closedWorkspaceIdForLabel = page.workspaceId
docPageIdToRelease = page.docLocation ? page.id : null
const currentPages = s.browserPagesByWorkspace[workspace.id] ?? []
const nextPages = currentPages.filter((entry) => entry.id !== pageId)
const closedIdx = currentPages.findIndex((entry) => entry.id === pageId)
const nextActivePageId =
workspace.activePageId === pageId
? ((nextPages[closedIdx] ?? nextPages[closedIdx - 1] ?? null)?.id ?? null)
: workspace.activePageId
const nextWorkspace = mirrorWorkspaceFromActivePage(
{
...workspace,
activePageId: nextActivePageId,
pageIds: nextPages.map((entry) => entry.id)
},
nextPages
)
const remoteHandle = s.remoteBrowserPageHandlesByPageId[pageId]
if (remoteHandle) {
remotePagesToClose.push({ worktreeId: page.worktreeId, handle: remoteHandle })
}
const nextRemoteBrowserPageHandlesByPageId = {
...s.remoteBrowserPageHandlesByPageId
}
delete nextRemoteBrowserPageHandlesByPageId[pageId]
const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId }
delete nextBrowserAnnotationsByPageId[pageId]
const nextBrowserCertificateFailuresByPageId = {
...s.browserCertificateFailuresByPageId
}
delete nextBrowserCertificateFailuresByPageId[pageId]
return {
browserPagesByWorkspace: {
...s.browserPagesByWorkspace,
[workspace.id]: nextPages
},
browserTabsByWorktree: {
...s.browserTabsByWorktree,
[workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map(
(tab) => (tab.id === workspace.id ? nextWorkspace : tab)
)
},
recentlyClosedBrowserPagesByWorkspace: {
...s.recentlyClosedBrowserPagesByWorkspace,
[workspace.id]: [
page,
...(s.recentlyClosedBrowserPagesByWorkspace[workspace.id] ?? []).filter(
(entry) => entry.id !== page.id
)
].slice(0, 10)
},
pendingAddressBarFocusByPageId: Object.fromEntries(
Object.entries(s.pendingAddressBarFocusByPageId).filter(
([pendingPageId]) => pendingPageId !== pageId
)
),
pendingAddressBarFocusByTabId: Object.fromEntries(
Object.entries(s.pendingAddressBarFocusByTabId).filter(
([pendingPageId]) => pendingPageId !== pageId
)
),
remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId,
browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId,
browserAnnotationsByPageId: nextBrowserAnnotationsByPageId
}
})
for (const remotePage of remotePagesToClose) {
closeRemoteBrowserPageInOwningEnvironment(
remotePage.worktreeId,
remotePage.handle,
get().recordClientHostedBrowserCloseIntents
)
}
if (docPageIdToRelease) {
releaseDocPreviewGrant(docPageIdToRelease)
}
const closedWorkspaceId = closedWorkspaceIdForLabel
if (!closedWorkspaceId) {
return
}
const workspace = findWorkspace(get().browserTabsByWorktree, closedWorkspaceId)
const item = Object.values(get().unifiedTabsByWorktree)
.flat()
.find((entry) => entry.contentType === 'browser' && entry.entityId === closedWorkspaceId)
if (item && workspace) {
get().setTabLabel(item.id, workspace.title)
}
}
}
}
@@ -0,0 +1,94 @@
import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract'
import { mirrorWorkspaceFromActivePage } from '../browser-page-records'
import { isLocalBrowserPageOwner } from './browser-host-state'
export function createBrowserPageFocusActions(
set: BrowserSliceSet,
get: BrowserSliceGet
): Pick<BrowserSlice, 'focusBrowserTabInWorktree' | 'consumeAddressBarFocusRequest'> {
return {
focusBrowserTabInWorktree: (worktreeId, browserPageId, options) => {
// Why: bridge targets a browserPageId but tabs activate a workspace; find the owning workspace (they differ for multi-page tabs).
const tabsForWorktree = get().browserTabsByWorktree[worktreeId] ?? []
const workspace = tabsForWorktree.find((tab) => (tab.pageIds ?? []).includes(browserPageId))
if (!workspace) {
// Best-effort: worktree state may not be hydrated yet, or the page closed between bridge switch and this IPC arriving.
return
}
// Default true: the only caller (tab switch --focus) wants the pane surfaced; false is an opt-out for pre-staging callers.
const surfacePane = options?.surfacePane ?? true
const pages = get().browserPagesByWorkspace[workspace.id] ?? []
const nextWorkspace = mirrorWorkspaceFromActivePage(
{ ...workspace, activePageId: browserPageId },
pages
)
// TODO: duplicates setActiveBrowserTab/Page; can't reuse (they touch globals unconditionally). Extract a per-worktree-only helper.
set((s) => {
const isActiveWorktree = s.activeWorktreeId === worktreeId
// Per-worktree slots: always update — safe pre-staging, only visible when user navigates here.
const nextTabsByWorktree = {
...s.browserTabsByWorktree,
[worktreeId]: tabsForWorktree.map((tab) =>
tab.id === workspace.id ? nextWorkspace : tab
)
}
const nextActiveTabIdByWorktree = {
...s.activeBrowserTabIdByWorktree,
[worktreeId]: workspace.id
}
const nextActiveTabTypeByWorktree = surfacePane
? { ...s.activeTabTypeByWorktree, [worktreeId]: 'browser' as const }
: s.activeTabTypeByWorktree
// Globals: mutate only when the targeted worktree is active — keeps cross-worktree --focus silent.
return {
browserTabsByWorktree: nextTabsByWorktree,
activeBrowserTabIdByWorktree: nextActiveTabIdByWorktree,
activeTabTypeByWorktree: nextActiveTabTypeByWorktree,
activeBrowserTabId: isActiveWorktree ? workspace.id : s.activeBrowserTabId,
activeTabType: isActiveWorktree && surfacePane ? 'browser' : s.activeTabType
}
})
// 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 (
isLocalBrowserPageOwner(get(), worktreeId, focusedPage?.browserRuntimeEnvironmentId) &&
typeof window !== 'undefined' &&
window.api?.browser
) {
window.api.browser.notifyActiveTabChanged({ browserPageId }).catch(() => {})
}
// Why: sync the unified-tab strip's active entry; activateTab only mutates per-worktree slices, so it's cross-worktree-safe.
const item = (get().unifiedTabsByWorktree[worktreeId] ?? []).find(
(entry) => entry.contentType === 'browser' && entry.entityId === workspace.id
)
if (item) {
get().activateTab(item.id)
}
},
consumeAddressBarFocusRequest: (pageId) => {
const state = get()
if (
!state.pendingAddressBarFocusByPageId[pageId] &&
!state.pendingAddressBarFocusByTabId[pageId]
) {
return false
}
set((s) => {
const nextByPageId = { ...s.pendingAddressBarFocusByPageId }
delete nextByPageId[pageId]
const nextByTabId = { ...s.pendingAddressBarFocusByTabId }
delete nextByTabId[pageId]
return {
pendingAddressBarFocusByPageId: nextByPageId,
pendingAddressBarFocusByTabId: nextByTabId
}
})
return true
}
}
}
@@ -0,0 +1,131 @@
import { GRAB_BUDGET } from '../../../../../shared/browser-grab-types'
import type {
BrowserSlice,
BrowserSliceGet,
BrowserSliceSet,
RemoteBrowserPageHandle
} from './browser-slice-contract'
import { findPage, findWorkspace } from '../browser-page-records'
import { sanitizeBrowserPageAnnotation } from './browser-page-annotation'
export function createBrowserPageMetadataActions(
set: BrowserSliceSet,
_get: BrowserSliceGet
): Pick<
BrowserSlice,
| 'setRemoteBrowserPageHandle'
| 'removeRemoteBrowserPageHandle'
| 'setBrowserPageViewportPreset'
| 'addBrowserPageAnnotation'
| 'updateBrowserPageAnnotation'
| 'deleteBrowserPageAnnotation'
| 'clearBrowserPageAnnotations'
> {
return {
setRemoteBrowserPageHandle: (pageId, handle) => {
set((s) => ({
remoteBrowserPageHandlesByPageId: {
...s.remoteBrowserPageHandlesByPageId,
[pageId]: handle
}
}))
},
removeRemoteBrowserPageHandle: (pageId, remotePageId) => {
let removedHandle: RemoteBrowserPageHandle | null = null
set((s) => {
const current = s.remoteBrowserPageHandlesByPageId[pageId]
if (!current || (remotePageId && current.remotePageId !== remotePageId)) {
return s
}
removedHandle = current
const nextRemoteBrowserPageHandlesByPageId = {
...s.remoteBrowserPageHandlesByPageId
}
delete nextRemoteBrowserPageHandlesByPageId[pageId]
return { remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId }
})
return removedHandle
},
// viewportPresetId is intentionally page-local (no workspace-layer UI consumer); do NOT add mirrorWorkspaceFromActivePage here.
setBrowserPageViewportPreset: (pageId, viewportPresetId) =>
set((s) => {
const page = findPage(s.browserPagesByWorkspace, pageId)
if (!page) {
return s
}
const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId)
if (!workspace) {
return s
}
const nextPages = (s.browserPagesByWorkspace[workspace.id] ?? []).map((entry) =>
entry.id === pageId ? { ...entry, viewportPresetId } : entry
)
return {
browserPagesByWorkspace: {
...s.browserPagesByWorkspace,
[workspace.id]: nextPages
}
}
}),
addBrowserPageAnnotation: (annotation) =>
set((s) => {
const existing = s.browserAnnotationsByPageId[annotation.browserPageId] ?? []
const next = [...existing, sanitizeBrowserPageAnnotation(annotation)].slice(
-GRAB_BUDGET.annotationsMaxPerPage
)
return {
browserAnnotationsByPageId: {
...s.browserAnnotationsByPageId,
[annotation.browserPageId]: next
}
}
}),
updateBrowserPageAnnotation: (pageId, annotationId, patch) =>
set((s) => {
const existing = s.browserAnnotationsByPageId[pageId] ?? []
const target = existing.find((annotation) => annotation.id === annotationId)
if (!target) {
return s
}
const updated = sanitizeBrowserPageAnnotation({ ...target, ...patch })
return {
browserAnnotationsByPageId: {
...s.browserAnnotationsByPageId,
[pageId]: existing.map((annotation) =>
annotation.id === annotationId ? updated : annotation
)
}
}
}),
deleteBrowserPageAnnotation: (pageId, annotationId) =>
set((s) => {
const existing = s.browserAnnotationsByPageId[pageId] ?? []
const next = existing.filter((annotation) => annotation.id !== annotationId)
if (next.length === existing.length) {
return s
}
const nextByPageId = { ...s.browserAnnotationsByPageId }
if (next.length > 0) {
nextByPageId[pageId] = next
} else {
delete nextByPageId[pageId]
}
return { browserAnnotationsByPageId: nextByPageId }
}),
clearBrowserPageAnnotations: (pageId) =>
set((s) => {
if (!s.browserAnnotationsByPageId[pageId]?.length) {
return s
}
const nextByPageId = { ...s.browserAnnotationsByPageId }
delete nextByPageId[pageId]
return { browserAnnotationsByPageId: nextByPageId }
})
}
}
@@ -0,0 +1,219 @@
import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract'
import type { AppState } from '../../types'
import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/constants'
import {
browserWorkspaceMirrorFieldsEqual,
findPage,
findWorkspace,
mirrorWorkspaceFromActivePage,
normalizeBrowserTitle,
normalizeUrl
} from '../browser-page-records'
export function createBrowserPageStateActions(
set: BrowserSliceSet,
get: BrowserSliceGet
): Pick<
BrowserSlice,
| 'updateBrowserTabPageState'
| 'updateBrowserPageState'
| 'setBrowserPageCertificateFailure'
| 'setBrowserTabUrl'
| 'setBrowserPageUrl'
> {
return {
updateBrowserTabPageState: (pageId, updates) => get().updateBrowserPageState(pageId, updates),
updateBrowserPageState: (pageId, updates) => {
set((s) => {
const page = findPage(s.browserPagesByWorkspace, pageId)
if (!page) {
return s
}
const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId)
if (!workspace) {
return s
}
const nextPage = {
...page,
title:
updates.title === undefined
? page.title
: normalizeBrowserTitle(updates.title, page.url, page.docLocation),
loading: updates.loading ?? page.loading,
faviconUrl: updates.faviconUrl === undefined ? page.faviconUrl : updates.faviconUrl,
canGoBack: updates.canGoBack ?? page.canGoBack,
canGoForward: updates.canGoForward ?? page.canGoForward,
loadError: updates.loadError === undefined ? page.loadError : updates.loadError
}
const unifiedTabs = s.unifiedTabsByWorktree[workspace.worktreeId] ?? []
const unifiedIndex =
workspace.activePageId === pageId && updates.title !== undefined
? unifiedTabs.findIndex(
(entry) => entry.contentType === 'browser' && entry.entityId === workspace.id
)
: -1
const unifiedLabelNeedsRepair =
unifiedIndex !== -1 && unifiedTabs[unifiedIndex]?.label !== nextPage.title
const pageStateUnchanged =
nextPage.title === page.title &&
nextPage.loading === page.loading &&
nextPage.faviconUrl === page.faviconUrl &&
nextPage.canGoBack === page.canGoBack &&
nextPage.canGoForward === page.canGoForward &&
nextPage.loadError === page.loadError
const currentPages = s.browserPagesByWorkspace[workspace.id] ?? []
const mirroredWorkspace = pageStateUnchanged
? mirrorWorkspaceFromActivePage(workspace, currentPages)
: null
const workspaceNeedsRepair =
mirroredWorkspace !== null &&
!browserWorkspaceMirrorFieldsEqual(workspace, mirroredWorkspace)
if (pageStateUnchanged && !unifiedLabelNeedsRepair && !workspaceNeedsRepair) {
return s
}
if (pageStateUnchanged) {
const nextState: Partial<AppState> = {}
if (workspaceNeedsRepair && mirroredWorkspace) {
nextState.browserTabsByWorktree = {
...s.browserTabsByWorktree,
[workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map(
(tab) => (tab.id === workspace.id ? mirroredWorkspace : tab)
)
}
}
if (unifiedLabelNeedsRepair) {
nextState.unifiedTabsByWorktree = {
...s.unifiedTabsByWorktree,
[workspace.worktreeId]: unifiedTabs.map((entry, index) =>
index === unifiedIndex ? { ...entry, label: nextPage.title } : entry
)
}
}
return nextState
}
const nextPages = currentPages.map((entry) => (entry.id === pageId ? nextPage : entry))
const nextWorkspace = mirrorWorkspaceFromActivePage(workspace, nextPages)
const nextState: Partial<AppState> = {
browserPagesByWorkspace: {
...s.browserPagesByWorkspace,
[workspace.id]: nextPages
}
}
if (!browserWorkspaceMirrorFieldsEqual(workspace, nextWorkspace)) {
nextState.browserTabsByWorktree = {
...s.browserTabsByWorktree,
[workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map(
(tab) => (tab.id === workspace.id ? nextWorkspace : tab)
)
}
}
if (
workspace.activePageId === pageId &&
updates.title !== undefined &&
unifiedIndex !== -1
) {
if (unifiedLabelNeedsRepair || unifiedTabs[unifiedIndex]?.label !== nextWorkspace.title) {
nextState.unifiedTabsByWorktree = {
...s.unifiedTabsByWorktree,
[workspace.worktreeId]: unifiedTabs.map((entry, index) =>
index === unifiedIndex ? { ...entry, label: nextWorkspace.title } : entry
)
}
}
}
return nextState
})
if (updates.loadError === null) {
get().setBrowserPageCertificateFailure(pageId, null)
}
},
setBrowserPageCertificateFailure: (pageId, failure) => {
set((s) => {
const current = s.browserCertificateFailuresByPageId[pageId]
if (failure === null) {
if (!current) {
return s
}
const nextFailures = { ...s.browserCertificateFailuresByPageId }
delete nextFailures[pageId]
return { browserCertificateFailuresByPageId: nextFailures }
}
if (!findPage(s.browserPagesByWorkspace, pageId) || current === failure) {
return s
}
return {
browserCertificateFailuresByPageId: {
...s.browserCertificateFailuresByPageId,
[pageId]: failure
}
}
})
},
setBrowserTabUrl: (pageId, url) => get().setBrowserPageUrl(pageId, url),
setBrowserPageUrl: (pageId, url, options) => {
const nextUrl = normalizeUrl(url)
if (nextUrl !== 'about:blank' && nextUrl !== ORCA_BROWSER_BLANK_URL) {
const currentPage = findPage(get().browserPagesByWorkspace, pageId)
if (currentPage) {
get().recordFeatureInteraction?.('browser')
}
}
set((s) => {
const page = findPage(s.browserPagesByWorkspace, pageId)
if (!page) {
return s
}
const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId)
if (!workspace) {
return s
}
// Why a document page keeps its blank url here too: this is the third door onto a page's url,
// and a document's url is blank by construction. A grant committed here would reach
// persistence, the publish boundary and the address bar, exactly as at the other two doors.
const nextPageUrl = page.docLocation ? ORCA_BROWSER_BLANK_URL : nextUrl
// Why: annotations point at DOM coords of the loaded document; a real URL change invalidates those markers.
const shouldClearAnnotations = normalizeUrl(page.url) !== nextPageUrl
const nextPages = (s.browserPagesByWorkspace[workspace.id] ?? []).map((entry) =>
entry.id === pageId
? {
...entry,
url: nextPageUrl,
title: normalizeBrowserTitle(entry.title, nextPageUrl, entry.docLocation),
// Why not simply true: a document page's guest is inert, so there is nothing to wait
// for and the loading affordance would never clear.
loading: !entry.docLocation,
loadError: options?.preserveLoadError ? entry.loadError : null
}
: entry
)
const nextWorkspace = mirrorWorkspaceFromActivePage(workspace, nextPages)
const nextBrowserAnnotationsByPageId = shouldClearAnnotations
? { ...s.browserAnnotationsByPageId }
: s.browserAnnotationsByPageId
if (shouldClearAnnotations) {
delete nextBrowserAnnotationsByPageId[pageId]
}
return {
browserPagesByWorkspace: {
...s.browserPagesByWorkspace,
[workspace.id]: nextPages
},
browserTabsByWorktree: {
...s.browserTabsByWorktree,
[workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map(
(tab) => (tab.id === workspace.id ? nextWorkspace : tab)
)
},
...(shouldClearAnnotations
? { browserAnnotationsByPageId: nextBrowserAnnotationsByPageId }
: {})
}
})
get().setBrowserPageCertificateFailure(pageId, null)
}
}
}
@@ -0,0 +1,172 @@
import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract'
import type { BrowserCookieImportResult } from '../../../../../shared/browser-workspace-types'
import type { BrowserDetectProfilesResult } from '../../../../../shared/runtime-types'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { translate } from '@/i18n/i18n'
import { selectExecutionHostDisplayLabel } from '@/lib/execution-host-display-label'
import {
getBrowserSettingsHostId,
getBrowserSettingsRuntimeEnvironmentId,
browserImportStateForHostUpdate,
retainCookieImportExecutionHost
} from './browser-host-state'
export function createBrowserProfileImportActions(
set: BrowserSliceSet,
get: BrowserSliceGet
): Pick<
BrowserSlice,
'importCookiesToProfile' | 'clearBrowserSessionImportState' | 'fetchDetectedBrowsers'
> {
return {
importCookiesToProfile: async (profileId) => {
const initialState = get()
const hostId = getBrowserSettingsHostId(initialState)
const executionHostLabel = selectExecutionHostDisplayLabel(initialState, hostId)
if (getBrowserSettingsRuntimeEnvironmentId(initialState)) {
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 retainCookieImportExecutionHost(
{ ok: false as const, reason },
hostId,
executionHostLabel,
'client'
)
}
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((state) =>
browserImportStateForHostUpdate(state, hostId, {
profileId,
status: 'success',
summary: result.summary,
error: null
})
)
if (getBrowserSettingsHostId(get()) === hostId) {
await get()
.fetchBrowserSessionProfiles()
.catch(() => {})
}
} else {
set((state) =>
browserImportStateForHostUpdate(state, hostId, {
profileId,
status: result.reason === 'canceled' ? 'idle' : 'error',
summary: null,
error: result.reason === 'canceled' ? null : result.reason
})
)
}
return retainCookieImportExecutionHost(result, hostId, executionHostLabel, 'client')
} catch (err) {
const reason = String((err as Error)?.message ?? err)
set((state) =>
browserImportStateForHostUpdate(state, hostId, {
profileId,
status: 'error',
summary: null,
error: reason
})
)
return retainCookieImportExecutionHost(
{ ok: false as const, reason },
hostId,
executionHostLabel,
'client'
)
}
},
clearBrowserSessionImportState: () => {
set({ browserSessionImportState: null })
},
fetchDetectedBrowsers: async () => {
const hostId = getBrowserSettingsHostId(get())
const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get())
if (runtimeEnvironmentId) {
const hostLabel = selectExecutionHostDisplayLabel(get(), hostId)
try {
// Why: the import runs on whichever machine hosts the pages, so the picker must offer that
// machine's browsers -- client-hosted means this desktop, not the (usually headless) remote.
const clientHostBrowsers = await window.api.browser.sessionDetectBrowsersForClientHost({
environmentId: runtimeEnvironmentId
})
const browsers =
clientHostBrowsers ??
(
await callRuntimeRpc<BrowserDetectProfilesResult>(
{ kind: 'environment', environmentId: runtimeEnvironmentId },
'browser.profileDetectBrowsers',
undefined,
{ timeoutMs: 15_000 }
)
).browsers
// Why: retain which machine answered so import menus can say where imports read and store.
const detectedBrowsersHost = {
machine: clientHostBrowsers ? ('client' as const) : ('remote' as const),
hostLabel
}
set((s) =>
getBrowserSettingsHostId(s) === hostId
? { detectedBrowsers: browsers, detectedBrowsersLoaded: true, detectedBrowsersHost }
: {}
)
} catch {
set((s) =>
getBrowserSettingsHostId(s) === hostId
? { detectedBrowsers: [], detectedBrowsersLoaded: true, detectedBrowsersHost: null }
: {}
)
}
return
}
if (get().detectedBrowsersLoaded) {
return
}
try {
const browsers = (await window.api.browser.sessionDetectBrowsers()) as {
family: string
label: string
profiles: { name: string; directory: string }[]
selectedProfile: string
}[]
set((s) =>
getBrowserSettingsHostId(s) === hostId
? {
detectedBrowsers: browsers,
detectedBrowsersLoaded: true,
detectedBrowsersHost: null
}
: {}
)
} catch {
/* best-effort — empty list is acceptable fallback */
set((s) => (getBrowserSettingsHostId(s) === hostId ? { detectedBrowsersLoaded: true } : {}))
}
}
}
}
@@ -0,0 +1,172 @@
import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract'
import type { BrowserSessionProfile } from '../../../../../shared/browser-workspace-types'
import type {
BrowserProfileCreateResult,
BrowserProfileDeleteResult,
BrowserProfileListResult
} from '../../../../../shared/runtime-types'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import {
getBrowserProfilesForHost,
getBrowserSettingsHostId,
getBrowserSettingsRuntimeEnvironmentId,
profileListByHostUpdate,
getDefaultBrowserProfileForHost
} from './browser-host-state'
export function createBrowserProfileListActions(
set: BrowserSliceSet,
get: BrowserSliceGet
): Pick<
BrowserSlice,
'fetchBrowserSessionProfiles' | 'createBrowserSessionProfile' | 'deleteBrowserSessionProfile'
> {
return {
fetchBrowserSessionProfiles: async () => {
const hostId = getBrowserSettingsHostId(get())
const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get())
if (runtimeEnvironmentId) {
try {
const result = await callRuntimeRpc<BrowserProfileListResult>(
{ kind: 'environment', environmentId: runtimeEnvironmentId },
'browser.profileList',
undefined,
{ timeoutMs: 15_000 }
)
// Why: client-hosted imports never touch the server, so the server's
// records can't carry the "imported from Chrome" badge — this desktop
// remembers what it imported into each environment's jar and overlays it.
const clientImportSources = await window.api.browser
.sessionClientRouteImportSources?.({ environmentId: runtimeEnvironmentId })
.catch(() => ({}))
const profiles = result.profiles.map((profile) =>
!profile.source && clientImportSources?.[profile.id]
? { ...profile, source: clientImportSources[profile.id] }
: profile
)
set((s) => profileListByHostUpdate(s, profiles, hostId))
} catch {
set((s) => profileListByHostUpdate(s, [], hostId))
}
return
}
try {
const profiles = (await window.api.browser.sessionListProfiles()) as BrowserSessionProfile[]
set((s) => profileListByHostUpdate(s, profiles, hostId))
} catch {
/* best-effort — stale profile list is preferable to a crash */
}
},
createBrowserSessionProfile: async (scope, label, options) => {
const hostId = getBrowserSettingsHostId(get())
const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get())
if (runtimeEnvironmentId) {
try {
const result = await callRuntimeRpc<BrowserProfileCreateResult>(
{ kind: 'environment', environmentId: runtimeEnvironmentId },
'browser.profileCreate',
{ scope, label, ...options },
{ timeoutMs: 15_000 }
)
const profile = result.profile
if (profile) {
set((s) => ({
...profileListByHostUpdate(
s,
[...getBrowserProfilesForHost(s, hostId), profile],
hostId
)
}))
}
return profile
} catch {
return null
}
}
try {
const profile = (await window.api.browser.sessionCreateProfile({
scope,
label,
...options
})) as BrowserSessionProfile | null
if (profile) {
set((s) => ({
...profileListByHostUpdate(
s,
[...getBrowserProfilesForHost(s, hostId), profile],
hostId
)
}))
}
return profile
} catch {
return null
}
},
deleteBrowserSessionProfile: async (profileId) => {
const hostId = getBrowserSettingsHostId(get())
const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get())
if (runtimeEnvironmentId) {
try {
const result = await callRuntimeRpc<BrowserProfileDeleteResult>(
{ kind: 'environment', environmentId: runtimeEnvironmentId },
'browser.profileDelete',
{ profileId },
{ timeoutMs: 15_000 }
)
if (result.deleted) {
set((s) => ({
...profileListByHostUpdate(
s,
getBrowserProfilesForHost(s, hostId).filter((profile) => profile.id !== profileId),
hostId
),
...(getDefaultBrowserProfileForHost(s, hostId) === profileId
? {
...(getBrowserSettingsHostId(s) === hostId
? { defaultBrowserSessionProfileId: null }
: {}),
defaultBrowserSessionProfileIdByHostId: {
...s.defaultBrowserSessionProfileIdByHostId,
[hostId]: null
}
}
: {})
}))
}
return result.deleted
} catch {
return false
}
}
try {
const ok = await window.api.browser.sessionDeleteProfile({ profileId })
if (ok) {
set((s) => ({
...profileListByHostUpdate(
s,
getBrowserProfilesForHost(s, hostId).filter((profile) => profile.id !== profileId),
hostId
),
...(getDefaultBrowserProfileForHost(s, hostId) === profileId
? {
...(getBrowserSettingsHostId(s) === hostId
? { defaultBrowserSessionProfileId: null }
: {}),
defaultBrowserSessionProfileIdByHostId: {
...s.defaultBrowserSessionProfileIdByHostId,
[hostId]: null
}
}
: {})
}))
}
return ok
} catch {
return false
}
}
}
}
@@ -0,0 +1,33 @@
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { isBrowserPageDefinitivelyGone } from '@/runtime/client-hosted-browser-close-intent-replay'
import { isDurableClientHostedBrowserHandle } from '@/runtime/client-hosted-browser-close-intents'
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
import type { PendingClientHostedBrowserClose } from '@/runtime/client-hosted-browser-close-intents'
import type { RemoteBrowserPageHandle } from './browser-slice-contract'
export function closeRemoteBrowserPageInOwningEnvironment(
worktreeId: string,
handle: RemoteBrowserPageHandle,
// Why a callback and not the store: this is module scope, and the recording action lives on the
// slice this file is still defining.
recordCloseIntents: (closes: readonly PendingClientHostedBrowserClose[]) => void
): void {
const target: RuntimeClientTarget = { kind: 'environment', environmentId: handle.environmentId }
void callRuntimeRpc(
target,
'browser.tabClose',
{ worktree: toRuntimeWorktreeSelector(worktreeId), page: handle.remotePageId },
{ timeoutMs: 15_000 }
).catch((error) => {
// A close the runtime never heard is a resurrection waiting to happen: the runtime persists
// client-hosted pages, so a durable intent replays this same RPC on the next reconnect.
// A definitive page-unknown answer means there is nothing left to resurrect.
if (!isDurableClientHostedBrowserHandle(handle) || isBrowserPageDefinitivelyGone(error)) {
return
}
recordCloseIntents([
{ environmentId: handle.environmentId, browserPageId: handle.remotePageId, worktreeId }
])
})
}
@@ -0,0 +1,262 @@
import type { StateCreator } from 'zustand'
import type { AppState } from '../../types'
import type {
BrowserCertificateFailure,
BrowserCookieImportResult,
BrowserCookieImportSummary,
BrowserHistoryEntry,
BrowserLoadError,
BrowserPage,
BrowserPageDocLocation,
BrowserSessionProfile,
BrowserSessionProfileCreateOptions,
BrowserViewportPresetId,
BrowserWorkspace
} from '../../../../../shared/browser-workspace-types'
import type { WorkspaceSessionState } from '../../../../../shared/workspace-session-state-types'
import type { WorkspaceDocHistoryEntry } from '../../../../../shared/workspace-doc-history'
import type {
BrowserAnnotationIntent,
BrowserPageAnnotation
} from '../../../../../shared/browser-grab-types'
import type {
BrowserPageConversionLeg,
BrowserPageConversionTarget
} from '../browser-page-conversion'
import type { RecentlyClosedTabPosition } from '../recently-closed-tabs'
import type {
ClientHostedBrowserCloseIntentsByEnvironment,
PendingClientHostedBrowserClose
} from '@/runtime/client-hosted-browser-close-intents'
import type { WorkspaceSessionHydrationOptions } from '@/lib/workspace-session-hydration-keys'
import type { ExecutionHostId } from '../../../../../shared/execution-host'
import type { RuntimeBrowserPlacement } from '../../../../../shared/runtime-browser-placement'
export type CreateBrowserTabOptions = {
activate?: boolean
browserPageId?: string
title?: string
sessionProfileId?: string | null
sessionPartition?: string | null
// Place the new tab in a specific group (e.g. "Open Preview to the Side"); defaults to the worktree's active group.
targetGroupId?: string
// Explicit "New Tab" focuses the address bar even with a real home URL; link-opened tabs leave it unset.
focusAddressBar?: boolean
browserRuntimeEnvironmentId?: string | null
/** Creates a page that shows a workspace document instead of a URL. */
docLocation?: BrowserPageDocLocation
}
export type CreateBrowserPageOptions = {
activate?: boolean
title?: string
browserRuntimeEnvironmentId?: string | null
/** Creates a page that shows a workspace document instead of a URL. */
docLocation?: BrowserPageDocLocation
}
export type BrowserTabPageState = {
title?: string
loading?: boolean
faviconUrl?: string | null
canGoBack?: boolean
canGoForward?: boolean
loadError?: BrowserLoadError | null
}
export type SetBrowserPageUrlOptions = {
preserveLoadError?: boolean
}
export type ClosedBrowserWorkspaceSnapshot = {
workspace: BrowserWorkspace
pages: BrowserPage[]
position?: RecentlyClosedTabPosition
}
export type RemoteBrowserPageHandle = {
environmentId: string
remotePageId: string
placement?: RuntimeBrowserPlacement
/** Optimistically staged by this client; the host has not published the page yet. */
staged?: true
/**
* This client expects to host the staged page itself. The real placement is minted host-side and
* only arrives with the snapshot, so the pane needs this to mount as the right kind of pane from
* the first frame instead of swapping components at adoption.
*/
stagedClientHosted?: true
/**
* Rebuilt at hydration from the persisted page row rather than observed from a host snapshot. The
* page id is real, but nothing has confirmed the host still has it, so the row is exempt from the
* absent-from-snapshot cull until the first snapshot that publishes it clears this.
*/
restoredFromSession?: true
/** The restored page was hosted by this desktop, so it must not restore as a streamed pane. */
restoredClientHosted?: true
}
export type BrowserCookieImportExecutionResult = BrowserCookieImportResult & {
executionHostId: ExecutionHostId
executionHostLabel: string
// Why: for a remote environment the import silently runs on either machine; toasts must say which.
executionMachine: 'client' | 'remote'
executionRemoteEnvironment: boolean
}
export type BrowserSlice = {
browserTabsByWorktree: Record<string, BrowserWorkspace[]>
browserPagesByWorkspace: Record<string, BrowserPage[]>
browserCertificateFailuresByPageId: Record<string, BrowserCertificateFailure>
browserAnnotationsByPageId: Record<string, BrowserPageAnnotation[]>
remoteBrowserPageHandlesByPageId: Record<string, RemoteBrowserPageHandle>
/**
* Closes of client-hosted pages their owning runtime never heard, keyed by environment.
*
* Durable because the runtime persists the pages themselves: a close swallowed while the host
* was down would otherwise be undone by that host's next start.
*/
clientHostedBrowserCloseIntentsByEnvironment: ClientHostedBrowserCloseIntentsByEnvironment
recordClientHostedBrowserCloseIntents: (
closes: readonly PendingClientHostedBrowserClose[]
) => void
clearClientHostedBrowserCloseIntents: (
environmentId: string,
browserPageIds: readonly string[]
) => void
activeBrowserTabId: string | null
activeBrowserTabIdByWorktree: Record<string, string | null>
recentlyClosedBrowserTabsByWorktree: Record<string, ClosedBrowserWorkspaceSnapshot[]>
recentlyClosedBrowserPagesByWorkspace: Record<string, BrowserPage[]>
pendingAddressBarFocusByTabId: Record<string, true>
pendingAddressBarFocusByPageId: Record<string, true>
createBrowserTab: (
worktreeId: string,
url: string,
options?: CreateBrowserTabOptions
) => BrowserWorkspace
openNewBrowserTabInActiveWorkspace: (groupId: string) => Promise<void>
/** `profileId: null` uses the workspace default profile. */
openBrowserProfileTabInActiveWorkspace: (
url: string,
profileId: string | null
) => Promise<boolean>
closeBrowserTab: (tabId: string, options?: { reason?: 'cleanup' }) => void
shutdownWorktreeBrowsers: (worktreeId: string) => Promise<void>
reopenClosedBrowserTab: (worktreeId: string) => BrowserWorkspace | null
setActiveBrowserTab: (tabId: string) => void
createBrowserPage: (
workspaceId: string,
url: string,
options?: CreateBrowserPageOptions
) => BrowserPage | null
closeBrowserPage: (pageId: string) => void
// Why replacement and not mutation: a page's kind (doc vs web) is immutable for its life, so the
// address bar converts by replacing the page — fresh id, same workspace row — keeping the two
// main-process registry halves disjoint by construction.
convertBrowserPage: (
pageId: string,
target: BrowserPageConversionTarget,
options?: { leg?: BrowserPageConversionLeg }
) => BrowserPage | null
reopenClosedBrowserPage: (workspaceId: string) => BrowserPage | null
setActiveBrowserPage: (workspaceId: string, pageId: string) => void
// Focus that never yanks the user across worktrees: per-worktree slots always update, globals only when targeting the active worktree.
focusBrowserTabInWorktree: (
worktreeId: string,
browserPageId: string,
options?: { surfacePane?: boolean }
) => void
consumeAddressBarFocusRequest: (pageId: string) => boolean
updateBrowserTabPageState: (pageId: string, updates: BrowserTabPageState) => void
updateBrowserPageState: (pageId: string, updates: BrowserTabPageState) => void
setBrowserPageCertificateFailure: (
pageId: string,
failure: BrowserCertificateFailure | null
) => void
setBrowserTabUrl: (pageId: string, url: string) => void
setBrowserPageUrl: (pageId: string, url: string, options?: SetBrowserPageUrlOptions) => void
setRemoteBrowserPageHandle: (pageId: string, handle: RemoteBrowserPageHandle) => void
removeRemoteBrowserPageHandle: (
pageId: string,
remotePageId?: string
) => RemoteBrowserPageHandle | null
setBrowserPageViewportPreset: (
pageId: string,
viewportPresetId: BrowserViewportPresetId | null
) => void
addBrowserPageAnnotation: (annotation: BrowserPageAnnotation) => void
updateBrowserPageAnnotation: (
pageId: string,
annotationId: string,
patch: { comment: string; intent: BrowserAnnotationIntent }
) => void
deleteBrowserPageAnnotation: (pageId: string, annotationId: string) => void
clearBrowserPageAnnotations: (pageId: string) => void
hydrateBrowserSession: (
session: WorkspaceSessionState,
options?: WorkspaceSessionHydrationOptions
) => void
switchBrowserTabProfile: (
workspaceId: string,
profileId: string | null,
sessionPartition?: string | null
) => void
browserSessionProfiles: BrowserSessionProfile[]
browserSessionProfilesByHostId: Partial<Record<ExecutionHostId, BrowserSessionProfile[]>>
browserSessionHostIdOverride: ExecutionHostId | null
setBrowserSessionHostId: (hostId: ExecutionHostId) => Promise<void>
browserSessionImportState: {
profileId: string
status: 'idle' | 'importing' | 'success' | 'error'
summary: BrowserCookieImportSummary | null
error: string | null
} | null
fetchBrowserSessionProfiles: () => Promise<void>
createBrowserSessionProfile: (
scope: 'isolated' | 'imported',
label: string,
options?: BrowserSessionProfileCreateOptions
) => Promise<BrowserSessionProfile | null>
deleteBrowserSessionProfile: (profileId: string) => Promise<boolean>
importCookiesToProfile: (profileId: string) => Promise<BrowserCookieImportExecutionResult>
clearBrowserSessionImportState: () => void
detectedBrowsers: {
family: string
label: string
profiles: { name: string; directory: string }[]
selectedProfile: string
}[]
detectedBrowsersLoaded: boolean
// Why: which machine answered detection for a remote environment, so import menus can say where
// the import will read and store; null while browser settings target the local host.
detectedBrowsersHost: { machine: 'client' | 'remote'; hostLabel: string } | null
fetchDetectedBrowsers: () => Promise<void>
importCookiesFromBrowser: (
profileId: string,
browserFamily: string,
browserProfile?: string
) => Promise<BrowserCookieImportExecutionResult>
clearDefaultSessionCookies: () => Promise<boolean>
browserUrlHistory: BrowserHistoryEntry[]
addBrowserHistoryEntry: (url: string, title: string) => void
workspaceDocHistory: WorkspaceDocHistoryEntry[]
/** A visit bumps recency and count; a title-only refresh (bump: false) renames the row. */
recordWorkspaceDocVisit: (
docLocation: BrowserPageDocLocation,
title: string | null,
options?: { bump?: boolean }
) => void
clearBrowserHistory: () => void
defaultBrowserSessionProfileId: string | null
defaultBrowserSessionProfileIdByHostId: Partial<Record<ExecutionHostId, string | null>>
setDefaultBrowserSessionProfileId: (profileId: string | null) => void
}
type BrowserStateCreator = StateCreator<AppState, [], [], BrowserSlice>
export type BrowserSliceSet = Parameters<BrowserStateCreator>[0]
export type BrowserSliceGet = Parameters<BrowserStateCreator>[1]
// Keep the public slice surface's domain types available to existing consumers while the
// implementation is split across focused modules.
export type { BrowserPageConversionLeg, BrowserPageConversionTarget, BrowserSessionProfile }
@@ -0,0 +1,253 @@
import { createBrowserUuid } from '@/lib/browser-uuid'
import { translate } from '@/i18n/i18n'
import {
FLOATING_TERMINAL_WORKTREE_ID,
ORCA_BROWSER_BLANK_URL
} from '../../../../../shared/constants'
import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract'
import {
assertManagedBrowserMaterializationAllowed,
getClientCreationActionPolicy
} from '@/lib/client-creation-action-policy'
import {
buildBrowserPage,
buildWorkspaceFromPage,
findPage,
findWorkspace
} from '../browser-page-records'
import { getBrowserSessionProfileHostId } from './browser-host-state'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
export function createBrowserTabActions(
set: BrowserSliceSet,
get: BrowserSliceGet
): Pick<
BrowserSlice,
| 'createBrowserTab'
| 'openNewBrowserTabInActiveWorkspace'
| 'openBrowserProfileTabInActiveWorkspace'
> {
return {
createBrowserTab: (worktreeId, url, options) => {
assertManagedBrowserMaterializationAllowed(get(), options?.browserRuntimeEnvironmentId)
const workspaceId = createBrowserUuid()
const browserPageId = options?.browserPageId
if (
browserPageId &&
(findWorkspace(get().browserTabsByWorktree, browserPageId) ||
findPage(get().browserPagesByWorkspace, browserPageId))
) {
throw new Error(`Browser page ${browserPageId} already exists`)
}
const page = buildBrowserPage(
workspaceId,
worktreeId,
url,
options?.title,
options?.browserRuntimeEnvironmentId,
browserPageId,
options?.docLocation
)
// Why: with no explicit profile, inherit the user's default so a Settings preference applies to new tabs.
const sessionProfileId =
options?.sessionProfileId !== undefined
? options.sessionProfileId
: (get().defaultBrowserSessionProfileIdByHostId[
getBrowserSessionProfileHostId(
get(),
worktreeId,
options?.browserRuntimeEnvironmentId
)
] ?? get().defaultBrowserSessionProfileId)
const browserTab = buildWorkspaceFromPage(
workspaceId,
worktreeId,
page,
[page.id],
sessionProfileId,
options?.sessionPartition
)
set((s) => {
const existingTabs = s.browserTabsByWorktree[worktreeId] ?? []
const nextTabBarOrder = (() => {
const currentOrder = s.tabBarOrderByWorktree[worktreeId] ?? []
const terminalIds = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)
const editorIds = s.openFiles
.filter((file) => file.worktreeId === worktreeId)
.map((file) => file.id)
const browserIds = existingTabs.map((tab) => tab.id)
const allExistingIds = new Set([...terminalIds, ...editorIds, ...browserIds])
const base = currentOrder.filter((entryId) => allExistingIds.has(entryId))
const inBase = new Set(base)
for (const entryId of [...terminalIds, ...editorIds, ...browserIds]) {
if (!inBase.has(entryId)) {
base.push(entryId)
inBase.add(entryId)
}
}
base.push(workspaceId)
return base
})()
const shouldActivate = options?.activate ?? true
const shouldUpdateGlobalActiveSurface = shouldActivate && s.activeWorktreeId === worktreeId
const shouldFocusFloatingTab =
shouldActivate && worktreeId === FLOATING_TERMINAL_WORKTREE_ID
const shouldFocusAddressBar =
(shouldUpdateGlobalActiveSurface || shouldFocusFloatingTab) &&
// Why the doc check and not just the url: a document page is blank by construction, and
// the blank url is exactly what marks a New Tab as wanting the address bar it does not have.
!page.docLocation &&
(options?.focusAddressBar ??
(page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL))
return {
browserTabsByWorktree: {
...s.browserTabsByWorktree,
[worktreeId]: [...existingTabs, browserTab]
},
browserPagesByWorkspace: {
...s.browserPagesByWorkspace,
[workspaceId]: [page]
},
tabBarOrderByWorktree: {
...s.tabBarOrderByWorktree,
[worktreeId]: nextTabBarOrder
},
activeBrowserTabId: shouldUpdateGlobalActiveSurface ? workspaceId : s.activeBrowserTabId,
activeBrowserTabIdByWorktree: {
...s.activeBrowserTabIdByWorktree,
[worktreeId]: shouldActivate
? workspaceId
: (s.activeBrowserTabIdByWorktree[worktreeId] ?? null)
},
activeTabType: shouldUpdateGlobalActiveSurface ? 'browser' : s.activeTabType,
activeTabTypeByWorktree: shouldActivate
? { ...s.activeTabTypeByWorktree, [worktreeId]: 'browser' }
: s.activeTabTypeByWorktree,
pendingAddressBarFocusByPageId: shouldFocusAddressBar
? {
...s.pendingAddressBarFocusByPageId,
[page.id]: true
}
: s.pendingAddressBarFocusByPageId,
pendingAddressBarFocusByTabId: shouldFocusAddressBar
? {
...s.pendingAddressBarFocusByTabId,
[workspaceId]: true,
[page.id]: true
}
: s.pendingAddressBarFocusByTabId
}
})
const state = get()
const alreadyHasUnifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).some(
(t) => t.contentType === 'browser' && t.entityId === workspaceId
)
if (!alreadyHasUnifiedTab) {
state.createUnifiedTab(worktreeId, 'browser', {
entityId: workspaceId,
label: browserTab.title,
targetGroupId: options?.targetGroupId,
activate: options?.activate ?? true
})
}
return browserTab
},
openNewBrowserTabInActiveWorkspace: async (groupId) => {
const state = get()
const worktreeId = state.activeWorktreeId
if (!worktreeId) {
return
}
const browserAvailability = getClientCreationActionPolicy(state, worktreeId)[
'managed-browser'
]
if (browserAvailability.state !== 'enabled') {
throw new Error(browserAvailability.reason)
}
const defaultUrl = state.browserDefaultUrl ?? 'about:blank'
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId)
if (browserAvailability.provider === 'paired-runtime') {
if (!runtimeEnvironmentId) {
throw new Error('The paired runtime browser provider is unavailable.')
}
const { createWebRuntimeSessionBrowserTab } = await import('@/runtime/web-runtime-session')
try {
const created = await createWebRuntimeSessionBrowserTab({
worktreeId,
environmentId: runtimeEnvironmentId,
url: defaultUrl,
// Why: desktop pane groups are client-owned — the local reconciler only honors a
// recorded clientTargetGroupId; targetGroupId steers snapshot-driven clients instead.
targetGroupId: groupId,
clientTargetGroupId: groupId
})
if (created) {
get().recordFeatureInteraction('browser-tab-created')
return
}
} catch (error) {
// Why: browser.headless.v1 remotes succeed above, so a failure here is real; surface it instead of a confusing local-tab fallback (split ownership).
console.warn(
'[browser] remote browser tab creation failed:',
error instanceof Error ? error.message : String(error)
)
throw error
}
throw new Error('The paired runtime could not create a managed browser tab.')
}
get().createBrowserTab(worktreeId, defaultUrl, {
title: translate('auto.store.slices.browser.d175274b6d', 'New Browser Tab'),
focusAddressBar: true,
...(runtimeEnvironmentId ? { browserRuntimeEnvironmentId: null } : {}),
targetGroupId: groupId
})
get().recordFeatureInteraction('browser-tab-created')
},
openBrowserProfileTabInActiveWorkspace: async (url, profileId) => {
const state = get()
const worktreeId = state.activeWorktreeId
if (!worktreeId) {
return false
}
const browserAvailability = getClientCreationActionPolicy(state, worktreeId)[
'managed-browser'
]
if (browserAvailability.state !== 'enabled') {
return false
}
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId)
if (browserAvailability.provider === 'paired-runtime') {
if (!runtimeEnvironmentId) {
return false
}
const { createWebRuntimeSessionBrowserTab } = await import('@/runtime/web-runtime-session')
try {
return await createWebRuntimeSessionBrowserTab({
worktreeId,
environmentId: runtimeEnvironmentId,
url,
profileId
})
} catch (error) {
console.warn(
'[browser] remote profile tab creation failed:',
error instanceof Error ? error.message : String(error)
)
return false
}
}
get().createBrowserTab(worktreeId, url, {
activate: true,
sessionProfileId: profileId,
...(runtimeEnvironmentId ? { browserRuntimeEnvironmentId: null } : {})
})
return true
}
}
}

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