mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
refactor(renderer): split runtime and store modules
This commit is contained in:
@@ -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
Reference in New Issue
Block a user