mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
2704 lines
94 KiB
TypeScript
2704 lines
94 KiB
TypeScript
/* eslint-disable max-lines -- Why: web session-tab sync reconciles terminal,
|
|
* unified-tab, group, and PTY maps atomically so host-published surfaces don't
|
|
* leave the web client in a split-brain tab state. */
|
|
import { useEffect } from 'react'
|
|
import type { AppState } from '../store'
|
|
import { useAppStore } from '../store'
|
|
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
|
|
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
|
|
import {
|
|
AGENT_STATUS_STALE_AFTER_MS,
|
|
type AgentStatusEntry
|
|
} from '../../../shared/agent-status-types'
|
|
import type {
|
|
RuntimeMobileSessionTabsResult,
|
|
RuntimeMobileSessionBrowserTab,
|
|
RuntimeMobileSessionFileTab,
|
|
RuntimeMobileSessionMarkdownTab,
|
|
RuntimeMobileSessionTabGroup,
|
|
RuntimeMobileSessionTerminalClientTab
|
|
} from '../../../shared/runtime-types'
|
|
import type {
|
|
BrowserPage,
|
|
BrowserWorkspace,
|
|
Tab,
|
|
TabGroup,
|
|
TabGroupLayoutNode,
|
|
TerminalLayoutSnapshot,
|
|
TerminalPaneLayoutNode,
|
|
TerminalTab,
|
|
TuiAgent
|
|
} from '../../../shared/types'
|
|
import type { OpenFile } from '../store/slices/editor'
|
|
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
|
|
import { getRemoteRuntimePtyEnvironmentId, toRemoteRuntimePtyId } from './runtime-terminal-stream'
|
|
import { sanitizeTerminalLayoutPaneTitlesForLabels } from '@/lib/terminal-pane-title-sanitization'
|
|
import {
|
|
getExplicitRuntimeEnvironmentIdForWorktree,
|
|
getRuntimeSessionMirrorEnvironmentIds
|
|
} from '@/lib/worktree-runtime-owner'
|
|
import {
|
|
createWebRuntimeSessionTerminal,
|
|
HOST_TERMINAL_SURFACE_SEPARATOR,
|
|
isWebTerminalSurfaceTabId,
|
|
toWebTerminalSurfaceTabId,
|
|
WEB_TERMINAL_SURFACE_TAB_PREFIX
|
|
} from './web-runtime-session'
|
|
import {
|
|
normalizeCompatibleAgentStatusEntryForOwner,
|
|
normalizeCompatibleAgentTitleForOwner
|
|
} from '../../../shared/agent-title-owner'
|
|
import { resolvePaneAgentOwner } from '../../../shared/pane-agent-owner'
|
|
import { resolveTerminalLayoutRoot } from './remote-terminal-layout-resolution'
|
|
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
|
|
import { clearWebSessionFocusIntent, peekWebSessionFocusIntent } from './web-session-focus-intent'
|
|
import {
|
|
clearWebSessionCloseIntentsForWorktree,
|
|
isWebSessionCloseIntentPending,
|
|
reconcileWebSessionCloseIntents
|
|
} from './web-session-close-intent'
|
|
import {
|
|
clearWebSessionReorderIntentsForWorktree,
|
|
resolveWebSessionReorderedOrder
|
|
} from './web-session-reorder-intent'
|
|
import {
|
|
beginWebRuntimeWakeTerminalRespawn,
|
|
clearAllWebRuntimeWakeTerminalRespawn,
|
|
clearWebRuntimeWakeTerminalRespawnForWorktree,
|
|
endWebRuntimeWakeTerminalRespawn,
|
|
shouldSkipWebRuntimeWakeTerminalRespawn
|
|
} from './web-runtime-wake-terminal-respawn'
|
|
import { isRuntimeSubscriptionReplayResponse } from '../../../shared/runtime-subscription-replay'
|
|
|
|
const WEB_SESSION_GROUP_PREFIX = 'web-session-tabs:'
|
|
|
|
type SessionTabsStreamEvent =
|
|
| (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' })
|
|
| { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[] }
|
|
| { type: 'end' }
|
|
|
|
type SessionTabsListAllResult = {
|
|
snapshots: RuntimeMobileSessionTabsResult[]
|
|
}
|
|
|
|
type SnapshotFreshness = {
|
|
publicationEpoch: string
|
|
snapshotVersion: number
|
|
}
|
|
|
|
const latestSessionTabsSnapshotByWorktree = new Map<string, SnapshotFreshness>()
|
|
const lastHostTerminalTabCountByWorktree = new Map<string, number>()
|
|
const hostSessionTabIdByLocalKey = new Map<string, string>()
|
|
|
|
type TerminalSurface = RuntimeMobileSessionTerminalClientTab
|
|
type ReadyTerminalSurface = RuntimeMobileSessionTerminalClientTab & { status: 'ready' }
|
|
type ReadyBrowserSurface = RuntimeMobileSessionBrowserTab & { browserPageId: string }
|
|
type ReadyEditorSurface = RuntimeMobileSessionMarkdownTab | RuntimeMobileSessionFileTab
|
|
|
|
type MirroredTerminalTab = {
|
|
tab: TerminalTab
|
|
hostTabId: string
|
|
ptyIds: string[]
|
|
layout: TerminalLayoutSnapshot
|
|
}
|
|
|
|
type MirroredBrowserTab = {
|
|
workspace: BrowserWorkspace
|
|
page: BrowserPage
|
|
remotePageId: string
|
|
unifiedTab: Tab
|
|
hostTabId: string
|
|
}
|
|
|
|
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'
|
|
| 'browserTabsByWorktree'
|
|
| 'groupsByWorktree'
|
|
| 'layoutByWorktree'
|
|
| 'openFiles'
|
|
| 'ptyIdsByTabId'
|
|
| 'remoteBrowserPageHandlesByPageId'
|
|
| 'tabBarOrderByWorktree'
|
|
| 'tabsByWorktree'
|
|
| 'terminalLayoutsByTabId'
|
|
| 'unifiedTabsByWorktree'
|
|
| 'unreadTerminalTabs'
|
|
| 'sortEpoch'
|
|
>
|
|
|
|
function isSessionTabsListAllResult(value: unknown): value is SessionTabsListAllResult {
|
|
return (
|
|
Boolean(value) &&
|
|
typeof value === 'object' &&
|
|
Array.isArray((value as { snapshots?: unknown }).snapshots)
|
|
)
|
|
}
|
|
|
|
function sessionTabsFreshnessKey(environmentId: string, worktreeId: string): string {
|
|
return `${environmentId}:${worktreeId}`
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
export function getLastKnownHostTerminalTabCount(
|
|
environmentId: string,
|
|
worktreeId: string
|
|
): number {
|
|
return (
|
|
lastHostTerminalTabCountByWorktree.get(sessionTabsFreshnessKey(environmentId, worktreeId)) ?? 0
|
|
)
|
|
}
|
|
|
|
// Why: a post-reconnect subscription replay re-emits the current snapshot with
|
|
// an unchanged epoch/version; dropping the freshness entry lets the monotonic
|
|
// gate accept that replay as authoritative instead of freezing the mirror
|
|
// (#7718). Normal-operation ordering protection is untouched — this only runs
|
|
// for responses the connection tagged as reconnect replays.
|
|
export function acceptReplayedWebSessionTabsSnapshot(
|
|
environmentId: string,
|
|
worktreeId: string
|
|
): void {
|
|
latestSessionTabsSnapshotByWorktree.delete(sessionTabsFreshnessKey(environmentId, worktreeId))
|
|
}
|
|
|
|
export function shouldApplyWebSessionTabsSnapshot(
|
|
snapshot: RuntimeMobileSessionTabsResult,
|
|
environmentId: string
|
|
): boolean {
|
|
const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree)
|
|
if ((snapshot as { removed?: unknown }).removed === true) {
|
|
// Why: removed worktrees can stop publishing snapshots, so their
|
|
// freshness/mapping entries need explicit cleanup instead of waiting for
|
|
// a later replacement snapshot that may never arrive.
|
|
clearWebSessionTabsTrackingForWorktree(environmentId, snapshot.worktree)
|
|
return true
|
|
}
|
|
if (snapshot.worktree === FLOATING_TERMINAL_WORKTREE_ID) {
|
|
// Why: the floating workspace is a local synthetic terminal. A focused
|
|
// remote runtime can publish an empty same-id snapshot while the user has a
|
|
// local ssh/tmux tab open; treating that as authoritative deletes the local
|
|
// floating tabs.
|
|
return false
|
|
}
|
|
rememberHostTerminalTabCount(environmentId, snapshot)
|
|
const current = latestSessionTabsSnapshotByWorktree.get(key)
|
|
// Why: snapshotVersion is monotonic only WITHIN one host generation; it resets
|
|
// when the host restarts, and each restart produces a different publicationEpoch.
|
|
// So only treat a frame as stale when it shares the current epoch and isn't
|
|
// newer — a different epoch is a new generation (or a restart) and must apply,
|
|
// even if its version is lower. (A cross-stream out-of-order frame with a
|
|
// different epoch may briefly apply, but the next snapshot's higher version
|
|
// self-heals it; rejecting on version alone would instead permanently drop a
|
|
// post-restart snapshot, since the client's tracking survives transparent
|
|
// transport reconnects.)
|
|
if (
|
|
current &&
|
|
current.publicationEpoch === snapshot.publicationEpoch &&
|
|
snapshot.snapshotVersion <= current.snapshotVersion
|
|
) {
|
|
return false
|
|
}
|
|
latestSessionTabsSnapshotByWorktree.set(key, {
|
|
publicationEpoch: snapshot.publicationEpoch,
|
|
snapshotVersion: snapshot.snapshotVersion
|
|
})
|
|
return true
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
export function resetWebSessionTabsSnapshotFreshnessForTests(): void {
|
|
latestSessionTabsSnapshotByWorktree.clear()
|
|
lastHostTerminalTabCountByWorktree.clear()
|
|
hostSessionTabIdByLocalKey.clear()
|
|
}
|
|
|
|
export function _getWebSessionTabsTrackingCountsForTest(): {
|
|
freshness: number
|
|
hostMappings: number
|
|
} {
|
|
return {
|
|
freshness: latestSessionTabsSnapshotByWorktree.size,
|
|
hostMappings: hostSessionTabIdByLocalKey.size
|
|
}
|
|
}
|
|
|
|
function clearWebSessionTabsTrackingForWorktree(environmentId: string, worktreeId: string): void {
|
|
const key = sessionTabsFreshnessKey(environmentId, worktreeId)
|
|
latestSessionTabsSnapshotByWorktree.delete(key)
|
|
lastHostTerminalTabCountByWorktree.delete(key)
|
|
clearWebRuntimeWakeTerminalRespawnForWorktree(worktreeId)
|
|
clearWebSessionFocusIntent(worktreeId)
|
|
clearWebSessionCloseIntentsForWorktree(worktreeId)
|
|
clearWebSessionReorderIntentsForWorktree(worktreeId)
|
|
const keyPrefix = `${environmentId}:${worktreeId}:`
|
|
for (const key of hostSessionTabIdByLocalKey.keys()) {
|
|
if (key.startsWith(keyPrefix)) {
|
|
hostSessionTabIdByLocalKey.delete(key)
|
|
}
|
|
}
|
|
}
|
|
|
|
export function clearWebSessionTabsTrackingForEnvironment(environmentId: string): void {
|
|
const trimmedEnvironmentId = environmentId.trim()
|
|
if (!trimmedEnvironmentId) {
|
|
return
|
|
}
|
|
const keyPrefix = `${trimmedEnvironmentId}:`
|
|
for (const key of latestSessionTabsSnapshotByWorktree.keys()) {
|
|
if (key.startsWith(keyPrefix)) {
|
|
latestSessionTabsSnapshotByWorktree.delete(key)
|
|
}
|
|
}
|
|
for (const key of lastHostTerminalTabCountByWorktree.keys()) {
|
|
if (key.startsWith(keyPrefix)) {
|
|
lastHostTerminalTabCountByWorktree.delete(key)
|
|
}
|
|
}
|
|
for (const key of hostSessionTabIdByLocalKey.keys()) {
|
|
if (key.startsWith(keyPrefix)) {
|
|
hostSessionTabIdByLocalKey.delete(key)
|
|
}
|
|
}
|
|
clearAllWebRuntimeWakeTerminalRespawn()
|
|
}
|
|
|
|
function hostSessionTabMappingKey(args: {
|
|
environmentId: string
|
|
worktreeId: string
|
|
tabId: string
|
|
}): string {
|
|
return `${args.environmentId}:${args.worktreeId}:${args.tabId}`
|
|
}
|
|
|
|
export function resolveHostSessionTabIdForWebSessionTab(
|
|
_state: WebSessionTabsSyncState,
|
|
args: {
|
|
environmentId: string
|
|
worktreeId: string
|
|
tabId: string
|
|
}
|
|
): string | null {
|
|
return hostSessionTabIdByLocalKey.get(hostSessionTabMappingKey(args)) ?? null
|
|
}
|
|
|
|
function isReadyTerminalTab(
|
|
tab: RuntimeMobileSessionTabsResult['tabs'][number]
|
|
): tab is ReadyTerminalSurface {
|
|
return tab.type === 'terminal' && tab.status === 'ready' && tab.terminal.trim().length > 0
|
|
}
|
|
|
|
function isTerminalSurfaceTab(
|
|
tab: RuntimeMobileSessionTabsResult['tabs'][number]
|
|
): tab is TerminalSurface {
|
|
return tab.type === 'terminal'
|
|
}
|
|
|
|
function isReadyBrowserTab(
|
|
tab: RuntimeMobileSessionTabsResult['tabs'][number]
|
|
): tab is ReadyBrowserSurface {
|
|
return tab.type === 'browser' && typeof tab.browserPageId === 'string' && tab.browserPageId !== ''
|
|
}
|
|
|
|
function isReadyEditorTab(
|
|
tab: RuntimeMobileSessionTabsResult['tabs'][number]
|
|
): tab is ReadyEditorSurface {
|
|
return tab.type === 'markdown' || tab.type === 'file'
|
|
}
|
|
|
|
function localEditorFileId(tab: ReadyEditorSurface): string {
|
|
if (tab.type === 'markdown' && tab.mode === 'markdown-preview') {
|
|
return `markdown-preview::${tab.sourceFilePath}`
|
|
}
|
|
return tab.filePath
|
|
}
|
|
|
|
function editorSourceFileId(tab: ReadyEditorSurface): string | undefined {
|
|
return tab.type === 'markdown' && tab.mode === 'markdown-preview' ? tab.sourceFilePath : undefined
|
|
}
|
|
|
|
function isRuntimeTerminalTabForEnvironment(tab: TerminalTab, environmentId: string): boolean {
|
|
if (!tab.ptyId) {
|
|
return false
|
|
}
|
|
return getRemoteRuntimePtyEnvironmentId(tab.ptyId) === environmentId
|
|
}
|
|
|
|
function isMirroredTerminalSurfaceId(tabId: string): boolean {
|
|
return (
|
|
tabId.startsWith(WEB_TERMINAL_SURFACE_TAB_PREFIX) ||
|
|
tabId.includes(HOST_TERMINAL_SURFACE_SEPARATOR)
|
|
)
|
|
}
|
|
|
|
function chooseRemoteTerminalLayout(
|
|
surfaces: readonly TerminalSurface[],
|
|
ptyIdsByLeafId: Record<string, string>,
|
|
existingLayout?: TerminalLayoutSnapshot
|
|
): 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 =
|
|
// 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 =
|
|
parentLayout?.expandedLeafId && knownLeafIds.has(parentLayout.expandedLeafId)
|
|
? parentLayout.expandedLeafId
|
|
: null
|
|
return {
|
|
// Why: the host's parentLayout is authoritative (carries the real split
|
|
// direction); only if it doesn't cover the current leaves do we 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 ("Terminal 2", agent title,
|
|
// etc.). Restoring it as a pane title makes the web client render a fake
|
|
// title bar above xterm. Only host layout titles are real user pane titles.
|
|
...(parentLayout?.titlesByLeafId ? { titlesByLeafId: parentLayout.titlesByLeafId } : {})
|
|
}
|
|
}
|
|
|
|
function shouldReplaceTerminalTab(
|
|
tab: TerminalTab,
|
|
environmentId: string,
|
|
nextRemotePtyIds: ReadonlySet<string>,
|
|
nextMirroredTerminalIds: ReadonlySet<string>,
|
|
nextMirroredLaunchAgents: ReadonlySet<TuiAgent>
|
|
): boolean {
|
|
if (
|
|
tab.launchAgent &&
|
|
!isMirroredTerminalSurfaceId(tab.id) &&
|
|
nextMirroredLaunchAgents.has(tab.launchAgent)
|
|
) {
|
|
// Why: paired web agent quick-launch used to create local-only tabs before
|
|
// the host snapshot landed. Retire only the matching agent's stale row.
|
|
return true
|
|
}
|
|
if (isMirroredTerminalSurfaceId(tab.id)) {
|
|
// Why: host session snapshots are authoritative for host-mirrored tabs.
|
|
// Replace old mirrors even when the next surface is still waiting on a
|
|
// stream handle, otherwise paired web keeps stale handles or drops parity.
|
|
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 the
|
|
// corresponding session surface. Only retire them once their PTY is present
|
|
// in the host snapshot, while always pruning prior mirrored surface IDs.
|
|
return (
|
|
tab.ptyId !== null &&
|
|
(nextRemotePtyIds.has(tab.ptyId) ||
|
|
nextMirroredTerminalIds.has(toWebTerminalSurfaceTabId(tab.id)))
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Constructs mirrored terminal tabs from the mobile session status payload,
|
|
* normalising Pi-compatible agent titles under launch ownership.
|
|
*/
|
|
function buildMirroredTerminalTabs(
|
|
snapshot: RuntimeMobileSessionTabsResult,
|
|
environmentId: string,
|
|
existingById: ReadonlyMap<string, TerminalTab>,
|
|
existingLayoutsByTabId: Readonly<Record<string, TerminalLayoutSnapshot>>,
|
|
sortOffset: number,
|
|
now: number
|
|
): 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 activeSurface =
|
|
(existingLayout?.activeLeafId
|
|
? surfaces.find((surface) => surface.leafId === existingLayout.activeLeafId)
|
|
: undefined) ??
|
|
surfaces.find((surface) => surface.isActive) ??
|
|
surfaces[0]!
|
|
const ptyIdsByLeafId = Object.fromEntries(
|
|
surfaces
|
|
.filter((surface): surface is ReadyTerminalSurface => surface.status === 'ready')
|
|
.map((surface) => [surface.leafId, toRemoteRuntimePtyId(surface.terminal, environmentId)])
|
|
)
|
|
const ptyIds = surfaces
|
|
.map((surface) => ptyIdsByLeafId[surface.leafId]!)
|
|
.filter((ptyId): ptyId is string => typeof ptyId === 'string' && ptyId.length > 0)
|
|
const launchAgent =
|
|
activeSurface.launchAgent ?? surfaces.find((surface) => surface.launchAgent)?.launchAgent
|
|
const ownerAgent = resolvePaneAgentOwner({
|
|
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',
|
|
ownerAgent
|
|
)
|
|
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: startup cwd is host-owned launch metadata; once the host omits it,
|
|
// mirrored clients must not resurrect stale subdirectory intent.
|
|
const startupCwd =
|
|
activeSurface.startupCwd || surfaces.find((surface) => surface.startupCwd)?.startupCwd
|
|
// Why: tab color/pin echo back through host snapshots, so prefer the client's
|
|
// own record (kept authoritative in tabsByWorktree by the pin/color setters)
|
|
// and fall back to the host value only when this client has no prior tab —
|
|
// e.g. first reconcile or a change made on another client. Mirrors how
|
|
// customTitle always prefers the client value to avoid 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 like color/pin, so prefer
|
|
// the client's own record during the optimistic echo window and adopt the host
|
|
// value only when this client has no prior tab (first reconcile / other client).
|
|
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,
|
|
...(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. If the host stops
|
|
// publishing it, mirrored clients must not resurrect stale startup intent.
|
|
...(launchAgent ? { launchAgent } : {})
|
|
},
|
|
hostTabId: parentTabId,
|
|
ptyIds,
|
|
layout: chooseRemoteTerminalLayout(surfaces, ptyIdsByLeafId, existingLayout)
|
|
}
|
|
})
|
|
}
|
|
|
|
function toMirroredPaneKey(surface: TerminalSurface): string | null {
|
|
if (!isTerminalLeafId(surface.leafId)) {
|
|
return null
|
|
}
|
|
return makePaneKey(toWebTerminalSurfaceTabId(surface.parentTabId), surface.leafId)
|
|
}
|
|
|
|
/**
|
|
* Normalises and mirrors agent status updates from the host payload,
|
|
* preserving authoritative ownership metadata.
|
|
*/
|
|
function remapHostAgentStatus(surface: TerminalSurface): AgentStatusEntry | null {
|
|
if (!surface.agentStatus) {
|
|
return null
|
|
}
|
|
const paneKey = toMirroredPaneKey(surface)
|
|
if (!paneKey) {
|
|
return null
|
|
}
|
|
const ownerAgent = resolvePaneAgentOwner({
|
|
launchAgent: surface.launchAgent,
|
|
hookAgent: surface.agentStatus.agentType
|
|
})
|
|
return {
|
|
...normalizeCompatibleAgentStatusEntryForOwner(surface.agentStatus, ownerAgent),
|
|
paneKey
|
|
}
|
|
}
|
|
|
|
function isMirroredAgentPaneKeyForTabs(paneKey: string, tabIds: ReadonlySet<string>): boolean {
|
|
const parsed = parsePaneKey(paneKey)
|
|
return parsed !== null && tabIds.has(parsed.tabId)
|
|
}
|
|
|
|
/**
|
|
* Generates a state patch for mirrored agent statuses, merging host
|
|
* status entries with client overrides defensively.
|
|
*/
|
|
function buildMirroredAgentStatusPatch(
|
|
state: WebSessionTabsSyncState,
|
|
currentTerminalTabs: readonly TerminalTab[],
|
|
terminalSurfaceTabs: readonly TerminalSurface[],
|
|
now: number
|
|
): 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
|
|
}
|
|
|
|
const nextByPaneKey = new Map<string, AgentStatusEntry>()
|
|
for (const surface of terminalSurfaceTabs) {
|
|
const entry = remapHostAgentStatus(surface)
|
|
if (!entry) {
|
|
continue
|
|
}
|
|
const existing = state.agentStatusByPaneKey[entry.paneKey]
|
|
// Why: active web streams can report a fresher OSC 9999 status for the same
|
|
// mirrored pane before the next host snapshot arrives. Do not rewind that
|
|
// row with an older host publication.
|
|
const nextEntry =
|
|
existing && existing.updatedAt > entry.updatedAt
|
|
? normalizeCompatibleAgentStatusEntryForOwner(existing, entry.agentType)
|
|
: entry
|
|
nextByPaneKey.set(entry.paneKey, nextEntry)
|
|
}
|
|
|
|
let nextAgentStatusByPaneKey = state.agentStatusByPaneKey
|
|
let changed = false
|
|
let sortRelevantChange = false
|
|
|
|
for (const paneKey of Object.keys(state.agentStatusByPaneKey)) {
|
|
if (!isMirroredAgentPaneKeyForTabs(paneKey, mirroredTabIds)) {
|
|
continue
|
|
}
|
|
if (nextByPaneKey.has(paneKey)) {
|
|
continue
|
|
}
|
|
if (nextAgentStatusByPaneKey === state.agentStatusByPaneKey) {
|
|
nextAgentStatusByPaneKey = { ...state.agentStatusByPaneKey }
|
|
}
|
|
delete nextAgentStatusByPaneKey[paneKey]
|
|
changed = true
|
|
sortRelevantChange = true
|
|
}
|
|
|
|
for (const [paneKey, entry] of nextByPaneKey) {
|
|
const existing = nextAgentStatusByPaneKey[paneKey]
|
|
if (agentStatusEntryEqual(existing, entry)) {
|
|
continue
|
|
}
|
|
if (nextAgentStatusByPaneKey === state.agentStatusByPaneKey) {
|
|
nextAgentStatusByPaneKey = { ...state.agentStatusByPaneKey }
|
|
}
|
|
nextAgentStatusByPaneKey[paneKey] = entry
|
|
changed = true
|
|
sortRelevantChange =
|
|
sortRelevantChange ||
|
|
!existing ||
|
|
existing.state !== entry.state ||
|
|
!isAgentStatusFresh(existing, now)
|
|
}
|
|
|
|
if (!changed) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
agentStatusByPaneKey: nextAgentStatusByPaneKey,
|
|
agentStatusEpoch: sortRelevantChange ? state.agentStatusEpoch + 1 : state.agentStatusEpoch,
|
|
sortEpoch: sortRelevantChange ? state.sortEpoch + 1 : state.sortEpoch
|
|
}
|
|
}
|
|
|
|
function buildTerminalUnifiedTab(
|
|
tab: TerminalTab,
|
|
groupId: string,
|
|
// Why: viewMode (terminal vs native chat) is host-tracked, but the client's
|
|
// optimistic toggle must win during the echo window. Callers pass the reconciled
|
|
// value (host value falling back to the captured client value) so toggling to
|
|
// chat doesn't revert when the next session-graph sync rebuilds tabs.
|
|
viewMode?: Tab['viewMode']
|
|
): Tab {
|
|
return {
|
|
id: tab.id,
|
|
entityId: tab.id,
|
|
groupId,
|
|
worktreeId: tab.worktreeId,
|
|
contentType: 'terminal',
|
|
label: tab.title,
|
|
...(tab.quickCommandLabel?.trim() ? { quickCommandLabel: tab.quickCommandLabel.trim() } : {}),
|
|
...(tab.generatedTitle?.trim() ? { generatedLabel: tab.generatedTitle.trim() } : {}),
|
|
customLabel: tab.customTitle,
|
|
color: tab.color,
|
|
sortOrder: tab.sortOrder,
|
|
createdAt: tab.createdAt,
|
|
isPreview: false,
|
|
isPinned: tab.isPinned === true,
|
|
...(viewMode ? { viewMode } : {})
|
|
}
|
|
}
|
|
|
|
function buildBrowserUnifiedTab(
|
|
tab: BrowserWorkspace,
|
|
hostTab: RuntimeMobileSessionBrowserTab,
|
|
existingUnifiedTab: Tab | null,
|
|
groupId: string
|
|
): Tab {
|
|
return {
|
|
id: existingUnifiedTab?.id ?? hostTab.id,
|
|
entityId: tab.id,
|
|
groupId,
|
|
worktreeId: tab.worktreeId,
|
|
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
|
|
}
|
|
}
|
|
|
|
function buildEditorUnifiedTab(
|
|
file: OpenFile,
|
|
tab: ReadyEditorSurface,
|
|
hostTabId: string,
|
|
existingUnifiedTab: Tab | null,
|
|
label: string,
|
|
groupId: string,
|
|
sortOrder: number,
|
|
createdAt: number
|
|
): Tab {
|
|
return {
|
|
id: hostTabId,
|
|
entityId: file.id,
|
|
groupId,
|
|
worktreeId: file.worktreeId,
|
|
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
|
|
}
|
|
}
|
|
|
|
function findExistingEditorUnifiedTab(
|
|
state: WebSessionTabsSyncState,
|
|
worktreeId: string,
|
|
fileId: string,
|
|
hostTabId: string
|
|
): Tab | null {
|
|
return (
|
|
(state.unifiedTabsByWorktree[worktreeId] ?? []).find(
|
|
(tab) => tab.contentType === 'editor' && (tab.id === hostTabId || tab.entityId === fileId)
|
|
) ?? null
|
|
)
|
|
}
|
|
|
|
function buildMirroredEditorTabs(
|
|
snapshot: RuntimeMobileSessionTabsResult,
|
|
environmentId: string,
|
|
state: WebSessionTabsSyncState,
|
|
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 = state.openFiles.find(
|
|
(file) => file.worktreeId === snapshot.worktree && file.id === fileId
|
|
)
|
|
const existingUnifiedTab = findExistingEditorUnifiedTab(
|
|
state,
|
|
snapshot.worktree,
|
|
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 as host-owned so a later snapshot that omits it can
|
|
// cull it. Locally opened web tabs lack this flag and survive syncs.
|
|
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
|
|
)
|
|
}
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
function browserWorkspaceHasRemoteEnvironmentPage(
|
|
state: WebSessionTabsSyncState,
|
|
workspace: BrowserWorkspace,
|
|
environmentId: string
|
|
): boolean {
|
|
return (state.browserPagesByWorkspace[workspace.id] ?? []).some(
|
|
(page) => state.remoteBrowserPageHandlesByPageId[page.id]?.environmentId === environmentId
|
|
)
|
|
}
|
|
|
|
function buildMirroredBrowserTabs(
|
|
snapshot: RuntimeMobileSessionTabsResult,
|
|
environmentId: string,
|
|
state: WebSessionTabsSyncState,
|
|
hostGroupIdByTabId: ReadonlyMap<string, string>,
|
|
fallbackGroupId: string,
|
|
sortOffset: number,
|
|
now: number
|
|
): MirroredBrowserTab[] {
|
|
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 groupId = hostGroupIdByTabId.get(tab.id) ?? fallbackGroupId
|
|
const title = tab.title.trim() || 'Browser'
|
|
const page: BrowserPage = {
|
|
id: pageId,
|
|
workspaceId,
|
|
worktreeId: snapshot.worktree,
|
|
url: tab.url,
|
|
title,
|
|
loading: tab.loading,
|
|
faviconUrl: existing?.page.faviconUrl ?? null,
|
|
canGoBack: tab.canGoBack,
|
|
canGoForward: tab.canGoForward,
|
|
loadError: null,
|
|
createdAt,
|
|
browserRuntimeEnvironmentId: environmentId,
|
|
viewportPresetId: existing?.page.viewportPresetId ?? null
|
|
}
|
|
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,
|
|
remotePageId: tab.browserPageId,
|
|
unifiedTab: buildBrowserUnifiedTab(workspace, tab, existing?.unifiedTab ?? null, groupId),
|
|
hostTabId: tab.id
|
|
}
|
|
})
|
|
}
|
|
|
|
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 web layout's
|
|
// rendered group is the only safe CSS anchor target for mirrored panes.
|
|
const firstRenderedLayoutGroupId = layoutGroupIds.values().next().value as string | undefined
|
|
return (
|
|
preferred?.id ??
|
|
firstRenderedLayoutGroupId ??
|
|
snapshot.activeGroupId ??
|
|
`${WEB_SESSION_GROUP_PREFIX}${snapshot.worktree}`
|
|
)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
function appendTabGroupLayout(
|
|
first: TabGroupLayoutNode | null,
|
|
second: TabGroupLayoutNode | null
|
|
): TabGroupLayoutNode | null {
|
|
if (!first) {
|
|
return second
|
|
}
|
|
if (!second) {
|
|
return first
|
|
}
|
|
return {
|
|
type: 'split',
|
|
direction: 'horizontal',
|
|
first,
|
|
second
|
|
}
|
|
}
|
|
|
|
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)
|
|
)
|
|
}
|
|
|
|
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]
|
|
)
|
|
}
|
|
|
|
function buildHostToLocalTabIdMap({
|
|
terminalSurfaces,
|
|
terminalTabs,
|
|
browserTabs,
|
|
editorTabs
|
|
}: {
|
|
terminalSurfaces: readonly TerminalSurface[]
|
|
terminalTabs: readonly TerminalTab[]
|
|
browserTabs: readonly MirroredBrowserTab[]
|
|
editorTabs: readonly MirroredEditorTab[]
|
|
}): 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)
|
|
}
|
|
return hostToLocal
|
|
}
|
|
|
|
function updateHostSessionTabIdMappings(args: {
|
|
environmentId: string
|
|
worktreeId: string
|
|
terminalSurfaces: readonly TerminalSurface[]
|
|
terminalTabs: readonly TerminalTab[]
|
|
browserTabs: readonly MirroredBrowserTab[]
|
|
editorTabs: readonly MirroredEditorTab[]
|
|
}): void {
|
|
const keyPrefix = `${args.environmentId}:${args.worktreeId}:`
|
|
for (const key of hostSessionTabIdByLocalKey.keys()) {
|
|
if (key.startsWith(keyPrefix)) {
|
|
hostSessionTabIdByLocalKey.delete(key)
|
|
}
|
|
}
|
|
|
|
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)) {
|
|
hostSessionTabIdByLocalKey.set(
|
|
hostSessionTabMappingKey({ ...args, tabId: localId }),
|
|
surface.parentTabId
|
|
)
|
|
}
|
|
}
|
|
for (const entry of args.browserTabs) {
|
|
hostSessionTabIdByLocalKey.set(
|
|
hostSessionTabMappingKey({ ...args, tabId: entry.unifiedTab.id }),
|
|
entry.hostTabId
|
|
)
|
|
}
|
|
for (const entry of args.editorTabs) {
|
|
hostSessionTabIdByLocalKey.set(
|
|
hostSessionTabMappingKey({ ...args, tabId: entry.unifiedTab.id }),
|
|
entry.hostTabId
|
|
)
|
|
}
|
|
}
|
|
|
|
function buildMirroredHostGroups({
|
|
currentGroups,
|
|
hostGroups,
|
|
hostToLocalTabId,
|
|
mirroredUnifiedIds,
|
|
nextActiveUnifiedTabId,
|
|
now,
|
|
validUnifiedTabIds,
|
|
worktreeId
|
|
}: {
|
|
currentGroups: readonly TabGroup[]
|
|
hostGroups: readonly RuntimeMobileSessionTabGroup[]
|
|
hostToLocalTabId: ReadonlyMap<string, string>
|
|
mirroredUnifiedIds: ReadonlySet<string>
|
|
nextActiveUnifiedTabId: string | null
|
|
now: number
|
|
validUnifiedTabIds: ReadonlySet<string>
|
|
worktreeId: string
|
|
}): TabGroup[] | null {
|
|
const strippedGroups = currentGroups.map((group) => {
|
|
const tabOrder = group.tabOrder.filter(
|
|
(tabId) => validUnifiedTabIds.has(tabId) && !mirroredUnifiedIds.has(tabId)
|
|
)
|
|
return {
|
|
...group,
|
|
tabOrder,
|
|
recentTabIds: sanitizeRecentTabIds(group.recentTabIds, tabOrder)
|
|
}
|
|
})
|
|
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))
|
|
const hostTabOrder = [
|
|
...(existing?.tabOrder.filter((tabId) => !localHostOrder.includes(tabId)) ?? []),
|
|
...localHostOrder
|
|
]
|
|
// Why: a pending client reorder for this group wins over a stale pre-move
|
|
// host order until the host echoes the move (or membership changes).
|
|
const tabOrder = resolveWebSessionReorderedOrder(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) {
|
|
orderedGroups.push(group)
|
|
}
|
|
}
|
|
|
|
return orderedGroups.length > 0 ? orderedGroups : null
|
|
}
|
|
|
|
function sameStringArray(a: readonly string[], b: readonly string[]): boolean {
|
|
if (a.length !== b.length) {
|
|
return false
|
|
}
|
|
return a.every((value, index) => value === b[index])
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|
|
|
|
function agentStatusEntryEqual(a: AgentStatusEntry | undefined, b: AgentStatusEntry): boolean {
|
|
if (!a) {
|
|
return false
|
|
}
|
|
return (
|
|
a.state === b.state &&
|
|
a.prompt === b.prompt &&
|
|
a.updatedAt === b.updatedAt &&
|
|
a.stateStartedAt === b.stateStartedAt &&
|
|
a.agentType === b.agentType &&
|
|
a.paneKey === b.paneKey &&
|
|
a.terminalTitle === b.terminalTitle &&
|
|
a.toolName === b.toolName &&
|
|
a.toolInput === b.toolInput &&
|
|
a.interactivePrompt === b.interactivePrompt &&
|
|
a.lastAssistantMessage === b.lastAssistantMessage &&
|
|
a.interrupted === b.interrupted &&
|
|
sameAgentStateHistory(a.stateHistory, b.stateHistory)
|
|
)
|
|
}
|
|
|
|
function isAgentStatusFresh(entry: Pick<AgentStatusEntry, 'updatedAt'>, now: number): boolean {
|
|
return now - entry.updatedAt <= AGENT_STATUS_STALE_AFTER_MS
|
|
}
|
|
|
|
function sameStringRecord(
|
|
a: Readonly<Record<string, string>> | undefined,
|
|
b: Readonly<Record<string, string>> | undefined
|
|
): boolean {
|
|
const left = a ?? {}
|
|
const right = b ?? {}
|
|
const leftKeys = Object.keys(left)
|
|
const rightKeys = Object.keys(right)
|
|
return (
|
|
leftKeys.length === rightKeys.length &&
|
|
leftKeys.every(
|
|
(key) => Object.prototype.hasOwnProperty.call(right, key) && left[key] === right[key]
|
|
)
|
|
)
|
|
}
|
|
|
|
function terminalLayoutNodeEqual(
|
|
a: TerminalPaneLayoutNode | null | undefined,
|
|
b: TerminalPaneLayoutNode | 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.leafId === b.leafId
|
|
}
|
|
return (
|
|
b.type === 'split' &&
|
|
a.direction === b.direction &&
|
|
a.ratio === b.ratio &&
|
|
terminalLayoutNodeEqual(a.first, b.first) &&
|
|
terminalLayoutNodeEqual(a.second, b.second)
|
|
)
|
|
}
|
|
|
|
function terminalLayoutEqual(
|
|
a: TerminalLayoutSnapshot | undefined,
|
|
b: TerminalLayoutSnapshot
|
|
): boolean {
|
|
return (
|
|
terminalLayoutNodeEqual(a?.root, b.root) &&
|
|
(a?.activeLeafId ?? null) === b.activeLeafId &&
|
|
(a?.expandedLeafId ?? null) === b.expandedLeafId &&
|
|
sameStringRecord(a?.ptyIdsByLeafId, b.ptyIdsByLeafId) &&
|
|
sameStringRecord(a?.buffersByLeafId, b.buffersByLeafId) &&
|
|
sameStringRecord(a?.scrollbackRefsByLeafId, b.scrollbackRefsByLeafId) &&
|
|
sameStringRecord(a?.titlesByLeafId, b.titlesByLeafId)
|
|
)
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
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]
|
|
}
|
|
|
|
function withWorktreeEntry<T>(
|
|
record: Record<string, T>,
|
|
key: string,
|
|
value: T | null,
|
|
equal: (a: T | undefined, b: T | null) => boolean
|
|
): Record<string, T> {
|
|
if (equal(record[key], value)) {
|
|
return record
|
|
}
|
|
const next = { ...record }
|
|
if (value === null) {
|
|
delete next[key]
|
|
} else {
|
|
next[key] = value
|
|
}
|
|
return next
|
|
}
|
|
|
|
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.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
|
|
)
|
|
}
|
|
|
|
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]!))
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|
|
|
|
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]!))
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|
|
|
|
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]!))
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|
|
|
|
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]!))
|
|
}
|
|
|
|
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.contentType === b.contentType &&
|
|
a.label === b.label &&
|
|
a.customLabel === b.customLabel &&
|
|
a.color === b.color &&
|
|
a.sortOrder === b.sortOrder &&
|
|
a.createdAt === b.createdAt &&
|
|
a.isPreview === b.isPreview &&
|
|
a.isPinned === b.isPinned
|
|
)
|
|
}
|
|
|
|
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]!))
|
|
}
|
|
|
|
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 ?? [])
|
|
)
|
|
}
|
|
|
|
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]!))
|
|
}
|
|
|
|
function toVisibleTabType(tab: Tab): WebSessionTabsSyncState['activeTabType'] {
|
|
if (tab.contentType === 'browser' || tab.contentType === 'terminal') {
|
|
return tab.contentType
|
|
}
|
|
return 'editor'
|
|
}
|
|
|
|
function findCurrentVisibleUnifiedTabId(args: {
|
|
state: WebSessionTabsSyncState
|
|
worktreeId: string
|
|
nextUnifiedTabs: readonly Tab[] | null
|
|
}): string | null {
|
|
const { state, worktreeId, nextUnifiedTabs } = args
|
|
if (!nextUnifiedTabs) {
|
|
return null
|
|
}
|
|
const currentVisibleType =
|
|
state.activeTabTypeByWorktree[worktreeId] ??
|
|
(state.activeWorktreeId === worktreeId ? state.activeTabType : null)
|
|
if (currentVisibleType === 'terminal') {
|
|
const terminalTabId = state.activeTabIdByWorktree[worktreeId]
|
|
return terminalTabId && nextUnifiedTabs.some((tab) => tab.id === terminalTabId)
|
|
? terminalTabId
|
|
: null
|
|
}
|
|
if (currentVisibleType === 'browser') {
|
|
const browserWorkspaceId = state.activeBrowserTabIdByWorktree[worktreeId]
|
|
return (
|
|
nextUnifiedTabs.find(
|
|
(tab) => tab.contentType === 'browser' && tab.entityId === browserWorkspaceId
|
|
)?.id ?? null
|
|
)
|
|
}
|
|
if (currentVisibleType === 'editor') {
|
|
const fileId = state.activeFileIdByWorktree[worktreeId]
|
|
return (
|
|
nextUnifiedTabs.find((tab) => tab.contentType === 'editor' && tab.entityId === fileId)?.id ??
|
|
null
|
|
)
|
|
}
|
|
return null
|
|
}
|
|
|
|
export function applyWebSessionTabsSnapshot(
|
|
state: WebSessionTabsSyncState,
|
|
rawSnapshot: RuntimeMobileSessionTabsResult,
|
|
environmentId: string,
|
|
now = Date.now()
|
|
): WebSessionTabsSyncState | Partial<WebSessionTabsSyncState> {
|
|
const worktreeId = rawSnapshot.worktree
|
|
if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
|
|
return state
|
|
}
|
|
// Why: a remote close prunes the local mirror immediately, but an in-flight
|
|
// pre-close snapshot can still list the tab and flash it back. Drop any tab
|
|
// the client is closing until the host confirms removal; reconcile the intents
|
|
// against the full snapshot first so confirmed (absent) closes clear.
|
|
// Why: must match the id the close path records as the intent. The close RPC
|
|
// resolves to the host session tab id (terminal parentTabId; otherwise tab.id)
|
|
// — keying browser on browserPageId instead never matched, so browser closes
|
|
// were never suppressed and reconcile cleared the intent on the still-present
|
|
// snapshot. The host also addresses tabs by session id, not page id.
|
|
const snapshotHostTabId = (tab: RuntimeMobileSessionTabsResult['tabs'][number]): string =>
|
|
tab.type === 'terminal' ? tab.parentTabId : tab.id
|
|
reconcileWebSessionCloseIntents(
|
|
worktreeId,
|
|
new Set(rawSnapshot.tabs.map((tab) => snapshotHostTabId(tab)))
|
|
)
|
|
const snapshot: RuntimeMobileSessionTabsResult = rawSnapshot.tabs.some((tab) =>
|
|
isWebSessionCloseIntentPending(worktreeId, snapshotHostTabId(tab), now)
|
|
)
|
|
? {
|
|
...rawSnapshot,
|
|
tabs: rawSnapshot.tabs.filter(
|
|
(tab) => !isWebSessionCloseIntentPending(worktreeId, snapshotHostTabId(tab), now)
|
|
)
|
|
}
|
|
: rawSnapshot
|
|
// Why: only follow the snapshot's active tab over the user's current focus when
|
|
// the client itself initiated this activation (a create/activate it recorded).
|
|
// An unsolicited server-active (e.g. an agent "thinking" echo) must not steal
|
|
// focus — that's the #5435 contract. Intent matches by the host active tab id
|
|
// (terminal session id, or browserPageId for browser tabs); consume it once.
|
|
const focusIntentHostTabId = peekWebSessionFocusIntent(worktreeId, now)
|
|
const honorSnapshotActiveFocus =
|
|
focusIntentHostTabId !== null &&
|
|
snapshot.activeTabId !== null &&
|
|
(snapshot.activeTabId === focusIntentHostTabId ||
|
|
snapshot.tabs.some(
|
|
(tab) =>
|
|
tab.id === snapshot.activeTabId &&
|
|
tab.type === 'browser' &&
|
|
tab.browserPageId === focusIntentHostTabId
|
|
))
|
|
if (honorSnapshotActiveFocus) {
|
|
clearWebSessionFocusIntent(worktreeId)
|
|
}
|
|
const currentTerminalTabs = state.tabsByWorktree[worktreeId] ?? []
|
|
const existingTerminalById = new Map(currentTerminalTabs.map((tab) => [tab.id, tab]))
|
|
const terminalSurfaceTabs = 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 nextMirroredLaunchAgents = new Set(
|
|
terminalSurfaceTabs
|
|
.map((tab) => tab.launchAgent)
|
|
.filter((agent): agent is TuiAgent => Boolean(agent))
|
|
)
|
|
const retainedTerminalTabs = currentTerminalTabs.filter(
|
|
(tab) =>
|
|
!shouldReplaceTerminalTab(
|
|
tab,
|
|
environmentId,
|
|
nextRemotePtyIds,
|
|
nextMirroredTerminalIds,
|
|
nextMirroredLaunchAgents
|
|
)
|
|
)
|
|
const mirroredTerminalTabs = buildMirroredTerminalTabs(
|
|
snapshot,
|
|
environmentId,
|
|
existingTerminalById,
|
|
state.terminalLayoutsByTabId,
|
|
retainedTerminalTabs.length,
|
|
now
|
|
)
|
|
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 targetGroupId = chooseTargetGroupId(state, snapshot)
|
|
const hostGroupIdByTabId = buildHostGroupIdByTabId(snapshot.tabGroups)
|
|
const readyBrowserTabs = 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(
|
|
currentBrowserTabs
|
|
.filter((tab) => {
|
|
if (mirroredBrowserWorkspaceIds.has(tab.id)) {
|
|
return true
|
|
}
|
|
if (!browserWorkspaceHasRemoteEnvironmentPage(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 = snapshot.tabs.filter(isReadyEditorTab)
|
|
const mirroredEditorTabs = buildMirroredEditorTabs(
|
|
snapshot,
|
|
environmentId,
|
|
state,
|
|
hostGroupIdByTabId,
|
|
targetGroupId,
|
|
mirroredTerminalTabEntries.length + mirroredBrowserTabs.length,
|
|
now
|
|
)
|
|
const mirroredEditorFileIds = new Set(mirroredEditorTabs.map((entry) => entry.file.id))
|
|
const mirroredEditorHostTabIds = new Set(mirroredEditorTabs.map((entry) => entry.hostTabId))
|
|
const removedEditorFileIds = new Set(
|
|
state.openFiles
|
|
.filter(
|
|
(file) =>
|
|
file.worktreeId === worktreeId &&
|
|
file.runtimeEnvironmentId === environmentId &&
|
|
(file.mode === 'edit' || file.mode === 'markdown-preview') &&
|
|
// Why: only cull tabs that came from the host mirror. Files the web
|
|
// user opened locally have no host counterpart, so a snapshot that
|
|
// omits them is not a signal to close them.
|
|
file.mirroredFromRuntimeSession === true &&
|
|
!mirroredEditorFileIds.has(file.id)
|
|
)
|
|
.map((file) => file.id)
|
|
)
|
|
const nextOpenFiles = (() => {
|
|
const retained = state.openFiles.filter(
|
|
(file) =>
|
|
!(
|
|
file.worktreeId === worktreeId &&
|
|
file.runtimeEnvironmentId === environmentId &&
|
|
(removedEditorFileIds.has(file.id) || mirroredEditorFileIds.has(file.id))
|
|
)
|
|
)
|
|
const next = [...retained, ...mirroredEditorTabs.map((entry) => entry.file)]
|
|
return sameOpenFiles(state.openFiles, next) ? state.openFiles : next
|
|
})()
|
|
const currentUnifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? []
|
|
const retainedUnifiedTabs = currentUnifiedTabs.filter((tab) => {
|
|
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)
|
|
)
|
|
const mirroredTerminalUnifiedTabs = mirroredTerminalTabs.map((entry) =>
|
|
buildTerminalUnifiedTab(
|
|
entry.tab,
|
|
hostGroupIdByTabId.get(entry.hostTabId) ?? targetGroupId,
|
|
// Prefer the reconciled mirrored value (already client-wins during the echo
|
|
// window); fall back to the captured client value when the entry omits it.
|
|
entry.tab.viewMode ?? existingViewModeByTabId.get(entry.tab.id)
|
|
)
|
|
)
|
|
const mirroredBrowserUnifiedTabs = mirroredBrowserTabs.map((entry) => entry.unifiedTab)
|
|
const mirroredEditorUnifiedTabs = mirroredEditorTabs.map((entry) => entry.unifiedTab)
|
|
const mirroredUnifiedTabs = [
|
|
...mirroredTerminalUnifiedTabs,
|
|
...mirroredBrowserUnifiedTabs,
|
|
...mirroredEditorUnifiedTabs
|
|
]
|
|
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 currentActiveTerminalStillExists =
|
|
state.activeTabIdByWorktree[worktreeId] &&
|
|
(nextTerminalTabs ?? []).some((tab) => tab.id === state.activeTabIdByWorktree[worktreeId])
|
|
? state.activeTabIdByWorktree[worktreeId]
|
|
: null
|
|
// Why: when the client initiated this activation (honorSnapshotActiveFocus),
|
|
// the snapshot's active terminal wins over the sticky current focus.
|
|
const intentTerminalId =
|
|
honorSnapshotActiveFocus && snapshot.activeTabType === 'terminal'
|
|
? activeMirroredTerminalId
|
|
: 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 && snapshot.activeTabType === 'browser'
|
|
? activeMirroredBrowserWorkspaceId
|
|
: null
|
|
const nextActiveBrowserWorkspaceId =
|
|
intentBrowserWorkspaceId ??
|
|
currentActiveBrowserStillExists ??
|
|
(snapshot.activeTabType === 'browser'
|
|
? (activeMirroredBrowserWorkspaceId ?? mirroredBrowserTabs[0]?.workspace.id)
|
|
: mirroredBrowserTabs[0]?.workspace.id) ??
|
|
null
|
|
const currentActiveEditorStillExists =
|
|
state.activeFileIdByWorktree[worktreeId] &&
|
|
nextOpenFiles.some(
|
|
(file) =>
|
|
file.worktreeId === worktreeId && file.id === state.activeFileIdByWorktree[worktreeId]
|
|
)
|
|
? state.activeFileIdByWorktree[worktreeId]
|
|
: null
|
|
const nextActiveEditorFileId =
|
|
currentActiveEditorStillExists ??
|
|
(snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file'
|
|
? (activeMirroredEditorFileId ?? mirroredEditorTabs[0]?.file.id)
|
|
: mirroredEditorTabs[0]?.file.id) ??
|
|
null
|
|
const currentVisibleUnifiedTabId = findCurrentVisibleUnifiedTabId({
|
|
state,
|
|
worktreeId,
|
|
nextUnifiedTabs
|
|
})
|
|
// Why: a client-initiated activation also drives the visible unified tab,
|
|
// overriding the sticky current-visible tab.
|
|
const intentUnifiedTabId = honorSnapshotActiveFocus
|
|
? snapshot.activeTabType === 'browser'
|
|
? activeMirroredBrowserTabId
|
|
: snapshot.activeTabType === 'terminal'
|
|
? intentTerminalId
|
|
: snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file'
|
|
? activeMirroredEditorTabId
|
|
: null
|
|
: null
|
|
const nextActiveUnifiedTabId =
|
|
intentUnifiedTabId ??
|
|
currentVisibleUnifiedTabId ??
|
|
(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
|
|
})
|
|
updateHostSessionTabIdMappings({
|
|
environmentId,
|
|
worktreeId,
|
|
terminalSurfaces: terminalSurfaceTabs,
|
|
terminalTabs: mirroredTerminalTabEntries,
|
|
browserTabs: mirroredBrowserTabs,
|
|
editorTabs: mirroredEditorTabs
|
|
})
|
|
|
|
const currentGroups = state.groupsByWorktree[worktreeId] ?? []
|
|
const nextGroups = (() => {
|
|
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,
|
|
worktreeId
|
|
})
|
|
}
|
|
const strippedGroups = currentGroups.map((group) => ({
|
|
...group,
|
|
tabOrder: group.tabOrder.filter(
|
|
(tabId) => validUnifiedTabIds.has(tabId) && !mirroredUnifiedIds.has(tabId)
|
|
),
|
|
recentTabIds: sanitizeRecentTabIds(
|
|
group.recentTabIds,
|
|
group.tabOrder.filter(
|
|
(tabId) => validUnifiedTabIds.has(tabId) && !mirroredUnifiedIds.has(tabId)
|
|
)
|
|
)
|
|
}))
|
|
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.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)
|
|
})()
|
|
|
|
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 push = (tabId: string): void => {
|
|
if (validTabBarIds.has(tabId) && !next.includes(tabId)) {
|
|
next.push(tabId)
|
|
}
|
|
}
|
|
// Why: remote snapshots can arrive after the client staged local browser
|
|
// tabs. Preserve the user's visible mixed order and only append new host
|
|
// tabs; otherwise terminal-browser-terminal can collapse to browser-terminal-terminal.
|
|
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
|
|
})()
|
|
|
|
let nextPtyIdsByTabId = state.ptyIdsByTabId
|
|
for (const removedId of removedTerminalIds) {
|
|
if (nextPtyIdsByTabId[removedId]) {
|
|
nextPtyIdsByTabId =
|
|
nextPtyIdsByTabId === state.ptyIdsByTabId ? { ...state.ptyIdsByTabId } : nextPtyIdsByTabId
|
|
delete nextPtyIdsByTabId[removedId]
|
|
}
|
|
}
|
|
for (const { tab, ptyIds } of mirroredTerminalTabs) {
|
|
const current = nextPtyIdsByTabId[tab.id] ?? []
|
|
if (!sameStringArray(current, ptyIds)) {
|
|
nextPtyIdsByTabId =
|
|
nextPtyIdsByTabId === state.ptyIdsByTabId ? { ...state.ptyIdsByTabId } : nextPtyIdsByTabId
|
|
nextPtyIdsByTabId[tab.id] = ptyIds
|
|
}
|
|
}
|
|
|
|
let nextTerminalLayoutsByTabId = state.terminalLayoutsByTabId
|
|
for (const removedId of removedTerminalIds) {
|
|
if (nextTerminalLayoutsByTabId[removedId]) {
|
|
nextTerminalLayoutsByTabId =
|
|
nextTerminalLayoutsByTabId === state.terminalLayoutsByTabId
|
|
? { ...state.terminalLayoutsByTabId }
|
|
: nextTerminalLayoutsByTabId
|
|
delete nextTerminalLayoutsByTabId[removedId]
|
|
}
|
|
}
|
|
for (const { tab, layout } of mirroredTerminalTabs) {
|
|
if (!terminalLayoutEqual(nextTerminalLayoutsByTabId[tab.id], layout)) {
|
|
nextTerminalLayoutsByTabId =
|
|
nextTerminalLayoutsByTabId === state.terminalLayoutsByTabId
|
|
? { ...state.terminalLayoutsByTabId }
|
|
: nextTerminalLayoutsByTabId
|
|
nextTerminalLayoutsByTabId[tab.id] = layout
|
|
}
|
|
}
|
|
|
|
let nextUnreadTerminalTabs = state.unreadTerminalTabs
|
|
for (const removedId of removedTerminalIds) {
|
|
if (nextUnreadTerminalTabs[removedId]) {
|
|
nextUnreadTerminalTabs =
|
|
nextUnreadTerminalTabs === state.unreadTerminalTabs
|
|
? { ...state.unreadTerminalTabs }
|
|
: nextUnreadTerminalTabs
|
|
delete nextUnreadTerminalTabs[removedId]
|
|
}
|
|
}
|
|
|
|
let nextBrowserPagesByWorkspace = state.browserPagesByWorkspace
|
|
let nextRemoteBrowserPageHandlesByPageId = state.remoteBrowserPageHandlesByPageId
|
|
for (const removedWorkspaceId of removedBrowserWorkspaceIds) {
|
|
const pages = nextBrowserPagesByWorkspace[removedWorkspaceId] ?? []
|
|
if (nextBrowserPagesByWorkspace[removedWorkspaceId]) {
|
|
nextBrowserPagesByWorkspace =
|
|
nextBrowserPagesByWorkspace === state.browserPagesByWorkspace
|
|
? { ...state.browserPagesByWorkspace }
|
|
: nextBrowserPagesByWorkspace
|
|
delete nextBrowserPagesByWorkspace[removedWorkspaceId]
|
|
}
|
|
for (const page of pages) {
|
|
if (nextRemoteBrowserPageHandlesByPageId[page.id]) {
|
|
nextRemoteBrowserPageHandlesByPageId =
|
|
nextRemoteBrowserPageHandlesByPageId === state.remoteBrowserPageHandlesByPageId
|
|
? { ...state.remoteBrowserPageHandlesByPageId }
|
|
: nextRemoteBrowserPageHandlesByPageId
|
|
delete nextRemoteBrowserPageHandlesByPageId[page.id]
|
|
}
|
|
}
|
|
}
|
|
for (const { page, remotePageId } of mirroredBrowserTabs) {
|
|
const current = nextBrowserPagesByWorkspace[page.workspaceId] ?? []
|
|
if (!sameBrowserPages(current, [page])) {
|
|
nextBrowserPagesByWorkspace =
|
|
nextBrowserPagesByWorkspace === state.browserPagesByWorkspace
|
|
? { ...state.browserPagesByWorkspace }
|
|
: nextBrowserPagesByWorkspace
|
|
nextBrowserPagesByWorkspace[page.workspaceId] = [page]
|
|
}
|
|
const currentHandle = nextRemoteBrowserPageHandlesByPageId[page.id]
|
|
if (
|
|
currentHandle?.environmentId !== environmentId ||
|
|
currentHandle.remotePageId !== remotePageId
|
|
) {
|
|
nextRemoteBrowserPageHandlesByPageId =
|
|
nextRemoteBrowserPageHandlesByPageId === state.remoteBrowserPageHandlesByPageId
|
|
? { ...state.remoteBrowserPageHandlesByPageId }
|
|
: nextRemoteBrowserPageHandlesByPageId
|
|
nextRemoteBrowserPageHandlesByPageId[page.id] = {
|
|
environmentId,
|
|
remotePageId
|
|
}
|
|
}
|
|
}
|
|
|
|
const nextTabsByWorktree = withWorktreeEntry(
|
|
state.tabsByWorktree,
|
|
worktreeId,
|
|
nextTerminalTabs,
|
|
sameTerminalTabs
|
|
)
|
|
const nextBrowserTabsByWorktree = withWorktreeEntry(
|
|
state.browserTabsByWorktree,
|
|
worktreeId,
|
|
nextBrowserTabs,
|
|
sameBrowserTabs
|
|
)
|
|
const nextUnifiedTabsByWorktree = withWorktreeEntry(
|
|
state.unifiedTabsByWorktree,
|
|
worktreeId,
|
|
nextUnifiedTabs,
|
|
sameUnifiedTabs
|
|
)
|
|
const nextGroupsByWorktree = withWorktreeEntry(
|
|
state.groupsByWorktree,
|
|
worktreeId,
|
|
nextGroups,
|
|
sameGroups
|
|
)
|
|
const nextActiveGroupId =
|
|
// Why: remote status/title snapshots carry the host's last active tab; a
|
|
// client that already switched panes must keep its local group focus.
|
|
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
|
|
? { ...state.activeGroupIdByWorktree, [worktreeId]: nextActiveGroupId ?? targetGroupId }
|
|
: state.activeGroupIdByWorktree
|
|
const nextLayoutByWorktree = (() => {
|
|
if (!nextGroups) {
|
|
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) {
|
|
return state.layoutByWorktree
|
|
}
|
|
if (tabGroupLayoutEqual(state.layoutByWorktree[worktreeId], fallbackLayout)) {
|
|
return state.layoutByWorktree
|
|
}
|
|
return {
|
|
...state.layoutByWorktree,
|
|
[worktreeId]: fallbackLayout
|
|
}
|
|
})()
|
|
const nextTabBarOrderByWorktree = withWorktreeEntry(
|
|
state.tabBarOrderByWorktree,
|
|
worktreeId,
|
|
nextTabBarOrder.length > 0 ? nextTabBarOrder : null,
|
|
(a, b) => sameStringArray(a ?? [], b ?? [])
|
|
)
|
|
const nextActiveTabIdByWorktree =
|
|
(state.activeTabIdByWorktree[worktreeId] ?? null) !== nextActiveTerminalId
|
|
? { ...state.activeTabIdByWorktree, [worktreeId]: nextActiveTerminalId }
|
|
: state.activeTabIdByWorktree
|
|
const nextActiveBrowserTabIdByWorktree =
|
|
(state.activeBrowserTabIdByWorktree[worktreeId] ?? null) !== nextActiveBrowserWorkspaceId
|
|
? { ...state.activeBrowserTabIdByWorktree, [worktreeId]: nextActiveBrowserWorkspaceId }
|
|
: state.activeBrowserTabIdByWorktree
|
|
const nextActiveFileIdByWorktree =
|
|
(state.activeFileIdByWorktree[worktreeId] ?? null) !== nextActiveEditorFileId
|
|
? { ...state.activeFileIdByWorktree, [worktreeId]: nextActiveEditorFileId }
|
|
: state.activeFileIdByWorktree
|
|
const isActiveWorktree = state.activeWorktreeId === worktreeId
|
|
const snapshotVisibleTabType =
|
|
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 =
|
|
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)
|
|
// Why: an empty/closed host snapshot has no active host tab, but the web
|
|
// client must not keep pointing global shortcuts at a removed browser/editor.
|
|
// A client-initiated activation (honorSnapshotActiveFocus) makes the snapshot's
|
|
// type win, so a create can switch the visible pane (e.g. terminal -> browser).
|
|
const nextVisibleTabType = honorSnapshotActiveFocus
|
|
? (snapshotVisibleTabType ?? currentVisibleTabTypeStillValid ?? fallbackVisibleTabType)
|
|
: (currentVisibleTabTypeStillValid ?? snapshotVisibleTabType ?? fallbackVisibleTabType)
|
|
const currentActiveTerminalStillValid =
|
|
state.activeTabId && (nextTerminalTabs ?? []).some((tab) => tab.id === state.activeTabId)
|
|
? state.activeTabId
|
|
: null
|
|
const currentActiveEditorStillValid =
|
|
state.activeFileId &&
|
|
nextOpenFiles.some((file) => file.worktreeId === worktreeId && file.id === state.activeFileId)
|
|
? state.activeFileId
|
|
: null
|
|
const nextActiveTabId = isActiveWorktree
|
|
? 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
|
|
? { ...state.activeTabTypeByWorktree, [worktreeId]: nextVisibleTabType }
|
|
: state.activeTabTypeByWorktree
|
|
const agentStatusPatch = buildMirroredAgentStatusPatch(
|
|
state,
|
|
currentTerminalTabs,
|
|
terminalSurfaceTabs,
|
|
now
|
|
)
|
|
|
|
const patch: Partial<WebSessionTabsSyncState> = {
|
|
...agentStatusPatch,
|
|
...(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 }
|
|
: {}),
|
|
...(nextBrowserPagesByWorkspace !== state.browserPagesByWorkspace
|
|
? { browserPagesByWorkspace: nextBrowserPagesByWorkspace }
|
|
: {}),
|
|
...(nextRemoteBrowserPageHandlesByPageId !== state.remoteBrowserPageHandlesByPageId
|
|
? { remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId }
|
|
: {}),
|
|
...(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
|
|
}
|
|
|
|
export function applyWebSessionTabsSnapshots(
|
|
state: WebSessionTabsSyncState,
|
|
snapshots: readonly RuntimeMobileSessionTabsResult[],
|
|
environmentId: string,
|
|
now = Date.now()
|
|
): WebSessionTabsSyncState | Partial<WebSessionTabsSyncState> {
|
|
let nextState = state
|
|
let mergedPatch: Partial<WebSessionTabsSyncState> = {}
|
|
for (const snapshot of snapshots) {
|
|
const patch = applyWebSessionTabsSnapshot(nextState, snapshot, environmentId, now)
|
|
if (patch === nextState) {
|
|
continue
|
|
}
|
|
mergedPatch = { ...mergedPatch, ...patch }
|
|
nextState = { ...nextState, ...patch }
|
|
}
|
|
return Object.keys(mergedPatch).length === 0 ? state : mergedPatch
|
|
}
|
|
|
|
export function applyFreshWebSessionTabsSnapshot(
|
|
state: WebSessionTabsSyncState,
|
|
snapshot: RuntimeMobileSessionTabsResult,
|
|
environmentId: string,
|
|
now = Date.now()
|
|
): WebSessionTabsSyncState | Partial<WebSessionTabsSyncState> {
|
|
if (!shouldApplyWebSessionTabsSnapshot(snapshot, environmentId)) {
|
|
return state
|
|
}
|
|
return applyWebSessionTabsSnapshot(state, snapshot, environmentId, now)
|
|
}
|
|
|
|
export function applyFreshWebSessionTabsSnapshots(
|
|
state: WebSessionTabsSyncState,
|
|
snapshots: readonly RuntimeMobileSessionTabsResult[],
|
|
environmentId: string,
|
|
now = Date.now()
|
|
): WebSessionTabsSyncState | Partial<WebSessionTabsSyncState> {
|
|
const freshSnapshots = snapshots.filter((snapshot) =>
|
|
shouldApplyWebSessionTabsSnapshot(snapshot, environmentId)
|
|
)
|
|
return freshSnapshots.length === 0
|
|
? state
|
|
: applyWebSessionTabsSnapshots(state, freshSnapshots, environmentId, now)
|
|
}
|
|
|
|
export function useWebSessionTabsSync(): void {
|
|
const activeWorktreeId = useAppStore((state) => state.activeWorktreeId)
|
|
const runtimeSessionMirrorEnvironmentKey = useAppStore((state) =>
|
|
getRuntimeSessionMirrorEnvironmentIds(state).join('\u0000')
|
|
)
|
|
const activeWorktreeRuntimeEnvironmentId = useAppStore((state) =>
|
|
getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId)
|
|
)
|
|
const workspaceSessionReady = useAppStore((state) => state.workspaceSessionReady)
|
|
|
|
useEffect(() => {
|
|
const environmentIds = runtimeSessionMirrorEnvironmentKey
|
|
? runtimeSessionMirrorEnvironmentKey.split('\u0000').filter((id) => id.trim())
|
|
: []
|
|
// Why: startup hydration writes browser-local session state; applying the
|
|
// host snapshot before that point gets clobbered and leaves the sidebar stale.
|
|
// Selectedness is not liveness: desktop and web clients both mirror the
|
|
// runtime's session bindings so background worktrees do not look asleep.
|
|
if (!workspaceSessionReady || environmentIds.length === 0) {
|
|
return
|
|
}
|
|
|
|
let disposed = false
|
|
const unsubscribes: (() => void)[] = []
|
|
// Why: the streaming RPC emits an initial snapshots event, but startup can
|
|
// render a paired web session before that event is applied. A one-shot
|
|
// fetch makes initial parity deterministic; the stream remains the live
|
|
// update path afterward.
|
|
for (const environmentId of environmentIds) {
|
|
if (
|
|
!shouldSyncAllRuntimeSessionTabs({
|
|
activeRuntimeEnvironmentId: environmentId,
|
|
workspaceSessionReady
|
|
})
|
|
) {
|
|
continue
|
|
}
|
|
void window.api.runtimeEnvironments
|
|
.call({
|
|
selector: environmentId,
|
|
method: 'session.tabs.listAll',
|
|
params: {},
|
|
timeoutMs: 15_000
|
|
})
|
|
.then((response: RuntimeRpcResponse<unknown>) => {
|
|
if (disposed) {
|
|
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
|
|
}
|
|
useAppStore.setState((state) =>
|
|
applyFreshWebSessionTabsSnapshots(state, result.snapshots, environmentId)
|
|
)
|
|
})
|
|
.catch((error) => {
|
|
if (!disposed) {
|
|
console.warn(
|
|
'[web-session-tabs-sync] failed to load initial session tabs:',
|
|
error instanceof Error ? error.message : String(error)
|
|
)
|
|
}
|
|
})
|
|
|
|
void window.api.runtimeEnvironments
|
|
.subscribe(
|
|
{
|
|
selector: environmentId,
|
|
method: 'session.tabs.subscribeAll',
|
|
params: {},
|
|
timeoutMs: 15_000
|
|
},
|
|
{
|
|
onResponse: (response: RuntimeRpcResponse<unknown>) => {
|
|
if (disposed) {
|
|
return
|
|
}
|
|
if (response.ok === false) {
|
|
console.warn(
|
|
'[web-session-tabs-sync] global subscription failed:',
|
|
response.error.message
|
|
)
|
|
return
|
|
}
|
|
const event = response.result as SessionTabsStreamEvent
|
|
const replayed = isRuntimeSubscriptionReplayResponse(response)
|
|
if (event.type === 'snapshots') {
|
|
if (replayed) {
|
|
for (const snapshot of event.snapshots) {
|
|
acceptReplayedWebSessionTabsSnapshot(environmentId, snapshot.worktree)
|
|
}
|
|
}
|
|
useAppStore.setState((state) =>
|
|
applyFreshWebSessionTabsSnapshots(state, event.snapshots, environmentId)
|
|
)
|
|
return
|
|
}
|
|
if (event.type !== 'snapshot' && event.type !== 'updated') {
|
|
return
|
|
}
|
|
if (replayed) {
|
|
acceptReplayedWebSessionTabsSnapshot(environmentId, event.worktree)
|
|
}
|
|
useAppStore.setState((state) =>
|
|
applyFreshWebSessionTabsSnapshot(state, event, environmentId)
|
|
)
|
|
},
|
|
onError: (error) => {
|
|
console.warn('[web-session-tabs-sync] global subscription error:', error.message)
|
|
}
|
|
}
|
|
)
|
|
.then((handle) => {
|
|
if (disposed) {
|
|
handle.unsubscribe()
|
|
return
|
|
}
|
|
unsubscribes.push(handle.unsubscribe)
|
|
})
|
|
.catch((error) => {
|
|
if (!disposed) {
|
|
console.warn(
|
|
'[web-session-tabs-sync] failed to subscribe globally:',
|
|
error instanceof Error ? error.message : String(error)
|
|
)
|
|
}
|
|
})
|
|
}
|
|
|
|
return () => {
|
|
disposed = true
|
|
for (const unsubscribe of unsubscribes) {
|
|
unsubscribe()
|
|
}
|
|
// Why: environment ids can churn as paired runtimes reconnect or switch;
|
|
// stale freshness/mapping entries should not live for the renderer lifetime.
|
|
for (const environmentId of environmentIds) {
|
|
clearWebSessionTabsTrackingForEnvironment(environmentId)
|
|
}
|
|
}
|
|
}, [runtimeSessionMirrorEnvironmentKey, workspaceSessionReady])
|
|
|
|
useEffect(() => {
|
|
const environmentId = activeWorktreeRuntimeEnvironmentId?.trim()
|
|
if (
|
|
!shouldSyncRuntimeSessionTabs({
|
|
activeWorktreeId,
|
|
activeWorktreeRuntimeEnvironmentId,
|
|
workspaceSessionReady
|
|
}) ||
|
|
!environmentId ||
|
|
!activeWorktreeId
|
|
) {
|
|
return
|
|
}
|
|
|
|
let disposed = false
|
|
let requestedInitialTerminal = false
|
|
let requestedRespawnAfterWake = false
|
|
let unsubscribe: (() => void) | null = null
|
|
void window.api.runtimeEnvironments
|
|
.subscribe(
|
|
{
|
|
selector: environmentId,
|
|
method: 'session.tabs.subscribe',
|
|
params: { worktree: toRuntimeWorktreeSelector(activeWorktreeId) },
|
|
timeoutMs: 15_000
|
|
},
|
|
{
|
|
onResponse: (response: RuntimeRpcResponse<unknown>) => {
|
|
if (disposed) {
|
|
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
|
|
}
|
|
if (isRuntimeSubscriptionReplayResponse(response)) {
|
|
acceptReplayedWebSessionTabsSnapshot(environmentId, event.worktree)
|
|
}
|
|
const fresh = shouldApplyWebSessionTabsSnapshot(event, environmentId)
|
|
const syncState = useAppStore.getState()
|
|
const localWorktreeTabs = syncState.tabsByWorktree[activeWorktreeId] ?? []
|
|
const localTerminalCount = localWorktreeTabs.length
|
|
const hasLiveLocalPty = localWorktreeTabs.some(
|
|
(tab) => (syncState.ptyIdsByTabId[tab.id] ?? []).length > 0
|
|
)
|
|
const shouldBootstrapInitialTerminal = shouldBootstrapInitialWebRuntimeTerminal({
|
|
event,
|
|
activeWorktreeId,
|
|
requestedInitialTerminal,
|
|
snapshotIsFresh: fresh,
|
|
localTerminalCount
|
|
})
|
|
const shouldRespawnAfterWake = shouldRespawnWebRuntimeTerminalAfterWake({
|
|
event,
|
|
activeWorktreeId,
|
|
requestedRespawnAfterWake,
|
|
snapshotIsFresh: fresh,
|
|
localTerminalCount,
|
|
hasLiveLocalPty,
|
|
skipWakeRespawn: shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId)
|
|
})
|
|
if (fresh) {
|
|
useAppStore.setState((state) =>
|
|
applyWebSessionTabsSnapshot(state, event, environmentId)
|
|
)
|
|
}
|
|
if (!disposed && shouldBootstrapInitialTerminal) {
|
|
requestedInitialTerminal = true
|
|
void createWebRuntimeSessionTerminal({
|
|
worktreeId: activeWorktreeId,
|
|
environmentId,
|
|
activate: true
|
|
})
|
|
} else if (
|
|
!disposed &&
|
|
shouldRespawnAfterWake &&
|
|
beginWebRuntimeWakeTerminalRespawn(activeWorktreeId)
|
|
) {
|
|
requestedRespawnAfterWake = true
|
|
// Why: wake recovery must recreate the terminal without changing
|
|
// selected worktree to avoid re-triggering activation churn.
|
|
void createWebRuntimeSessionTerminal({
|
|
worktreeId: activeWorktreeId,
|
|
environmentId,
|
|
activate: true,
|
|
selectWorktree: false
|
|
}).finally(() => {
|
|
endWebRuntimeWakeTerminalRespawn(activeWorktreeId)
|
|
})
|
|
}
|
|
},
|
|
onError: (error) => {
|
|
console.warn('[web-session-tabs-sync] subscription error:', error.message)
|
|
}
|
|
}
|
|
)
|
|
.then((handle) => {
|
|
if (disposed) {
|
|
handle.unsubscribe()
|
|
return
|
|
}
|
|
unsubscribe = handle.unsubscribe
|
|
})
|
|
.catch((error) => {
|
|
if (!disposed) {
|
|
console.warn(
|
|
'[web-session-tabs-sync] failed to subscribe:',
|
|
error instanceof Error ? error.message : String(error)
|
|
)
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
disposed = true
|
|
unsubscribe?.()
|
|
}
|
|
}, [activeWorktreeId, activeWorktreeRuntimeEnvironmentId, workspaceSessionReady])
|
|
}
|