fix(ci): split the structured session-tabs sync and give static analysis mobile types

The local structured session-tabs sync module outgrew the 300-line cap once it
took on generation fencing, so split it along its real seams instead of raising
the cap: the generation/cursor fence, snapshot projection, snapshot apply,
inventory refresh, and the subscription loop. The original path stays as a
barrel so no importer moves.

Repoint the host-session-mirror settle census at the apply module, which owns
two receipts now — the snapshot it mirrors in, and the toggle-off teardown that
retracts what it published. The teardown receipt is named rather than anonymous
so the pin says which direction it settles.

The changed-code quality gate lints mobile files and resolves their types from
mobile/node_modules, but mobile is a separate pnpm project that the root install
never populates, so every mobile type degraded to an `error` type and the gate
reported phantom findings. Install mobile dependencies in static analysis when
the diff touches mobile, gated on a new classifier output.
This commit is contained in:
Merge Sim
2026-09-03 12:01:05 -07:00
parent 4e4c302bbd
commit b2e3256df4
10 changed files with 444 additions and 307 deletions
+10
View File
@@ -28,6 +28,7 @@ jobs:
outputs:
should_run: ${{ steps.filter.outputs.should_run }}
native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }}
mobile_dependencies: ${{ steps.filter.outputs.mobile_dependencies }}
static_analysis: ${{ steps.filter.outputs.static_analysis }}
typecheck: ${{ steps.filter.outputs.typecheck }}
git_compatibility: ${{ steps.filter.outputs.git_compatibility }}
@@ -95,6 +96,15 @@ jobs:
- name: Enforce type-aware code-quality baseline
run: pnpm run audit:code-quality:type-aware
# Why: the changed-code gate lints mobile files too, and its type-aware pass
# resolves types from mobile/node_modules. Mobile is a separate pnpm project,
# so the root install above leaves it empty and every mobile type degrades to
# an `error` type — reported as phantom findings against the changed lines.
- name: Install mobile dependencies
if: needs.code_paths.outputs.mobile_dependencies == 'true'
working-directory: mobile
run: pnpm install --frozen-lockfile
- name: Enforce changed-code quality
run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}"
+9
View File
@@ -261,6 +261,14 @@ export function shouldRunPrChecks(changedFiles) {
return changedFiles.some((file) => !isDocsOnlyPath(file) && !isDesktopIrrelevantPath(file))
}
export function needsMobileDependencies(changedFiles) {
// Why: static analysis lints CHANGED files, mobile ones included, and its
// type-aware pass resolves types from mobile/node_modules. Mobile is a
// separate pnpm project, so without this the root-only install leaves every
// mobile type an `error` type and the gate reports phantom findings.
return changedFiles.length === 0 || changedFiles.some((file) => file.startsWith('mobile/'))
}
export function classifyPrJobs(changedFiles) {
const emptyDiff = changedFiles.length === 0
const shouldRun = shouldRunPrChecks(changedFiles)
@@ -274,6 +282,7 @@ export function classifyPrJobs(changedFiles) {
return {
should_run: shouldRun,
native_cache_changed: shouldRun && (emptyDiff || changedFiles.some(isNativeCacheInputPath)),
mobile_dependencies: needsMobileDependencies(changedFiles),
...jobs
}
}
@@ -305,6 +305,20 @@ describe('per-job path classification', () => {
}
})
// Why: static analysis lints changed mobile files with a type-aware pass, and
// mobile is a separate pnpm project. Without its node_modules every mobile type
// resolves to an `error` type and the changed-code gate fails on phantom
// findings, which is exactly how a react-test-renderer union broke a PR.
it('installs mobile dependencies exactly when mobile files change', () => {
expect(classifyPrJobs([]).mobile_dependencies).toBe(true)
expect(classifyPrJobs(['README.md']).mobile_dependencies).toBe(false)
expect(classifyPrJobs(['src/main/index.ts']).mobile_dependencies).toBe(false)
expect(
classifyPrJobs(['src/main/index.ts', 'mobile/src/session/a.test.ts']).mobile_dependencies
).toBe(true)
expect(classifyPrJobs(['mobile/package.json']).mobile_dependencies).toBe(true)
})
it('keeps unit-test-only diffs out of packaging', () => {
expectClassification(['src/main/git/git-status.test.ts'], {
git_compatibility: true
@@ -343,6 +357,20 @@ describe('PR Checks skip wiring', () => {
}
})
it('gives static analysis the mobile types its type-aware pass resolves', () => {
expect(prWorkflow.jobs.code_paths.outputs.mobile_dependencies).toBe(
'${{ steps.filter.outputs.mobile_dependencies }}'
)
const steps = prWorkflow.jobs.static_analysis.steps
const install = steps.findIndex((step) => step.name === 'Install mobile dependencies')
const gate = steps.findIndex((step) => step.name === 'Enforce changed-code quality')
expect(install).toBeGreaterThan(-1)
expect(install).toBeLessThan(gate)
expect(steps[install].if).toBe("needs.code_paths.outputs.mobile_dependencies == 'true'")
expect(steps[install]['working-directory']).toBe('mobile')
expect(steps[install].run).toContain('--frozen-lockfile')
})
it('keeps the cheap root-directory guard on docs-only PRs', () => {
expect(prWorkflow.jobs.root_directory_guard.if).toBeUndefined()
expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined()
@@ -150,8 +150,9 @@ describe('host-session-mirror settle census', () => {
'runtime/web-session-tabs-sync/visibility-resume-repair.ts': 1,
// The eager post-create session.tabs.list refresh.
'runtime/web-runtime-session-snapshot.ts': 1,
// The local structured-session inventory/subscription frame.
'runtime/local-structured-session-tabs-sync.ts': 1
// The local structured-session mirror owns two: the inventory/subscription
// frame, and the toggle-off teardown that retracts the tabs it published.
'runtime/local-structured-session-tabs-sync/snapshot-apply.ts': 2
})
})
@@ -196,14 +197,19 @@ describe('host-session-mirror settle census', () => {
// Hydration and mirror receipts remain pinned by their extracted owners:
// the global singular frame owns two hydration completions and the global
// inventory frame one, initial loading owns one, active subscription owns
// two mirror settles, and visibility resume repair owns one.
// two mirror settles, and visibility resume repair owns one. The local
// structured-session apply module owns one settle per direction: the
// snapshot it mirrors in, and the teardown that retracts it.
'runtime/web-session-tabs-sync/active-session-subscription.ts': { settle: 2 },
'runtime/web-session-tabs-sync/global-session-events.ts': { settleHydration: 2 },
'runtime/web-session-tabs-sync/global-session-inventory-event.ts': { settleHydration: 1 },
'runtime/web-session-tabs-sync/load-initial.ts': { settleHydration: 1 },
'runtime/web-session-tabs-sync/visibility-resume-repair.ts': { settle: 1 },
'runtime/web-runtime-session-snapshot.ts': { settleMirror: 1 },
'runtime/local-structured-session-tabs-sync.ts': { settleStructuredSessionMirror: 1 }
'runtime/local-structured-session-tabs-sync/snapshot-apply.ts': {
settleStructuredSessionClear: 1,
settleStructuredSessionMirror: 1
}
})
})
@@ -1,309 +1,22 @@
import { useEffect } from 'react'
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
import { useAppStore } from '../store'
import type { WorktreeRuntimeOwnerState } from '../lib/worktree-runtime-owner'
import { getExecutionHostIdForWorktree } from '../lib/worktree-runtime-owner'
import { applyWebSessionTabsSnapshot, applyWebSessionTabsStorePatch } from './web-session-tabs-sync'
import type { WebSessionTabsSyncState } from './web-session-tabs-sync'
import {
noteRetiredValue,
sameSessionTabsPublicationLineage
} from './web-session-tabs-sync/publisher-identity-fences'
import type { SessionTabsPublicationEpochHistory } from './web-session-tabs-sync/state'
import { refreshLocalRuntimeCapabilities } from './local-runtime-capabilities'
import {
knownStructuredSessionWorktreeIds,
removeStructuredSessionTabsForVersions,
type StructuredSessionTabPublicationVersion
} from './local-structured-session-tab-retirement'
import { clearLocalStructuredSessionTabs } from './local-structured-session-tabs-sync/snapshot-apply'
import { startLocalStructuredSessionTabsSync } from './local-structured-session-tabs-sync/subscription'
export const LOCAL_STRUCTURED_SESSION_OWNER = 'local-structured-session'
let localStructuredSessionTabsRestorePromise: Promise<void> | null = null
let localStructuredSessionSyncGeneration = 0
const localStructuredSessionVersionByWorktree = new Map<
string,
StructuredSessionTabPublicationVersion
>()
const localStructuredSessionEpochHistoryByWorktree = new Map<
string,
SessionTabsPublicationEpochHistory
>()
export function resetLocalStructuredSessionVersionForTests(): void {
localStructuredSessionSyncGeneration += 1
localStructuredSessionVersionByWorktree.clear()
localStructuredSessionEpochHistoryByWorktree.clear()
}
type SessionTabsEvent =
| (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' })
| { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[] }
| { type: 'end' }
export function projectLocalStructuredSessionTabs(
snapshot: RuntimeMobileSessionTabsResult
): RuntimeMobileSessionTabsResult {
const structuredIds = new Set(
snapshot.tabs.filter((tab) => tab.type === 'agent-session').map((tab) => tab.id)
)
const visibleHostTabIds = structuredIds
const visibleIds = structuredIds
const projectedTabGroups = snapshot.tabGroups
?.map((group) => ({
...group,
tabOrder: group.tabOrder.filter((id) => visibleHostTabIds.has(id)),
activeTabId:
group.activeTabId && visibleHostTabIds.has(group.activeTabId) ? group.activeTabId : null,
recentTabIds: group.recentTabIds?.filter((id) => visibleHostTabIds.has(id))
}))
.filter((group) => group.tabOrder.length > 0)
return {
...snapshot,
activeTabId: visibleIds.has(snapshot.activeTabId ?? '') ? snapshot.activeTabId : null,
activeTabType:
snapshot.activeTabId && visibleIds.has(snapshot.activeTabId) ? snapshot.activeTabType : null,
activeGroupId:
snapshot.activeGroupId &&
projectedTabGroups?.some((group) => group.id === snapshot.activeGroupId)
? snapshot.activeGroupId
: (projectedTabGroups?.[0]?.id ?? null),
tabs: snapshot.tabs.filter((tab) => visibleIds.has(tab.id)),
tabGroups: projectedTabGroups,
// Why: group membership locates chats; the renderer's split tree remains locally authoritative.
tabGroupLayout: undefined
}
}
export function applyStructuredSessionTabSnapshots(
snapshots: readonly RuntimeMobileSessionTabsResult[],
owner = LOCAL_STRUCTURED_SESSION_OWNER
): void {
const settleStructuredSessionMirror = applyWebSessionTabsStorePatch(
(state) => applyLocalStructuredSessionTabSnapshots(state, snapshots, owner),
{ frames: [] }
)
settleStructuredSessionMirror()
}
export function removeLocalStructuredSessionTabs<
State extends WebSessionTabsSyncState & WorktreeRuntimeOwnerState
>(state: State, owner = LOCAL_STRUCTURED_SESSION_OWNER, now = Date.now()): State {
return removeStructuredSessionTabsForVersions(
state,
localStructuredSessionVersionByWorktree,
owner,
now
)
}
export function clearLocalStructuredSessionTabs(): void {
// Fence responses from the previous enabled instance before clearing its mirror.
localStructuredSessionSyncGeneration += 1
const settle = applyWebSessionTabsStorePatch((state) => removeLocalStructuredSessionTabs(state), {
frames: []
})
settle()
localStructuredSessionTabsRestorePromise = null
localStructuredSessionVersionByWorktree.clear()
localStructuredSessionEpochHistoryByWorktree.clear()
}
export function applyLocalStructuredSessionTabSnapshots<
State extends WebSessionTabsSyncState & WorktreeRuntimeOwnerState
>(
state: State,
snapshots: readonly RuntimeMobileSessionTabsResult[],
owner = LOCAL_STRUCTURED_SESSION_OWNER,
now = Date.now()
): State {
let next = state
for (const snapshot of snapshots) {
// Why: the execution host owns its tabs; local inventory must not rewrite paired or SSH panes.
if (getExecutionHostIdForWorktree(next, snapshot.worktree) !== 'local') {
continue
}
const prior = localStructuredSessionVersionByWorktree.get(snapshot.worktree)
const sharesLineage = Boolean(
prior && sameSessionTabsPublicationLineage(prior.publicationEpoch, snapshot.publicationEpoch)
)
const epochHistory = localStructuredSessionEpochHistoryByWorktree.get(snapshot.worktree)
if (epochHistory?.retired.includes(snapshot.publicationEpoch) && !sharesLineage) {
continue
}
if (prior && sharesLineage && snapshot.snapshotVersion <= prior.snapshotVersion) {
continue
}
const patch = applyWebSessionTabsSnapshot(
next,
projectLocalStructuredSessionTabs(snapshot),
owner,
now,
{
contentScope: 'agent-session',
preserveLocalLayout: true,
terminalPtyMode: 'local'
}
)
next = patch === next ? next : ({ ...next, ...patch } as State)
localStructuredSessionVersionByWorktree.set(snapshot.worktree, {
publicationEpoch: snapshot.publicationEpoch,
snapshotVersion: snapshot.snapshotVersion
})
localStructuredSessionEpochHistoryByWorktree.set(
snapshot.worktree,
noteRetiredValue(epochHistory, snapshot.publicationEpoch, 8)
)
}
// Drop publisher cursors for worktrees that no longer exist. Without this,
// every deleted worktree leaves an entry for the lifetime of the renderer.
const knownWorktreeIds = knownStructuredSessionWorktreeIds(next)
for (const worktreeId of localStructuredSessionVersionByWorktree.keys()) {
if (!knownWorktreeIds.has(worktreeId)) {
localStructuredSessionVersionByWorktree.delete(worktreeId)
localStructuredSessionEpochHistoryByWorktree.delete(worktreeId)
}
}
return next
}
export function restoreLocalStructuredSessionTabsOnce(
expectedGeneration = localStructuredSessionSyncGeneration
): Promise<void> {
localStructuredSessionTabsRestorePromise ??= refreshLocalRuntimeCapabilities()
.then(() => refreshLocalStructuredSessionTabs(expectedGeneration))
.then(() => undefined)
.catch((error) => {
localStructuredSessionTabsRestorePromise = null
throw error
})
return localStructuredSessionTabsRestorePromise
}
/** Fetch the current host inventory even after the startup restore has settled. */
export function refreshLocalStructuredSessionTabs(
expectedGeneration = localStructuredSessionSyncGeneration
): Promise<RuntimeMobileSessionTabsResult[]> {
return window.api.runtime
.call({ method: 'session.tabs.listAll', params: {} })
.then((response) => {
if (!response.ok) {
throw new Error('structured session inventory unavailable')
}
const result = response.result as { snapshots?: RuntimeMobileSessionTabsResult[] }
const snapshots = result.snapshots ?? []
if (expectedGeneration === localStructuredSessionSyncGeneration) {
applyStructuredSessionTabSnapshots(snapshots)
}
return snapshots
})
}
export async function startLocalStructuredSessionTabsSync(args: {
isDisposed: () => boolean
setUnsubscribe: (unsubscribe: () => void) => void
}): Promise<void> {
const syncGeneration = localStructuredSessionSyncGeneration
const isCurrent = (): boolean =>
!args.isDisposed() && syncGeneration === localStructuredSessionSyncGeneration
const capabilities = await refreshLocalRuntimeCapabilities()
if (!isCurrent()) {
return
}
const supported = capabilities.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)
await restoreLocalStructuredSessionTabsOnce(syncGeneration)
if (!isCurrent()) {
return
}
if (!supported) {
return
}
let subscriptionGeneration = 0
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let reconnectAttempt = 0
let activeHandle: { unsubscribe: () => void } | null = null
const scheduleSubscribeRetry = (): void => {
if (!isCurrent() || reconnectTimer !== null) {
return
}
const reconnectDelay = Math.min(250 * 2 ** reconnectAttempt, 5000)
reconnectAttempt += 1
reconnectTimer = setTimeout(() => {
reconnectTimer = null
void refreshLocalStructuredSessionTabs(syncGeneration)
.catch((error) => console.warn('[structured-session-tabs] resync failed', error))
.finally(() => {
if (isCurrent()) {
void subscribeCurrent().catch((error) => {
console.warn('[structured-session-tabs] resubscribe failed', error)
scheduleSubscribeRetry()
})
}
})
}, reconnectDelay)
}
const subscribeCurrent = async (): Promise<void> => {
if (!isCurrent()) {
return
}
const generation = ++subscriptionGeneration
let handle: { unsubscribe: () => void } | null = null
handle = await window.api.runtime.subscribe(
{ method: 'session.tabs.subscribeAll', params: {} },
(response) => {
if (!isCurrent() || generation !== subscriptionGeneration) {
return
}
if (!response.ok) {
// A streaming RPC can terminate with an error response before its
// handle resolves; fence that generation and retry the subscription.
subscriptionGeneration += 1
handle?.unsubscribe()
if (activeHandle === handle) {
activeHandle = null
}
scheduleSubscribeRetry()
return
}
const event = response.result as SessionTabsEvent
if (event.type === 'snapshots') {
applyStructuredSessionTabSnapshots(event.snapshots)
} else if (event.type === 'snapshot' || event.type === 'updated') {
applyStructuredSessionTabSnapshots([event])
} else if (event.type === 'end' && generation === subscriptionGeneration) {
// Reattach with one refresh so a runtime-restart boundary cannot strand stale tabs.
subscriptionGeneration += 1
handle?.unsubscribe()
if (activeHandle === handle) {
activeHandle = null
}
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer)
}
scheduleSubscribeRetry()
}
}
)
if (!isCurrent() || generation !== subscriptionGeneration) {
handle.unsubscribe()
} else {
activeHandle = handle
}
}
args.setUnsubscribe(() => {
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
activeHandle?.unsubscribe()
activeHandle = null
})
void subscribeCurrent().catch((error) => {
console.warn('[structured-session-tabs] subscribe failed', error)
scheduleSubscribeRetry()
})
}
export { resetLocalStructuredSessionVersionForTests } from './local-structured-session-tabs-sync/inventory-generation-fence'
export {
refreshLocalStructuredSessionTabs,
restoreLocalStructuredSessionTabsOnce
} from './local-structured-session-tabs-sync/inventory-refresh'
export {
applyLocalStructuredSessionTabSnapshots,
applyStructuredSessionTabSnapshots,
clearLocalStructuredSessionTabs,
LOCAL_STRUCTURED_SESSION_OWNER,
removeLocalStructuredSessionTabs
} from './local-structured-session-tabs-sync/snapshot-apply'
export { projectLocalStructuredSessionTabs } from './local-structured-session-tabs-sync/snapshot-projection'
export { startLocalStructuredSessionTabsSync } from './local-structured-session-tabs-sync/subscription'
export function useLocalStructuredSessionTabsSync(): void {
const ready = useAppStore(
@@ -0,0 +1,57 @@
import type { SessionTabsPublicationEpochHistory } from '../web-session-tabs-sync/state'
import type { StructuredSessionTabPublicationVersion } from '../local-structured-session-tab-retirement'
// Everything a toggle-off must invalidate: which publisher instance the renderer
// is listening to, which publication it already accepted per worktree, and the
// one-shot startup restore. A response in flight for a superseded instance must
// never reach the mirror, so every async entry point carries the generation it
// was started under and re-checks it before applying.
let syncGeneration = 0
let restorePromise: Promise<void> | null = null
export const localStructuredSessionVersionByWorktree = new Map<
string,
StructuredSessionTabPublicationVersion
>()
export const localStructuredSessionEpochHistoryByWorktree = new Map<
string,
SessionTabsPublicationEpochHistory
>()
export function localStructuredSessionGeneration(): number {
return syncGeneration
}
export function isCurrentLocalStructuredSessionGeneration(generation: number): boolean {
return generation === syncGeneration
}
/** Retire the current publisher instance: responses already in flight stop applying. */
export function supersedeLocalStructuredSessionGeneration(): void {
syncGeneration += 1
}
// Separate from superseding because a teardown still has to publish the retiring
// cursors as retracted tabs before it may forget them.
export function forgetLocalStructuredSessionPublicationCursors(): void {
localStructuredSessionVersionByWorktree.clear()
localStructuredSessionEpochHistoryByWorktree.clear()
}
export function dropLocalStructuredSessionRestoreLatch(): void {
restorePromise = null
}
/** Latch the startup restore, releasing it on failure so a retry can re-run it. */
export function latchLocalStructuredSessionRestore(start: () => Promise<void>): Promise<void> {
restorePromise ??= start().catch((error: unknown) => {
restorePromise = null
throw error
})
return restorePromise
}
export function resetLocalStructuredSessionVersionForTests(): void {
supersedeLocalStructuredSessionGeneration()
forgetLocalStructuredSessionPublicationCursors()
}
@@ -0,0 +1,37 @@
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import { refreshLocalRuntimeCapabilities } from '../local-runtime-capabilities'
import {
isCurrentLocalStructuredSessionGeneration,
latchLocalStructuredSessionRestore,
localStructuredSessionGeneration
} from './inventory-generation-fence'
import { applyStructuredSessionTabSnapshots } from './snapshot-apply'
export function restoreLocalStructuredSessionTabsOnce(
expectedGeneration = localStructuredSessionGeneration()
): Promise<void> {
return latchLocalStructuredSessionRestore(() =>
refreshLocalRuntimeCapabilities()
.then(() => refreshLocalStructuredSessionTabs(expectedGeneration))
.then(() => undefined)
)
}
/** Fetch the current host inventory even after the startup restore has settled. */
export function refreshLocalStructuredSessionTabs(
expectedGeneration = localStructuredSessionGeneration()
): Promise<RuntimeMobileSessionTabsResult[]> {
return window.api.runtime
.call({ method: 'session.tabs.listAll', params: {} })
.then((response) => {
if (!response.ok) {
throw new Error('structured session inventory unavailable')
}
const result = response.result as { snapshots?: RuntimeMobileSessionTabsResult[] }
const snapshots = result.snapshots ?? []
if (isCurrentLocalStructuredSessionGeneration(expectedGeneration)) {
applyStructuredSessionTabSnapshots(snapshots)
}
return snapshots
})
}
@@ -0,0 +1,118 @@
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import type { WorktreeRuntimeOwnerState } from '../../lib/worktree-runtime-owner'
import { getExecutionHostIdForWorktree } from '../../lib/worktree-runtime-owner'
import {
applyWebSessionTabsSnapshot,
applyWebSessionTabsStorePatch
} from '../web-session-tabs-sync'
import type { WebSessionTabsSyncState } from '../web-session-tabs-sync'
import {
noteRetiredValue,
sameSessionTabsPublicationLineage
} from '../web-session-tabs-sync/publisher-identity-fences'
import {
knownStructuredSessionWorktreeIds,
removeStructuredSessionTabsForVersions
} from '../local-structured-session-tab-retirement'
import {
dropLocalStructuredSessionRestoreLatch,
forgetLocalStructuredSessionPublicationCursors,
localStructuredSessionEpochHistoryByWorktree,
localStructuredSessionVersionByWorktree,
supersedeLocalStructuredSessionGeneration
} from './inventory-generation-fence'
import { projectLocalStructuredSessionTabs } from './snapshot-projection'
export const LOCAL_STRUCTURED_SESSION_OWNER = 'local-structured-session'
export function applyStructuredSessionTabSnapshots(
snapshots: readonly RuntimeMobileSessionTabsResult[],
owner = LOCAL_STRUCTURED_SESSION_OWNER
): void {
const settleStructuredSessionMirror = applyWebSessionTabsStorePatch(
(state) => applyLocalStructuredSessionTabSnapshots(state, snapshots, owner),
{ frames: [] }
)
settleStructuredSessionMirror()
}
export function removeLocalStructuredSessionTabs<
State extends WebSessionTabsSyncState & WorktreeRuntimeOwnerState
>(state: State, owner = LOCAL_STRUCTURED_SESSION_OWNER, now = Date.now()): State {
return removeStructuredSessionTabsForVersions(
state,
localStructuredSessionVersionByWorktree,
owner,
now
)
}
export function clearLocalStructuredSessionTabs(): void {
// Fence responses from the previous enabled instance before clearing its mirror.
supersedeLocalStructuredSessionGeneration()
const settleStructuredSessionClear = applyWebSessionTabsStorePatch(
(state) => removeLocalStructuredSessionTabs(state),
{ frames: [] }
)
settleStructuredSessionClear()
dropLocalStructuredSessionRestoreLatch()
forgetLocalStructuredSessionPublicationCursors()
}
export function applyLocalStructuredSessionTabSnapshots<
State extends WebSessionTabsSyncState & WorktreeRuntimeOwnerState
>(
state: State,
snapshots: readonly RuntimeMobileSessionTabsResult[],
owner = LOCAL_STRUCTURED_SESSION_OWNER,
now = Date.now()
): State {
let next = state
for (const snapshot of snapshots) {
// Why: the execution host owns its tabs; local inventory must not rewrite paired or SSH panes.
if (getExecutionHostIdForWorktree(next, snapshot.worktree) !== 'local') {
continue
}
const prior = localStructuredSessionVersionByWorktree.get(snapshot.worktree)
const sharesLineage = Boolean(
prior && sameSessionTabsPublicationLineage(prior.publicationEpoch, snapshot.publicationEpoch)
)
const epochHistory = localStructuredSessionEpochHistoryByWorktree.get(snapshot.worktree)
if (epochHistory?.retired.includes(snapshot.publicationEpoch) && !sharesLineage) {
continue
}
if (prior && sharesLineage && snapshot.snapshotVersion <= prior.snapshotVersion) {
continue
}
const patch = applyWebSessionTabsSnapshot(
next,
projectLocalStructuredSessionTabs(snapshot),
owner,
now,
{
contentScope: 'agent-session',
preserveLocalLayout: true,
terminalPtyMode: 'local'
}
)
next = patch === next ? next : ({ ...next, ...patch } as State)
localStructuredSessionVersionByWorktree.set(snapshot.worktree, {
publicationEpoch: snapshot.publicationEpoch,
snapshotVersion: snapshot.snapshotVersion
})
localStructuredSessionEpochHistoryByWorktree.set(
snapshot.worktree,
noteRetiredValue(epochHistory, snapshot.publicationEpoch, 8)
)
}
// Drop publisher cursors for worktrees that no longer exist. Without this,
// every deleted worktree leaves an entry for the lifetime of the renderer.
const knownWorktreeIds = knownStructuredSessionWorktreeIds(next)
for (const worktreeId of localStructuredSessionVersionByWorktree.keys()) {
if (!knownWorktreeIds.has(worktreeId)) {
localStructuredSessionVersionByWorktree.delete(worktreeId)
localStructuredSessionEpochHistoryByWorktree.delete(worktreeId)
}
}
return next
}
@@ -0,0 +1,37 @@
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
/** Narrow a host inventory snapshot to the structured agent-session tabs it publishes. */
export function projectLocalStructuredSessionTabs(
snapshot: RuntimeMobileSessionTabsResult
): RuntimeMobileSessionTabsResult {
const structuredIds = new Set(
snapshot.tabs.filter((tab) => tab.type === 'agent-session').map((tab) => tab.id)
)
const visibleHostTabIds = structuredIds
const visibleIds = structuredIds
const projectedTabGroups = snapshot.tabGroups
?.map((group) => ({
...group,
tabOrder: group.tabOrder.filter((id) => visibleHostTabIds.has(id)),
activeTabId:
group.activeTabId && visibleHostTabIds.has(group.activeTabId) ? group.activeTabId : null,
recentTabIds: group.recentTabIds?.filter((id) => visibleHostTabIds.has(id))
}))
.filter((group) => group.tabOrder.length > 0)
return {
...snapshot,
activeTabId: visibleIds.has(snapshot.activeTabId ?? '') ? snapshot.activeTabId : null,
activeTabType:
snapshot.activeTabId && visibleIds.has(snapshot.activeTabId) ? snapshot.activeTabType : null,
activeGroupId:
snapshot.activeGroupId &&
projectedTabGroups?.some((group) => group.id === snapshot.activeGroupId)
? snapshot.activeGroupId
: (projectedTabGroups?.[0]?.id ?? null),
tabs: snapshot.tabs.filter((tab) => visibleIds.has(tab.id)),
tabGroups: projectedTabGroups,
// Why: group membership locates chats; the renderer's split tree remains locally authoritative.
tabGroupLayout: undefined
}
}
@@ -0,0 +1,122 @@
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import { refreshLocalRuntimeCapabilities } from '../local-runtime-capabilities'
import {
isCurrentLocalStructuredSessionGeneration,
localStructuredSessionGeneration
} from './inventory-generation-fence'
import {
refreshLocalStructuredSessionTabs,
restoreLocalStructuredSessionTabsOnce
} from './inventory-refresh'
import { applyStructuredSessionTabSnapshots } from './snapshot-apply'
type SessionTabsEvent =
| (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' })
| { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[] }
| { type: 'end' }
export async function startLocalStructuredSessionTabsSync(args: {
isDisposed: () => boolean
setUnsubscribe: (unsubscribe: () => void) => void
}): Promise<void> {
const syncGeneration = localStructuredSessionGeneration()
const isCurrent = (): boolean =>
!args.isDisposed() && isCurrentLocalStructuredSessionGeneration(syncGeneration)
const capabilities = await refreshLocalRuntimeCapabilities()
if (!isCurrent()) {
return
}
const supported = capabilities.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)
await restoreLocalStructuredSessionTabsOnce(syncGeneration)
if (!isCurrent()) {
return
}
if (!supported) {
return
}
let subscriptionGeneration = 0
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let reconnectAttempt = 0
let activeHandle: { unsubscribe: () => void } | null = null
const scheduleSubscribeRetry = (): void => {
if (!isCurrent() || reconnectTimer !== null) {
return
}
const reconnectDelay = Math.min(250 * 2 ** reconnectAttempt, 5000)
reconnectAttempt += 1
reconnectTimer = setTimeout(() => {
reconnectTimer = null
void refreshLocalStructuredSessionTabs(syncGeneration)
.catch((error) => console.warn('[structured-session-tabs] resync failed', error))
.finally(() => {
if (isCurrent()) {
void subscribeCurrent().catch((error) => {
console.warn('[structured-session-tabs] resubscribe failed', error)
scheduleSubscribeRetry()
})
}
})
}, reconnectDelay)
}
const subscribeCurrent = async (): Promise<void> => {
if (!isCurrent()) {
return
}
const generation = ++subscriptionGeneration
let handle: { unsubscribe: () => void } | null = null
handle = await window.api.runtime.subscribe(
{ method: 'session.tabs.subscribeAll', params: {} },
(response) => {
if (!isCurrent() || generation !== subscriptionGeneration) {
return
}
if (!response.ok) {
// A streaming RPC can terminate with an error response before its
// handle resolves; fence that generation and retry the subscription.
subscriptionGeneration += 1
handle?.unsubscribe()
if (activeHandle === handle) {
activeHandle = null
}
scheduleSubscribeRetry()
return
}
const event = response.result as SessionTabsEvent
if (event.type === 'snapshots') {
applyStructuredSessionTabSnapshots(event.snapshots)
} else if (event.type === 'snapshot' || event.type === 'updated') {
applyStructuredSessionTabSnapshots([event])
} else if (event.type === 'end' && generation === subscriptionGeneration) {
// Reattach with one refresh so a runtime-restart boundary cannot strand stale tabs.
subscriptionGeneration += 1
handle?.unsubscribe()
if (activeHandle === handle) {
activeHandle = null
}
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer)
}
scheduleSubscribeRetry()
}
}
)
if (!isCurrent() || generation !== subscriptionGeneration) {
handle.unsubscribe()
} else {
activeHandle = handle
}
}
args.setUnsubscribe(() => {
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
activeHandle?.unsubscribe()
activeHandle = null
})
void subscribeCurrent().catch((error) => {
console.warn('[structured-session-tabs] subscribe failed', error)
scheduleSubscribeRetry()
})
}