diff --git a/src/renderer/src/runtime/browser-workspace-tab-close-census.test.ts b/src/renderer/src/runtime/browser-workspace-tab-close-census.test.ts index 05a0dd7123b..b2e53eab08e 100644 --- a/src/renderer/src/runtime/browser-workspace-tab-close-census.test.ts +++ b/src/renderer/src/runtime/browser-workspace-tab-close-census.test.ts @@ -129,15 +129,20 @@ const BROWSER_WORKSPACE_CLOSE_SITES: { why: 'Rolls back a client-staged row for a create that never reached a host page.' }, { - path: 'src/renderer/src/store/slices/browser.ts', + path: 'src/renderer/src/store/slices/browser/browser-close-actions.ts', closeBrowserTabMentions: 3, reasonCarryingCloseCalls: 0, planReasonForwardings: 0, routesThroughPlan: false, - why: - 'shutdownWorktreeBrowsers tears the whole worktree down; the slice is the seam itself. Same ' + - 'carve-out for parked chrome as the staging rollback, and page ids are unique so a later ' + - "page cannot pick up a dead one's entry." + why: 'Slice-owned shutdown tears down every browser row directly.' + }, + { + path: 'src/renderer/src/store/slices/browser/browser-slice-contract.ts', + closeBrowserTabMentions: 1, + reasonCarryingCloseCalls: 0, + planReasonForwardings: 0, + routesThroughPlan: false, + why: 'The extracted slice contract declares the close action consumed by browser controllers.' } ] diff --git a/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts b/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts index b50ed2ae795..3b2f6fad2d2 100644 --- a/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts +++ b/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts @@ -108,7 +108,7 @@ describe('host-session-mirror settle census', () => { // empty inventory, a non-empty inventory, and an empty inventory that // settles only after `terminal.list` says the host has no live PTY — // `0 === 0` is not host evidence (STA-5377). - 'runtime/web-session-tabs-sync.ts': { hydrated: 3, worktreeHydrated: 2 } + 'runtime/web-session-tabs-sync/mirror-settle.ts': { hydrated: 3, worktreeHydrated: 2 } }) }) @@ -140,9 +140,13 @@ describe('host-session-mirror settle census', () => { } } expect(callSites, SETTLE_RULE).toEqual({ - // initial listAll, visibility-resume repair, full inventory, global - // singular frame, scoped active frame. - 'runtime/web-session-tabs-sync.ts': 5, + // Initial listAll, visibility-resume repair, full inventory, global + // singular frame, and scoped active frame are owned by these extracted + // recovery/event modules. + 'runtime/web-session-tabs-sync/active-session-subscription.ts': 1, + 'runtime/web-session-tabs-sync/global-session-events.ts': 2, + 'runtime/web-session-tabs-sync/load-initial.ts': 1, + 'runtime/web-session-tabs-sync/visibility-resume-coordinator.ts': 1, // The eager post-create session.tabs.list refresh. 'runtime/web-runtime-session-snapshot.ts': 1, // The local structured-session inventory/subscription frame. @@ -188,10 +192,14 @@ describe('host-session-mirror settle census', () => { // Pinning the resolved names keeps the rule from going vacuous: a binding // this census misreads settles on some other identifier's invocation. expect(receiptBindings, SETTLE_RULE).toEqual({ - // Four hydration receipts (initial listAll, full inventory, scoped active - // frame patch, and its patchless twin) and three mirror ones - // (visibility-resume repair, global singular frame patch and patchless). - 'runtime/web-session-tabs-sync.ts': { settleHydration: 4, settleMirror: 3 }, + // Hydration and mirror receipts remain pinned by their extracted owners: + // global events own the three hydration completions, initial loading owns + // one, active subscription owns two mirror settles, and visibility resume + // owns one. + 'runtime/web-session-tabs-sync/active-session-subscription.ts': { settle: 2 }, + 'runtime/web-session-tabs-sync/global-session-events.ts': { settleHydration: 3 }, + 'runtime/web-session-tabs-sync/load-initial.ts': { settleHydration: 1 }, + 'runtime/web-session-tabs-sync/visibility-resume-coordinator.ts': { settle: 1 }, 'runtime/web-runtime-session-snapshot.ts': { settleMirror: 1 }, 'runtime/local-structured-session-tabs-sync.ts': { settleStructuredSessionMirror: 1 } }) @@ -207,7 +215,9 @@ describe('host-session-mirror settle census', () => { } expect(patchlessCounts, SETTLE_RULE).toEqual({ // The definition, the global singular frame, and the scoped active frame. - 'runtime/web-session-tabs-sync.ts': 3 + 'runtime/web-session-tabs-sync/active-session-subscription.ts': 1, + 'runtime/web-session-tabs-sync/global-session-events.ts': 1, + 'runtime/web-session-tabs-sync/mirror-settle.ts': 1 }) }) @@ -228,11 +238,11 @@ describe('host-session-mirror settle census', () => { // to say whether the frame is host evidence — the whole point of the pair. expect(settlingCounts, SETTLE_RULE).toEqual({ // The applied frame (its own patch) and the outranked one (the accepted view). - 'runtime/web-session-tabs-sync.ts': 2 + 'runtime/web-session-tabs-sync/tracking-decisions.ts': 2 }) expect(silentCounts, SETTLE_RULE).toEqual({ // The unmirrored frame: no accepted view of it ever reached the store. - 'runtime/web-session-tabs-sync.ts': 1 + 'runtime/web-session-tabs-sync/tracking-decisions.ts': 1 }) }) }) diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index e2154520cdb..631d4bfec18 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -1,277 +1,60 @@ -/* eslint-disable max-lines -- Why: runtime graph sync and mobile session-tab publication share the same injected renderer state and terminal registry. Keeping them together prevents a second store/registry reader from drifting. */ -import { - collectLeafIdsInOrder, - serializePaneTree, - normalizeTerminalLayoutSnapshot -} from '@/components/terminal-pane/layout-serialization' -import { warnTerminalLifecycleAnomaly } from '@/components/terminal-pane/terminal-lifecycle-diagnostics' -import { getEagerPtyBufferHandle } from '@/components/terminal-pane/pty-dispatcher' -import { - collectParkedTerminalWatcherPtyIds, - getParkedTerminalWatcherPaneIdsByPtyId -} from '@/components/terminal-pane/terminal-parked-watcher-registry' -import { createBrowserUuid } from '@/lib/browser-uuid' -import type { PaneManager } from '@/lib/pane-manager/pane-manager' -import { resolveLeafIdForManager } from '@/lib/pane-manager/pane-key-resolution' -import { getSystemPrefersDark, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' -import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization' import type { AppState } from '@/store/types' -import type { - RuntimeMobileSessionBrowserTab, - RuntimeMobileSessionFileTab, - RuntimeMobileSessionMarkdownTab, - RuntimeMobileSessionTabGroup, - RuntimeMobileSessionSnapshotTab, - RuntimeMobileTerminalTheme, - RuntimeMobileSessionTabsSnapshot, - RuntimeRendererSyncWindowGraph -} from '../../../shared/runtime-types' -import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' -import { isWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' -import { isClaudeManagementTitle } from '../../../shared/agent-detection' -import { parseWorkspaceKey } from '../../../shared/workspace-scope' -import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../shared/tab-types' -import type { - TerminalLayoutSnapshot, - TerminalPaneLayoutNode, - TerminalTab -} from '../../../shared/terminal-tab-types' -import { resolveTerminalTabTitle } from '../../../shared/tab-title-resolution' +import { resolveLeafIdForManager } from '@/lib/pane-manager/pane-key-resolution' import { - isNativeChatTabWideFallbackSafe, - nativeChatLaunchAgentForLeaf, - resolveNativeChatActiveLayoutLeafId -} from '../components/native-chat/native-chat-leaf-routing' + syncRuntimeGraph, + setTrailingGraphSyncScheduler +} from './sync-runtime-graph/graph-publication' +import { graphState, RUNTIME_GRAPH_SYNC_COALESCE_MS } from './sync-runtime-graph/graph-state' import { - getActiveTabNavOrder, - getGroupVisibleTabOrder, - type VisibleTabRef -} from '../components/tab-bar/group-tab-order' -import { resolveTerminalLayoutRoot } from './remote-terminal-layout-resolution' -import { parseRemoteRuntimePtyId } from './runtime-terminal-stream' -import { applyNativeChatLaunchDraftResolved } from './native-chat-launch-draft-runtime-resolution' + AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS, + buildRuntimeMobileAgentStatusProjectionForTests, + resetRuntimeMobileAgentStatusProjectionCacheForTests +} from './sync-runtime-graph/agent-status-projection' +import { + canSkipRuntimeMobileSessionSyncKeyBuild, + getRuntimeMobileSessionSyncKey, + runtimeMobileSessionSyncKeysEqual +} from './sync-runtime-graph/sync-key' +import { buildMobileSessionTabSnapshots } from './sync-runtime-graph/mobile-session-snapshots' +import type { RegisteredTerminalTab } from './sync-runtime-graph/types' -type RegisteredTerminalTab = { - tabId: string - worktreeId: string - getManager: () => PaneManager | null - getContainer: () => HTMLDivElement | null - getPtyIdForPane: (paneId: number) => string | null - getTabWideAgentHintLeafId: () => string | null +export type { RegisteredTerminalTab, RuntimeMobileSessionSyncKey } from './sync-runtime-graph/types' +export { + AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS, + buildRuntimeMobileAgentStatusProjectionForTests, + resetRuntimeMobileAgentStatusProjectionCacheForTests, + canSkipRuntimeMobileSessionSyncKeyBuild, + getRuntimeMobileSessionSyncKey, + runtimeMobileSessionSyncKeysEqual, + buildMobileSessionTabSnapshots } -type OpenFileByWorktreeAndId = Map> -type OpenFileIndexes = { - byWorktreeAndId: OpenFileByWorktreeAndId - idsByWorktree: Map -} -type FallbackEditorTabTarget = { - tabId: string - groupId: string | null -} -type TabsProjectionCacheEntry = { - tabs: NonNullable - worktreeIdJson: string - projection: string -} -type TabsProjectionCache = { - source: AppState['tabsByWorktree'] - entries: Map - projection: string -} -type AgentStatusProjectionCacheEntry = { - entry: AppState['agentStatusByPaneKey'][string] - projection: string -} -type AgentStatusProjectionCache = { - source: AppState['agentStatusByPaneKey'] - entries: Map - projection: string -} -type MobileSessionAgentStatusByWorktree = ReadonlyMap< - string, - ReadonlyMap -> -/** Slices shared by every worktree in one publication; derived from `AppState` exactly once. */ -type MobileSessionPublicationInputs = { - browserTabsByWorktree: AppState['browserTabsByWorktree'] - openFileIndexes: OpenFileIndexes - editorDraftVersionByFileId: ReadonlyMap - agentStatusByWorktreeId: MobileSessionAgentStatusByWorktree - generatedTitlesEnabled: boolean - terminalTheme: RuntimeMobileTerminalTheme | undefined -} -/** - * Live PaneManager/DOM reads for one mounted terminal tab, captured once per - * publication. - * - * Why: builders read this instead of the registry, so live state the memo - * cannot witness is unrepresentable — mounted worktrees memoize like the rest - * instead of rebuilding on every publication. - */ -type MountedTerminalSurfaceCapture = { - paneLeafIds: readonly string[] - hasLiveActivePane: boolean - liveActiveLeafId: string | null - liveLayoutRoot: TerminalPaneLayoutNode | null - numericPaneIdByLeafId: ReadonlyMap - ptyIdByNumericPaneId: ReadonlyMap - tabWideAgentHintLeafId: string | null -} -/** - * One worktree's complete mobile-snapshot input set. - * - * Why: every builder below takes this instead of `AppState`, so the compiler — - * not a reviewer — proves what a worktree's snapshot actually depends on. - */ -type MobileSessionWorktreeInputs = { - worktreeId: string - terminalTabs: AppState['tabsByWorktree'][string] - browserWorkspaces: AppState['browserTabsByWorktree'][string] - unifiedTabs: AppState['unifiedTabsByWorktree'][string] - groups: AppState['groupsByWorktree'][string] - tabBarOrder: AppState['tabBarOrderByWorktree'][string] | undefined - activeGroupId: string | null - tabGroupLayout: TabGroupLayoutNode | undefined - openFilesById: ReadonlyMap | undefined - openFileIds: readonly string[] - terminalLayoutByTabId: ReadonlyMap - paneTitlesByTabId: ReadonlyMap - launchDraftByPaneKey: ReadonlyMap< - string, - NonNullable[string] - > - agentStatusByPaneKey: ReadonlyMap - editorDraftVersionByFileId: ReadonlyMap - pagesByBrowserWorkspaceId: ReadonlyMap< - string, - NonNullable[string] - > - certificateFailureByBrowserPageId: ReadonlyMap< - string, - NonNullable[string] - > - activeEditorFileId: string | null - activeEditorTabType: AppState['activeTabType'] | null - activeTerminalTabId: string | null - activeBrowserWorkspaceId: string | null - generatedTitlesEnabled: boolean - terminalTheme: RuntimeMobileTerminalTheme | undefined - mountedSurfaceCaptureByTabId: ReadonlyMap -} - -const registeredTabs = new Map() -// Why: registration time suppresses the "no live transport" warning during the async PTY-connect window; after the grace period it's a real stuck state. -const tabRegisteredAt = new Map() -const NO_TRANSPORT_GRACE_MS = 10_000 -const EMPTY_ACTIVE_BROWSER_TAB_ID_BY_WORKTREE: AppState['activeBrowserTabIdByWorktree'] = {} -const EMPTY_BROWSER_TABS_BY_WORKTREE: AppState['browserTabsByWorktree'] = {} -const EMPTY_BROWSER_PAGES_BY_WORKSPACE: AppState['browserPagesByWorkspace'] = {} -const EMPTY_LAYOUT_BY_WORKTREE: AppState['layoutByWorktree'] = {} -const EMPTY_AGENT_STATUS_BY_PANE_KEY: AppState['agentStatusByPaneKey'] = {} -const AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS = 30_000 -const RUNTIME_GRAPH_SYNC_COALESCE_MS = 16 -let syncScheduled = false -let syncInFlight = false -let syncPendingAfterFlight = false -let syncEnabled = false -let syncTimer: ReturnType | null = null -let getStoreState: (() => AppState) | null = null -let mobileSessionSnapshotVersion = 0 -// Why: main gates per-worktree mobile fanout on (publicationEpoch, -// snapshotVersion), so that pair must be a semantic revision: reuse the cached -// snapshot (same version) whenever a worktree's mobile-visible content is -// unchanged, and bump the version only for worktrees that actually changed. -const mobileSessionSnapshotCacheByWorktree = new Map< - string, - { - inputs: MobileSessionWorktreeInputs - content: unknown - snapshot: RuntimeMobileSessionTabsSnapshot - } ->() - -// Structural equality under JSON-serialization semantics (undefined-valued -// keys are absent), so version reuse matches a JSON fingerprint exactly -// without allocating a serialized copy of the payload on every graph sync. -// Any value strict-equality can't prove equal (e.g. NaN) reads as changed, -// which only costs a redundant fanout — never a suppressed one. -function jsonContentEquals(a: unknown, b: unknown): boolean { - if (a === b) { - return true - } - if (Array.isArray(a) || Array.isArray(b)) { - if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { - return false - } - return a.every((item, index) => jsonContentEquals(item, b[index])) - } - if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) { - return false - } - const aRecord = a as Record - const bRecord = b as Record - for (const key of Object.keys(aRecord)) { - if (!jsonContentEquals(aRecord[key], bRecord[key])) { - return false - } - } - for (const key of Object.keys(bRecord)) { - if (bRecord[key] !== undefined && aRecord[key] === undefined) { - return false - } - } - return true -} -let cachedTabsProjection: TabsProjectionCache | null = null -let cachedAgentStatusProjection: AgentStatusProjectionCache | null = null -let cachedOpenFileIndexesSource: AppState['openFiles'] | null = null -let cachedOpenFileIndexes: OpenFileIndexes | null = null -let cachedEditorDraftsSource: AppState['editorDrafts'] | null = null -let cachedEditorDraftVersionByFileId: Map | null = null -let cachedMobileTerminalThemeSettings: AppState['settings'] | null = null -let cachedMobileTerminalThemeSystemPrefersDark: boolean | null = null -let cachedMobileTerminalTheme: RuntimeMobileTerminalTheme | undefined -let hasCachedMobileTerminalTheme = false -const EMPTY_NARROWED_BY_KEY: ReadonlyMap = new Map() -// Why: absent per-worktree slices must resolve to one shared value, or every empty -// worktree would present a fresh `[]` and never compare equal to its last publication. -const EMPTY_WORKTREE_TERMINAL_TABS: AppState['tabsByWorktree'][string] = [] -const EMPTY_WORKTREE_BROWSER_WORKSPACES: AppState['browserTabsByWorktree'][string] = [] -const EMPTY_WORKTREE_UNIFIED_TABS: AppState['unifiedTabsByWorktree'][string] = [] -const EMPTY_WORKTREE_TAB_GROUPS: AppState['groupsByWorktree'][string] = [] -const EMPTY_WORKTREE_OPEN_FILE_IDS: readonly string[] = [] -const mobileSessionPublicationEpoch = `renderer:${createBrowserUuid()}` -// Why: the snapshot object main last acknowledged per worktree; anything still -// identical is withheld from the graph payload instead of re-cloned across IPC. -const publishedMobileSessionSnapshotByWorktree = new Map() - export function setRuntimeGraphStoreStateGetter(getter: (() => AppState) | null): void { - getStoreState = getter + graphState.getStoreState = getter } /** True while a TerminalPane for this tab is mounted (lifecycle effect ran). */ export function hasRegisteredRuntimeTerminalTab(tabId: string): boolean { - return registeredTabs.has(tabId) + return graphState.registeredTabs.has(tabId) } export function registerRuntimeTerminalTab(tab: RegisteredTerminalTab): () => void { - registeredTabs.set(tab.tabId, tab) - tabRegisteredAt.set(tab.tabId, Date.now()) + graphState.registeredTabs.set(tab.tabId, tab) + graphState.tabRegisteredAt.set(tab.tabId, Date.now()) scheduleRuntimeGraphSync() return () => { - // Why: React can mount a replacement surface before the prior effect cleans up; stale cleanup must not erase the successor's registry. - if (registeredTabs.get(tab.tabId) !== tab) { + // React can mount a replacement before the old effect cleans up. + if (graphState.registeredTabs.get(tab.tabId) !== tab) { return } - registeredTabs.delete(tab.tabId) - tabRegisteredAt.delete(tab.tabId) + graphState.registeredTabs.delete(tab.tabId) + graphState.tabRegisteredAt.delete(tab.tabId) scheduleRuntimeGraphSync() } } export function focusRuntimeTerminalSurface(tabId: string, leafId?: string | null): boolean { - const registered = registeredTabs.get(tabId) + const registered = graphState.registeredTabs.get(tabId) const manager = registered?.getManager() if (!manager) { return false @@ -290,9 +73,9 @@ export function focusRuntimeTerminalSurface(tabId: string, leafId?: string | nul } export function setRuntimeGraphSyncEnabled(enabled: boolean): void { - syncEnabled = enabled + graphState.syncEnabled = enabled if (!enabled) { - syncPendingAfterFlight = false + graphState.syncPendingAfterFlight = false clearScheduledRuntimeGraphSync() return } @@ -300,1885 +83,45 @@ export function setRuntimeGraphSyncEnabled(enabled: boolean): void { } function clearScheduledRuntimeGraphSync(): void { - if (syncTimer !== null) { - clearTimeout(syncTimer) - syncTimer = null + if (graphState.syncTimer !== null) { + clearTimeout(graphState.syncTimer) + graphState.syncTimer = null } - syncScheduled = false + graphState.syncScheduled = false } export function scheduleRuntimeGraphSync(): void { - if (!syncEnabled || syncScheduled) { + if (!graphState.syncEnabled || graphState.syncScheduled) { return } - if (syncInFlight) { - syncPendingAfterFlight = true + if (graphState.syncInFlight) { + graphState.syncPendingAfterFlight = true return } - syncScheduled = true - // Why: a frame-sized timer collapses separate title/status IPC tasks into one graph publish without tying publication to paint frames. - syncTimer = setTimeout(() => { - syncTimer = null - syncScheduled = false + graphState.syncScheduled = true + // Collapse separate title/status tasks into one frame-sized graph publication. + graphState.syncTimer = setTimeout(() => { + graphState.syncTimer = null + graphState.syncScheduled = false void runRuntimeGraphSync() }, RUNTIME_GRAPH_SYNC_COALESCE_MS) } async function runRuntimeGraphSync(): Promise { - if (syncInFlight) { - syncPendingAfterFlight = true + if (graphState.syncInFlight) { + graphState.syncPendingAfterFlight = true return } - syncInFlight = true + graphState.syncInFlight = true try { await syncRuntimeGraph() } finally { - syncInFlight = false - if (syncPendingAfterFlight) { - syncPendingAfterFlight = false - // Why: coalesce updates that arrived during one in-flight sync into a single trailing graph instead of stacking concurrent IPC calls. + graphState.syncInFlight = false + if (graphState.syncPendingAfterFlight) { + graphState.syncPendingAfterFlight = false scheduleRuntimeGraphSync() } } } -export type RuntimeMobileSessionSyncKey = { - // Why: reference changes signal layout/title updates without stringifying thousands of tabs. - terminalLayoutsByTabId: AppState['terminalLayoutsByTabId'] - runtimePaneTitlesByTabId: AppState['runtimePaneTitlesByTabId'] - nativeChatLaunchDraftByTabId: AppState['nativeChatLaunchDraftByTabId'] - folderWorkspaces: AppState['folderWorkspaces'] - groupsByWorktree: AppState['groupsByWorktree'] - activeGroupIdByWorktree: AppState['activeGroupIdByWorktree'] - layoutByWorktree: AppState['layoutByWorktree'] - unifiedTabsByWorktree: AppState['unifiedTabsByWorktree'] - tabBarOrderByWorktree: AppState['tabBarOrderByWorktree'] - activeFileId: AppState['activeFileId'] - activeFileIdByWorktree: AppState['activeFileIdByWorktree'] - activeTabType: AppState['activeTabType'] - activeTabTypeByWorktree: AppState['activeTabTypeByWorktree'] - activeTabId: AppState['activeTabId'] - activeBrowserTabIdByWorktree: AppState['activeBrowserTabIdByWorktree'] - agentStatusEpoch: number - agentStatusProjection: string - generatedTabTitlesEnabled: boolean - systemPrefersDark: boolean | null - terminalThemeProjection: string - // Why: underlying refs churn even when the mobile shape is unchanged (tabsByWorktree reallocates per OSC title frame); pre-serialize. - tabsProjection: string - openFilesProjection: string - browserProjection: string - editorDraftsProjection: string -} - -export function canSkipRuntimeMobileSessionSyncKeyBuild( - state: AppState, - previousState: AppState, - systemPrefersDark?: boolean, - previousSystemPrefersDark: boolean | null | undefined = systemPrefersDark -): boolean { - const terminalThemeSystemPrefersDark = getTerminalThemeSystemPrefersDark(state, systemPrefersDark) - const previousTerminalThemeSystemPrefersDark = getTerminalThemeSystemPrefersDark( - previousState, - previousSystemPrefersDark - ) - return ( - terminalThemeSystemPrefersDark === previousTerminalThemeSystemPrefersDark && - state.tabsByWorktree === previousState.tabsByWorktree && - state.groupsByWorktree === previousState.groupsByWorktree && - state.activeGroupIdByWorktree === previousState.activeGroupIdByWorktree && - state.layoutByWorktree === previousState.layoutByWorktree && - state.unifiedTabsByWorktree === previousState.unifiedTabsByWorktree && - state.tabBarOrderByWorktree === previousState.tabBarOrderByWorktree && - state.activeFileId === previousState.activeFileId && - state.activeFileIdByWorktree === previousState.activeFileIdByWorktree && - state.activeTabType === previousState.activeTabType && - state.activeTabTypeByWorktree === previousState.activeTabTypeByWorktree && - state.browserTabsByWorktree === previousState.browserTabsByWorktree && - state.browserPagesByWorkspace === previousState.browserPagesByWorkspace && - state.activeBrowserTabIdByWorktree === previousState.activeBrowserTabIdByWorktree && - state.openFiles === previousState.openFiles && - state.editorDrafts === previousState.editorDrafts && - state.settings === previousState.settings && - state.activeTabId === previousState.activeTabId && - state.terminalLayoutsByTabId === previousState.terminalLayoutsByTabId && - state.runtimePaneTitlesByTabId === previousState.runtimePaneTitlesByTabId && - state.nativeChatLaunchDraftByTabId === previousState.nativeChatLaunchDraftByTabId && - state.folderWorkspaces === previousState.folderWorkspaces && - state.agentStatusEpoch === previousState.agentStatusEpoch && - state.agentStatusByPaneKey === previousState.agentStatusByPaneKey - ) -} - -function getTerminalThemeSystemPrefersDark( - state: Pick, - systemPrefersDark: boolean | null | undefined -): boolean | null { - return state.settings?.theme === 'system' ? (systemPrefersDark ?? null) : null -} - -export function getRuntimeMobileSessionSyncKey( - state: AppState, - previousState?: AppState, - previousKey?: RuntimeMobileSessionSyncKey, - systemPrefersDark = getSystemPrefersDark() -): RuntimeMobileSessionSyncKey { - const canReusePrevious = previousState !== undefined && previousKey !== undefined - const terminalThemeSystemPrefersDark = getTerminalThemeSystemPrefersDark(state, systemPrefersDark) - const browserTabsByWorktree = getBrowserTabsByWorktree(state) - const browserPagesByWorkspace = getBrowserPagesByWorkspace(state) - const agentStatusByPaneKey = state.agentStatusByPaneKey ?? EMPTY_AGENT_STATUS_BY_PANE_KEY - const previousBrowserTabsByWorktree = previousState - ? getBrowserTabsByWorktree(previousState) - : EMPTY_BROWSER_TABS_BY_WORKTREE - const previousBrowserPagesByWorkspace = previousState - ? getBrowserPagesByWorkspace(previousState) - : EMPTY_BROWSER_PAGES_BY_WORKSPACE - const previousAgentStatusByPaneKey = previousState - ? (previousState.agentStatusByPaneKey ?? EMPTY_AGENT_STATUS_BY_PANE_KEY) - : EMPTY_AGENT_STATUS_BY_PANE_KEY - - return { - terminalLayoutsByTabId: state.terminalLayoutsByTabId, - runtimePaneTitlesByTabId: state.runtimePaneTitlesByTabId, - nativeChatLaunchDraftByTabId: state.nativeChatLaunchDraftByTabId, - folderWorkspaces: state.folderWorkspaces, - groupsByWorktree: state.groupsByWorktree, - activeGroupIdByWorktree: state.activeGroupIdByWorktree, - layoutByWorktree: state.layoutByWorktree ?? EMPTY_LAYOUT_BY_WORKTREE, - unifiedTabsByWorktree: state.unifiedTabsByWorktree, - tabBarOrderByWorktree: state.tabBarOrderByWorktree, - activeFileId: state.activeFileId, - activeFileIdByWorktree: state.activeFileIdByWorktree, - activeTabType: state.activeTabType, - activeTabTypeByWorktree: state.activeTabTypeByWorktree, - activeTabId: state.activeTabId, - activeBrowserTabIdByWorktree: - state.activeBrowserTabIdByWorktree ?? EMPTY_ACTIVE_BROWSER_TAB_ID_BY_WORKTREE, - // Why: epoch covers sort/retention/freshness changes; projection covers prompt/tool details, skipping timestamp-only heartbeats. - agentStatusEpoch: state.agentStatusEpoch ?? 0, - agentStatusProjection: - canReusePrevious && agentStatusByPaneKey === previousAgentStatusByPaneKey - ? previousKey.agentStatusProjection - : buildRuntimeMobileAgentStatusProjection(agentStatusByPaneKey), - generatedTabTitlesEnabled: state.settings?.tabAutoGenerateTitle === true, - systemPrefersDark: terminalThemeSystemPrefersDark, - terminalThemeProjection: - canReusePrevious && - state.settings === previousState.settings && - previousKey.systemPrefersDark === terminalThemeSystemPrefersDark - ? previousKey.terminalThemeProjection - : JSON.stringify(resolveMobileTerminalTheme(state, systemPrefersDark) ?? null), - // Why: background title ticks churn many times/sec; reuse unchanged projections so they don't rescan all tabs, files, and drafts. - tabsProjection: - canReusePrevious && state.tabsByWorktree === previousState.tabsByWorktree - ? previousKey.tabsProjection - : buildRuntimeMobileTabsProjection(state.tabsByWorktree), - openFilesProjection: - canReusePrevious && state.openFiles === previousState.openFiles - ? previousKey.openFilesProjection - : buildRuntimeMobileOpenFilesProjection(state.openFiles), - browserProjection: - canReusePrevious && - browserTabsByWorktree === previousBrowserTabsByWorktree && - browserPagesByWorkspace === previousBrowserPagesByWorkspace - ? previousKey.browserProjection - : buildRuntimeMobileBrowserProjection(state), - editorDraftsProjection: - canReusePrevious && state.editorDrafts === previousState.editorDrafts - ? previousKey.editorDraftsProjection - : buildRuntimeMobileEditorDraftsProjection(state.editorDrafts) - } -} - -function getBrowserTabsByWorktree(state: AppState): AppState['browserTabsByWorktree'] { - // Why: some callers/tests build partial pre-browser states; treat missing browser slices as no tabs. - return state.browserTabsByWorktree ?? EMPTY_BROWSER_TABS_BY_WORKTREE -} - -function getBrowserPagesByWorkspace(state: AppState): AppState['browserPagesByWorkspace'] { - return state.browserPagesByWorkspace ?? EMPTY_BROWSER_PAGES_BY_WORKSPACE -} - -function buildRuntimeMobileTabsProjection(tabsByWorktree: AppState['tabsByWorktree']): string { - if (cachedTabsProjection?.source === tabsByWorktree) { - return cachedTabsProjection.projection - } - - const previousEntries = cachedTabsProjection?.entries - const entries = new Map() - const parts: string[] = [] - - for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { - const previous = previousEntries?.get(worktreeId) - const entry = - previous?.tabs === tabs - ? previous - : { - tabs, - worktreeIdJson: previous?.worktreeIdJson ?? JSON.stringify(worktreeId), - projection: JSON.stringify( - tabs.map((tab) => ({ - id: tab.id, - title: tab.title, - quickCommandLabel: tab.quickCommandLabel, - aiVaultTitle: tab.aiVaultTitle, - generatedTitle: tab.generatedTitle, - customTitle: tab.customTitle, - launchAgent: tab.launchAgent - })) - ) - } - entries.set(worktreeId, entry) - parts.push(`${entry.worktreeIdJson}:${entry.projection}`) - } - - cachedTabsProjection = { - source: tabsByWorktree, - entries, - projection: `{${parts.join(',')}}` - } - return cachedTabsProjection.projection -} - -function resolveRuntimeTerminalTitle( - tab: Pick< - TerminalTab, - 'customTitle' | 'quickCommandLabel' | 'aiVaultTitle' | 'generatedTitle' | 'title' - >, - generatedTitlesEnabled: boolean, - liveTitle = tab.title -): string { - return resolveTerminalTabTitle({ ...tab, title: liveTitle }, generatedTitlesEnabled, liveTitle) -} - -function buildRuntimeMobileOpenFilesProjection(openFiles: AppState['openFiles']): string { - return JSON.stringify( - openFiles.map((file) => ({ - id: file.id, - filePath: file.filePath, - relativePath: file.relativePath, - worktreeId: file.worktreeId, - language: file.language, - mode: file.mode, - diffSource: file.diffSource, - isDirty: file.isDirty, - isUntitled: file.isUntitled, - deleteUntouchedOnClose: file.deleteUntouchedOnClose, - markdownPreviewSourceFileId: file.markdownPreviewSourceFileId - })) - ) -} - -function buildRuntimeMobileBrowserProjection(state: AppState): string { - const browserTabsByWorktree = getBrowserTabsByWorktree(state) - const browserPagesByWorkspace = getBrowserPagesByWorkspace(state) - return JSON.stringify({ - workspacesByWorktree: Object.fromEntries( - Object.entries(browserTabsByWorktree).map(([worktreeId, workspaces]) => [ - worktreeId, - workspaces.map((workspace) => ({ - id: workspace.id, - activePageId: workspace.activePageId, - title: workspace.title, - url: workspace.url, - loading: workspace.loading, - canGoBack: workspace.canGoBack, - canGoForward: workspace.canGoForward - })) - ]) - ), - pagesByWorkspace: Object.fromEntries( - Object.entries(browserPagesByWorkspace).map(([workspaceId, pages]) => [ - workspaceId, - pages.map((page) => ({ - id: page.id, - title: page.title, - url: page.url, - loading: page.loading, - canGoBack: page.canGoBack, - canGoForward: page.canGoForward - })) - ]) - ) - }) -} - -function buildRuntimeMobileEditorDraftsProjection(editorDrafts: AppState['editorDrafts']): string { - return JSON.stringify( - Object.fromEntries( - Object.entries(editorDrafts).map(([fileId, content]) => [fileId, stableHashString(content)]) - ) - ) -} - -function serializeRuntimeMobileAgentStatusEntry( - paneKey: string, - entry: AppState['agentStatusByPaneKey'][string] -): string { - return JSON.stringify({ - paneKey, - entryPaneKey: entry.paneKey, - state: entry.state, - workingMode: entry.workingMode ?? null, - prompt: entry.prompt, - updatedAtBucket: Math.floor(entry.updatedAt / AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS), - stateStartedAt: entry.stateStartedAt, - agentType: entry.agentType ?? null, - terminalTitle: entry.terminalTitle ?? null, - stateHistory: entry.stateHistory.map((history) => ({ - state: history.state, - prompt: history.prompt, - startedAt: history.startedAt, - interrupted: history.interrupted ?? null - })), - toolName: entry.toolName ?? null, - toolInput: entry.toolInput ?? null, - // Why: include so a newly-captured AskUserQuestion prompt re-fires the mobile republish even when no other field changed. - interactivePrompt: entry.interactivePrompt ?? null, - lastAssistantMessage: entry.lastAssistantMessage ?? null, - interrupted: entry.interrupted ?? null - }) -} - -function buildRuntimeMobileAgentStatusProjection( - agentStatusByPaneKey: AppState['agentStatusByPaneKey'] -): string { - if (cachedAgentStatusProjection?.source === agentStatusByPaneKey) { - return cachedAgentStatusProjection.projection - } - - // Why per-entry: a status ping replaces one entry and re-spreads the map, so - // without this every other live agent — each carrying a 20-entry history and an - // 8 KB message — is re-serialized to discover it did not change. - const previousEntries = cachedAgentStatusProjection?.entries - const entries = new Map() - const parts: string[] = [] - - for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey).sort(([a], [b]) => - a.localeCompare(b) - )) { - const previous = previousEntries?.get(paneKey) - const cached = - previous?.entry === entry - ? previous - : { entry, projection: serializeRuntimeMobileAgentStatusEntry(paneKey, entry) } - entries.set(paneKey, cached) - parts.push(cached.projection) - } - - const projection = `[${parts.join(',')}]` - cachedAgentStatusProjection = { source: agentStatusByPaneKey, entries, projection } - return projection -} - -export function buildRuntimeMobileAgentStatusProjectionForTests( - agentStatusByPaneKey: AppState['agentStatusByPaneKey'] -): string { - return buildRuntimeMobileAgentStatusProjection(agentStatusByPaneKey) -} - -export const AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS = - AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS - -export function resetRuntimeMobileAgentStatusProjectionCacheForTests(): void { - cachedAgentStatusProjection = null -} - -export function runtimeMobileSessionSyncKeysEqual( - a: RuntimeMobileSessionSyncKey, - b: RuntimeMobileSessionSyncKey -): boolean { - return ( - a.terminalLayoutsByTabId === b.terminalLayoutsByTabId && - a.runtimePaneTitlesByTabId === b.runtimePaneTitlesByTabId && - a.nativeChatLaunchDraftByTabId === b.nativeChatLaunchDraftByTabId && - a.folderWorkspaces === b.folderWorkspaces && - a.groupsByWorktree === b.groupsByWorktree && - a.activeGroupIdByWorktree === b.activeGroupIdByWorktree && - a.layoutByWorktree === b.layoutByWorktree && - a.unifiedTabsByWorktree === b.unifiedTabsByWorktree && - a.tabBarOrderByWorktree === b.tabBarOrderByWorktree && - a.activeFileId === b.activeFileId && - a.activeFileIdByWorktree === b.activeFileIdByWorktree && - a.activeTabType === b.activeTabType && - a.activeTabTypeByWorktree === b.activeTabTypeByWorktree && - a.activeTabId === b.activeTabId && - a.activeBrowserTabIdByWorktree === b.activeBrowserTabIdByWorktree && - a.agentStatusEpoch === b.agentStatusEpoch && - a.agentStatusProjection === b.agentStatusProjection && - a.generatedTabTitlesEnabled === b.generatedTabTitlesEnabled && - a.systemPrefersDark === b.systemPrefersDark && - a.terminalThemeProjection === b.terminalThemeProjection && - a.tabsProjection === b.tabsProjection && - a.openFilesProjection === b.openFilesProjection && - a.browserProjection === b.browserProjection && - a.editorDraftsProjection === b.editorDraftsProjection - ) -} - -async function syncRuntimeGraph(): Promise { - if (!syncEnabled || !getStoreState) { - return - } - // Why: can't import the store directly (terminal slice imports this module); inject the getter to break the construction cycle. - const state = getStoreState() - const systemPrefersDark = getSystemPrefersDark() - // Why: build lookup maps once per sync instead of re-flattening every worktree's tabs for each registered terminal. - const terminalTabById = new Map( - Object.values(state.tabsByWorktree) - .flat() - .map((tab) => [tab.id, tab]) - ) - const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true - const mobileSessionTabs = buildMobileSessionTabSnapshots(state, systemPrefersDark) - const publication = partitionMobileSessionPublication(mobileSessionTabs) - const graph: RuntimeRendererSyncWindowGraph = { - tabs: [], - leaves: [], - rendererGeneration: mobileSessionPublicationEpoch, - mobileSessionTabs: publication.changed, - unchangedMobileSessionWorktrees: publication.unchangedWorktrees - } - - for (const [tabId, registeredTab] of registeredTabs) { - const tab = terminalTabById.get(tabId) - if (!tab) { - continue - } - if (isWebOnlyMirroredTerminalTab(tab, state.terminalLayoutsByTabId[tabId])) { - continue - } - - const manager = registeredTab.getManager() - const container = registeredTab.getContainer() - const activePaneId = manager?.getActivePane()?.id ?? null - const root = - container?.firstElementChild instanceof HTMLElement ? container.firstElementChild : null - - graph.tabs.push({ - tabId, - worktreeId: registeredTab.worktreeId, - title: resolveRuntimeTerminalTitle(tab, generatedTitlesEnabled), - activeLeafId: activePaneId === null ? null : (manager?.getLeafId(activePaneId) ?? null), - layout: serializePaneTree(root) - }) - - const savedPtyIdsByLeafId = state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {} - for (const pane of manager?.getPanes() ?? []) { - const leafId = pane.leafId - const ptyId = registeredTab.getPtyIdForPane(pane.id) - const savedPtyId = savedPtyIdsByLeafId[leafId] ?? null - const registeredTime = tabRegisteredAt.get(tabId) ?? 0 - if (!ptyId && savedPtyId && Date.now() - registeredTime > NO_TRANSPORT_GRACE_MS) { - warnTerminalLifecycleAnomaly('mounted terminal leaf has saved PTY but no live transport', { - tabId, - worktreeId: registeredTab.worktreeId, - leafId, - paneId: pane.id, - ptyId: savedPtyId - }) - } - const paneTitles = state.runtimePaneTitlesByTabId[tabId] ?? {} - graph.leaves.push({ - tabId, - worktreeId: registeredTab.worktreeId, - leafId, - paneRuntimeId: pane.id, - ptyId, - paneTitle: paneTitles[pane.id] ?? null, - title: resolveRuntimeTerminalTitle( - tab, - generatedTitlesEnabled, - state.runtimePaneTitlesByTabId[tabId]?.[pane.id] ?? tab.title - ) - }) - } - } - - // Why: inactive automation tabs never mount a TerminalPane; publish their leaf+ptyId from persisted layout (gated on a live buffer) or the live PTY looks orphaned. - // Cold-parked tabs are the same shape with a different liveness proof: their - // pane is unmounted but a parked watcher still owns the PTY. Dropping their - // leaf invalidates the terminal handle every paired subscriber is bound to, - // stalling a remotely-driven terminal the host merely stopped displaying. - // Built once for the whole publication; a per-leaf registry scan would be - // quadratic across a many-worktree workspace. - const parkedWatcherPtyIds = collectParkedTerminalWatcherPtyIds() - for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { - for (const tab of tabs) { - const layout = state.terminalLayoutsByTabId[tab.id] - if (registeredTabs.has(tab.id) || isWebOnlyMirroredTerminalTab(tab, layout)) { - continue - } - const savedPtyIdsByLeafId = layout?.ptyIdsByLeafId - if (!savedPtyIdsByLeafId) { - continue - } - const liveLeaves = Object.entries(savedPtyIdsByLeafId).filter( - ([leafId, ptyId]) => - typeof ptyId === 'string' && - ptyId.length > 0 && - isTerminalLeafId(leafId) && - (Boolean(getEagerPtyBufferHandle(ptyId)) || parkedWatcherPtyIds.has(ptyId)) - ) - if (liveLeaves.length === 0) { - continue - } - const title = resolveRuntimeTerminalTitle(tab, generatedTitlesEnabled) - // Why: partial coverage is legitimate — one leaf's watcher can be disposed - // while its siblings stay parked — so a saved activeLeafId that did not - // survive the filter would name a leaf this publication never sends. - const publishedLeafIds = new Set(liveLeaves.map(([leafId]) => leafId)) - const savedActiveLeafId = layout?.activeLeafId - const parkedPaneIdsByPtyId = getParkedTerminalWatcherPaneIdsByPtyId(tab.id) - const parkedPaneTitles = state.runtimePaneTitlesByTabId[tab.id] ?? {} - graph.tabs.push({ - tabId: tab.id, - worktreeId, - title, - activeLeafId: - savedActiveLeafId && publishedLeafIds.has(savedActiveLeafId) - ? savedActiveLeafId - : liveLeaves[0][0], - layout: resolveTerminalLayoutRoot({ - authoritativeRoot: layout?.root, - leafIds: liveLeaves.map(([leafId]) => leafId), - onSynthesize: (leafCount) => - console.warn( - `[sync-runtime-graph] synthesized layout for ${leafCount} unmounted leaves with no saved tree` - ) - }) - }) - liveLeaves.forEach(([leafId, ptyId], index) => { - // Why the watcher's id wins: PaneManager ids are allocated monotonically - // and closing a pane retires its id without renumbering, so an ordinal - // can name a pane that no longer exists — and main routes split/close and - // the paneKey fallback through this value. - const parkedPaneId = parkedPaneIdsByPtyId.get(ptyId) - graph.leaves.push({ - tabId: tab.id, - worktreeId, - leafId, - paneRuntimeId: parkedPaneId ?? index + 1, - ptyId, - // Why not null: the parked byte watcher keeps writing this pane's - // runtime title, and main prefers leaf.paneTitle over its own older - // lastOscTitle. Dropping it would pin a parked agent pane to a stale - // title for as long as it stays parked. - paneTitle: (parkedPaneId === undefined ? null : parkedPaneTitles[parkedPaneId]) ?? null, - title - }) - }) - } - } - - try { - const result = await window.api.runtime.syncWindowGraph(graph) - // Why: only an acknowledged publication may be treated as delivered. A throw - // leaves the memo behind, so the retry resends every worktree in full. - commitMobileSessionPublication(mobileSessionTabs, result?.mobileSessionResyncWorktrees) - const currentState = getStoreState() - currentState?.setRuntimeAgentOrchestrationByPaneKey?.(result?.agentOrchestrationByPaneKey ?? {}) - for (const resolution of result?.nativeChatLaunchDraftResolutions ?? []) { - if (currentState) { - applyNativeChatLaunchDraftResolved(currentState, { - type: 'nativeChatLaunchDraftResolved', - ...resolution - }) - } - } - if (result?.mobileSessionResyncWorktrees?.length) { - scheduleRuntimeGraphSync() - } - } catch (error) { - console.error('[runtime] Failed to sync renderer graph:', error) - } -} - -function partitionMobileSessionPublication(snapshots: RuntimeMobileSessionTabsSnapshot[]): { - changed: RuntimeMobileSessionTabsSnapshot[] - unchangedWorktrees: string[] -} { - const changed: RuntimeMobileSessionTabsSnapshot[] = [] - const unchangedWorktrees: string[] = [] - for (const snapshot of snapshots) { - // Why: buildMobileSessionTabSnapshots returns the cached object for a worktree - // it did not rebuild, so identity — not a deep compare — settles this. - if (publishedMobileSessionSnapshotByWorktree.get(snapshot.worktree) === snapshot) { - unchangedWorktrees.push(snapshot.worktree) - } else { - changed.push(snapshot) - } - } - return { changed, unchangedWorktrees } -} - -function commitMobileSessionPublication( - snapshots: RuntimeMobileSessionTabsSnapshot[], - resyncWorktrees: string[] | undefined -): void { - const published = new Set() - for (const snapshot of snapshots) { - published.add(snapshot.worktree) - publishedMobileSessionSnapshotByWorktree.set(snapshot.worktree, snapshot) - } - for (const worktreeId of publishedMobileSessionSnapshotByWorktree.keys()) { - if (!published.has(worktreeId)) { - publishedMobileSessionSnapshotByWorktree.delete(worktreeId) - } - } - // Why: main dropped these after acknowledging them, so forget the delivery and - // let the scheduled resync republish them in full. - for (const worktreeId of resyncWorktrees ?? []) { - publishedMobileSessionSnapshotByWorktree.delete(worktreeId) - } -} - -function narrowRecordByKeys( - source: Record | undefined, - keys: readonly string[] -): ReadonlyMap { - if (!source || keys.length === 0) { - return EMPTY_NARROWED_BY_KEY - } - let narrowed: Map | null = null - for (const key of keys) { - const value = source[key] - if (value === undefined) { - continue - } - narrowed ??= new Map() - narrowed.set(key, value) - } - return narrowed ?? EMPTY_NARROWED_BY_KEY -} - -function narrowMapByKeys( - source: ReadonlyMap, - keys: readonly string[] -): ReadonlyMap { - if (source.size === 0 || keys.length === 0) { - return EMPTY_NARROWED_BY_KEY - } - let narrowed: Map | null = null - for (const key of keys) { - if (!source.has(key)) { - continue - } - narrowed ??= new Map() - narrowed.set(key, source.get(key) as T) - } - return narrowed ?? EMPTY_NARROWED_BY_KEY -} - -function getMobileTerminalTheme( - state: AppState, - systemPrefersDark: boolean -): RuntimeMobileTerminalTheme | undefined { - // Why: resolving per terminal tab allocated a fresh theme per surface; one instance per publication is byte-identical downstream. - if ( - hasCachedMobileTerminalTheme && - cachedMobileTerminalThemeSettings === state.settings && - cachedMobileTerminalThemeSystemPrefersDark === systemPrefersDark - ) { - return cachedMobileTerminalTheme - } - cachedMobileTerminalTheme = resolveMobileTerminalTheme(state, systemPrefersDark) - cachedMobileTerminalThemeSettings = state.settings - cachedMobileTerminalThemeSystemPrefersDark = systemPrefersDark - hasCachedMobileTerminalTheme = true - return cachedMobileTerminalTheme -} - -function buildMobileSessionAgentStatusByWorktree( - agentStatusByPaneKey: AppState['agentStatusByPaneKey'], - tabsByWorktree: AppState['tabsByWorktree'] -): MobileSessionAgentStatusByWorktree { - const byWorktreeId = new Map>() - const paneKeys = Object.keys(agentStatusByPaneKey) - if (paneKeys.length === 0) { - return byWorktreeId - } - const worktreeIdByTabId = new Map() - for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { - for (const tab of tabs) { - worktreeIdByTabId.set(tab.id, worktreeId) - } - } - for (const paneKey of paneKeys) { - // Why: every key a builder can look up is makePaneKey output, so an unparseable key is unreachable state, not a missed input. - const tabId = parsePaneKey(paneKey)?.tabId - const worktreeId = tabId === undefined ? undefined : worktreeIdByTabId.get(tabId) - if (worktreeId === undefined) { - continue - } - let bucket = byWorktreeId.get(worktreeId) - if (!bucket) { - bucket = new Map() - byWorktreeId.set(worktreeId, bucket) - } - bucket.set(paneKey, agentStatusByPaneKey[paneKey]) - } - return byWorktreeId -} - -function buildMobileSessionWorktreeInputs( - state: AppState, - worktreeId: string, - publication: MobileSessionPublicationInputs -): MobileSessionWorktreeInputs { - const terminalTabs = state.tabsByWorktree[worktreeId] ?? EMPTY_WORKTREE_TERMINAL_TABS - const terminalTabIds = terminalTabs.map((tab) => tab.id) - const terminalLayoutByTabId = narrowRecordByKeys(state.terminalLayoutsByTabId, terminalTabIds) - const mountedSurfaceCaptureByTabId = captureMountedTerminalSurfaces( - terminalTabs, - state.terminalLayoutsByTabId - ) - const browserWorkspaces = - publication.browserTabsByWorktree[worktreeId] ?? EMPTY_WORKTREE_BROWSER_WORKSPACES - const pagesByBrowserWorkspaceId = narrowRecordByKeys( - state.browserPagesByWorkspace, - browserWorkspaces.map((workspace) => workspace.id) - ) - const browserPageIds: string[] = [] - for (const pages of pagesByBrowserWorkspaceId.values()) { - for (const page of pages) { - browserPageIds.push(page.id) - } - } - const openFilesById = publication.openFileIndexes.byWorktreeAndId.get(worktreeId) - const openFileIds = - publication.openFileIndexes.idsByWorktree.get(worktreeId) ?? EMPTY_WORKTREE_OPEN_FILE_IDS - // Why: the global activeFileId/activeTabType fallbacks only matter when the active file lives here, so resolve them per worktree. - const resolvedActiveFileId = state.activeFileIdByWorktree?.[worktreeId] ?? state.activeFileId - const activeEditorFileId = - resolvedActiveFileId && openFilesById?.has(resolvedActiveFileId) ? resolvedActiveFileId : null - const activeTabId = state.activeTabId - return { - worktreeId, - terminalTabs, - browserWorkspaces, - unifiedTabs: state.unifiedTabsByWorktree[worktreeId] ?? EMPTY_WORKTREE_UNIFIED_TABS, - groups: state.groupsByWorktree[worktreeId] ?? EMPTY_WORKTREE_TAB_GROUPS, - tabBarOrder: state.tabBarOrderByWorktree[worktreeId], - activeGroupId: state.activeGroupIdByWorktree[worktreeId] ?? null, - tabGroupLayout: (state.layoutByWorktree ?? EMPTY_LAYOUT_BY_WORKTREE)[worktreeId], - openFilesById, - openFileIds, - terminalLayoutByTabId, - paneTitlesByTabId: narrowRecordByKeys(state.runtimePaneTitlesByTabId, terminalTabIds), - launchDraftByPaneKey: buildMobileLaunchDraftsByPaneKey({ - terminalTabs, - terminalLayoutByTabId, - mountedSurfaceCaptureByTabId, - launchDraftByTabId: narrowRecordByKeys(state.nativeChatLaunchDraftByTabId, terminalTabIds) - }), - agentStatusByPaneKey: - publication.agentStatusByWorktreeId.get(worktreeId) ?? EMPTY_NARROWED_BY_KEY, - editorDraftVersionByFileId: narrowMapByKeys( - publication.editorDraftVersionByFileId, - openFileIds - ), - pagesByBrowserWorkspaceId, - certificateFailureByBrowserPageId: narrowRecordByKeys( - state.browserCertificateFailuresByPageId, - browserPageIds - ), - activeEditorFileId, - activeEditorTabType: activeEditorFileId - ? (state.activeTabTypeByWorktree?.[worktreeId] ?? state.activeTabType) - : null, - activeTerminalTabId: - activeTabId !== null && terminalTabIds.includes(activeTabId) ? activeTabId : null, - activeBrowserWorkspaceId: state.activeBrowserTabIdByWorktree?.[worktreeId] ?? null, - generatedTitlesEnabled: publication.generatedTitlesEnabled, - terminalTheme: publication.terminalTheme, - mountedSurfaceCaptureByTabId - } -} - -function captureMountedTerminalSurfaces( - terminalTabs: AppState['tabsByWorktree'][string], - terminalLayoutsByTabId: AppState['terminalLayoutsByTabId'] -): ReadonlyMap { - let captures: Map | null = null - for (const tab of terminalTabs) { - const registered = registeredTabs.get(tab.id) - if (!registered) { - continue - } - captures ??= new Map() - captures.set(tab.id, captureMountedTerminalSurface(registered, terminalLayoutsByTabId[tab.id])) - } - return captures ?? EMPTY_NARROWED_BY_KEY -} - -function captureMountedTerminalSurface( - registered: RegisteredTerminalTab, - savedLayout: AppState['terminalLayoutsByTabId'][string] | undefined -): MountedTerminalSurfaceCapture { - const manager = registered.getManager() - const paneLeafIds = manager?.getPanes().map((pane) => pane.leafId) ?? [] - const activePane = manager?.getActivePane() ?? null - const firstChild = registered.getContainer()?.firstElementChild - // Why: mirrors getRuntimeLeafIdsForTerminal so every leaf the builder resolves has a captured pane id. - const effectiveLeafIds = - paneLeafIds.length > 0 - ? paneLeafIds - : collectLeafIdsInOrder(savedLayout?.root).filter(isTerminalLeafId) - const numericPaneIdByLeafId = new Map() - const ptyIdByNumericPaneId = new Map() - for (const leafId of effectiveLeafIds) { - const numericPaneId = manager?.getNumericIdForLeaf(leafId) ?? null - numericPaneIdByLeafId.set(leafId, numericPaneId) - if (numericPaneId !== null) { - ptyIdByNumericPaneId.set(numericPaneId, registered.getPtyIdForPane(numericPaneId)) - } - } - return { - paneLeafIds, - hasLiveActivePane: activePane !== null, - liveActiveLeafId: activePane !== null ? (manager?.getLeafId(activePane.id) ?? null) : null, - liveLayoutRoot: serializePaneTree( - typeof HTMLElement !== 'undefined' && firstChild instanceof HTMLElement ? firstChild : null - ), - numericPaneIdByLeafId, - ptyIdByNumericPaneId, - tabWideAgentHintLeafId: registered.getTabWideAgentHintLeafId() - } -} - -function mountedTerminalSurfaceCaptureEquals( - a: MountedTerminalSurfaceCapture, - b: MountedTerminalSurfaceCapture -): boolean { - return ( - a.hasLiveActivePane === b.hasLiveActivePane && - a.liveActiveLeafId === b.liveActiveLeafId && - a.paneLeafIds.length === b.paneLeafIds.length && - a.paneLeafIds.every((leafId, index) => b.paneLeafIds[index] === leafId) && - narrowedEntriesEqual(a.numericPaneIdByLeafId, b.numericPaneIdByLeafId) && - narrowedEntriesEqual(a.ptyIdByNumericPaneId, b.ptyIdByNumericPaneId) && - a.tabWideAgentHintLeafId === b.tabWideAgentHintLeafId && - // Why: serialization allocates a fresh tree per capture, so it compares by content. - jsonContentEquals(a.liveLayoutRoot, b.liveLayoutRoot) - ) -} - -function mountedSurfaceCapturesEqual( - a: ReadonlyMap, - b: ReadonlyMap -): boolean { - if (a === b) { - return true - } - if (a.size !== b.size) { - return false - } - for (const [tabId, capture] of a) { - const other = b.get(tabId) - if (!other || !mountedTerminalSurfaceCaptureEquals(capture, other)) { - return false - } - } - return true -} - -function narrowedEntriesEqual(a: ReadonlyMap, b: ReadonlyMap): boolean { - if (a === b) { - return true - } - if (a.size !== b.size) { - return false - } - for (const [key, value] of a) { - if (b.get(key) !== value) { - return false - } - } - return true -} - -/** - * True when a worktree's snapshot can be reused without rebuilding its content. - * - * Every field of `MobileSessionWorktreeInputs` is compared, so a missed input - * is a compile error rather than a stale publication to paired clients. - */ -function canReuseMobileSessionSnapshot( - previous: MobileSessionWorktreeInputs, - next: MobileSessionWorktreeInputs -): boolean { - return ( - previous.worktreeId === next.worktreeId && - previous.terminalTabs === next.terminalTabs && - previous.browserWorkspaces === next.browserWorkspaces && - previous.unifiedTabs === next.unifiedTabs && - previous.groups === next.groups && - previous.tabBarOrder === next.tabBarOrder && - previous.activeGroupId === next.activeGroupId && - previous.tabGroupLayout === next.tabGroupLayout && - previous.openFilesById === next.openFilesById && - previous.openFileIds === next.openFileIds && - previous.activeEditorFileId === next.activeEditorFileId && - previous.activeEditorTabType === next.activeEditorTabType && - previous.activeTerminalTabId === next.activeTerminalTabId && - previous.activeBrowserWorkspaceId === next.activeBrowserWorkspaceId && - previous.generatedTitlesEnabled === next.generatedTitlesEnabled && - previous.terminalTheme === next.terminalTheme && - narrowedEntriesEqual(previous.terminalLayoutByTabId, next.terminalLayoutByTabId) && - narrowedEntriesEqual(previous.paneTitlesByTabId, next.paneTitlesByTabId) && - narrowedEntriesEqual(previous.launchDraftByPaneKey, next.launchDraftByPaneKey) && - narrowedEntriesEqual(previous.agentStatusByPaneKey, next.agentStatusByPaneKey) && - narrowedEntriesEqual(previous.editorDraftVersionByFileId, next.editorDraftVersionByFileId) && - narrowedEntriesEqual(previous.pagesByBrowserWorkspaceId, next.pagesByBrowserWorkspaceId) && - narrowedEntriesEqual( - previous.certificateFailureByBrowserPageId, - next.certificateFailureByBrowserPageId - ) && - // Why: live DOM/PaneManager state is invisible to store references; the - // capture makes it comparable instead of forcing mounted worktrees to rebuild. - mountedSurfaceCapturesEqual( - previous.mountedSurfaceCaptureByTabId, - next.mountedSurfaceCaptureByTabId - ) - ) -} - -export function buildMobileSessionTabSnapshots( - state: AppState, - systemPrefersDark = getSystemPrefersDark() -): RuntimeMobileSessionTabsSnapshot[] { - // Why: high-frequency title ticks fire mobile sync; cache indexes/hashes by store-slice ref to skip rescanning editor state. - const openFileIndexes = getOpenFileIndexes(state.openFiles) - const browserTabsByWorktree = getBrowserTabsByWorktree(state) - const publicationInputs: MobileSessionPublicationInputs = { - browserTabsByWorktree, - openFileIndexes, - editorDraftVersionByFileId: getEditorDraftVersionByFileId(state.editorDrafts), - agentStatusByWorktreeId: buildMobileSessionAgentStatusByWorktree( - state.agentStatusByPaneKey ?? EMPTY_AGENT_STATUS_BY_PANE_KEY, - state.tabsByWorktree - ), - generatedTitlesEnabled: state.settings?.tabAutoGenerateTitle === true, - terminalTheme: getMobileTerminalTheme(state, systemPrefersDark) - } - const liveFolderWorkspaceIds = new Set( - (state.folderWorkspaces ?? []).map((workspace) => workspace.id) - ) - const worktreeIds = new Set([ - ...Object.keys(state.tabsByWorktree), - ...Object.keys(state.groupsByWorktree), - ...Object.keys(state.unifiedTabsByWorktree), - ...Object.keys(browserTabsByWorktree), - ...state.openFiles.map((file) => file.worktreeId) - ]) - - const snapshots: RuntimeMobileSessionTabsSnapshot[] = [] - for (const worktreeId of worktreeIds) { - const workspaceScope = parseWorkspaceKey(worktreeId) - if ( - workspaceScope?.type === 'folder' && - !liveFolderWorkspaceIds.has(workspaceScope.folderWorkspaceId) - ) { - mobileSessionSnapshotCacheByWorktree.delete(worktreeId) - continue - } - const inputs = buildMobileSessionWorktreeInputs(state, worktreeId, publicationInputs) - const cached = mobileSessionSnapshotCacheByWorktree.get(worktreeId) - // Why: invalidate before computing — building the maps, projection, and tab - // array first made the cache save the fanout but none of the per-worktree work. - if (cached && canReuseMobileSessionSnapshot(cached.inputs, inputs)) { - snapshots.push(cached.snapshot) - continue - } - const activeGroupId = inputs.activeGroupId - const terminalTabByIdForWorktree = new Map(inputs.terminalTabs.map((tab) => [tab.id, tab])) - const browserWorkspaceByIdForWorktree = new Map( - inputs.browserWorkspaces.map((workspace) => [workspace.id, workspace]) - ) - const unifiedTabByIdForWorktree = new Map(inputs.unifiedTabs.map((tab) => [tab.id, tab])) - const openFilesForWorktree = inputs.openFilesById - const editorIds = inputs.openFileIds.filter((fileId) => { - const file = openFilesForWorktree?.get(fileId) - return file ? isMobilePublishableOpenFile(file) : false - }) - const publishableTerminalIds = [...terminalTabByIdForWorktree.values()] - .filter( - (terminal) => - !isWebOnlyMirroredTerminalTab(terminal, inputs.terminalLayoutByTabId.get(terminal.id)) - ) - .map((terminal) => terminal.id) - const groupProjection = buildMobileSessionGroupProjection(inputs, { - terminalIds: publishableTerminalIds, - editorIds, - browserIds: [...browserWorkspaceByIdForWorktree.values()] - .filter((workspace) => isMobilePublishableBrowserWorkspace(workspace)) - .map((workspace) => workspace.id) - }) - const tabs: RuntimeMobileSessionSnapshotTab[] = [] - const emittedEditorFileIds = new Set() - const emittedEditorTabIds = new Set() - - for (const item of groupProjection.order) { - if (item.type === 'terminal') { - const terminal = terminalTabByIdForWorktree.get(item.id) - if (!terminal) { - continue - } - if (isWebOnlyMirroredTerminalTab(terminal, inputs.terminalLayoutByTabId.get(terminal.id))) { - continue - } - tabs.push(...buildMobileTerminalSurfaceTabs(inputs, terminal, item.tabId)) - } else if (item.type === 'editor') { - const file = openFilesForWorktree?.get(item.id) - if (!file || !isMobilePublishableOpenFile(file)) { - continue - } - const markdown = buildMobileMarkdownTab( - inputs, - file, - item.tabId ? unifiedTabByIdForWorktree.get(item.tabId) : undefined - ) - if (markdown) { - tabs.push(markdown) - } else { - tabs.push( - buildMobileFileTab( - inputs, - file, - item.tabId ? unifiedTabByIdForWorktree.get(item.tabId) : undefined - ) - ) - } - emittedEditorFileIds.add(file.id) - emittedEditorTabIds.add(item.tabId ?? item.id) - } else if (item.type === 'browser') { - const workspace = browserWorkspaceByIdForWorktree.get(item.id) - if (!workspace || !isMobilePublishableBrowserWorkspace(workspace)) { - continue - } - tabs.push( - buildMobileBrowserTab( - inputs, - workspace, - item.tabId ? unifiedTabByIdForWorktree.get(item.tabId) : undefined - ) - ) - } - } - - // Why: split-group projection can miss plain editor files during hydration; publish them so mobile/web still mirror. - const fallbackEditorTabs: FallbackEditorTabTarget[] = [] - if (openFilesForWorktree) { - const unifiedEditorTabs = getEditorUnifiedTabsForWorktree(inputs) - const unifiedEditorFileIds = new Set(unifiedEditorTabs.map((tab) => tab.entityId)) - for (const unifiedTab of unifiedEditorTabs) { - if (emittedEditorTabIds.has(unifiedTab.id)) { - continue - } - const file = openFilesForWorktree.get(unifiedTab.entityId) - if (!file || !isMobilePublishableOpenFile(file)) { - continue - } - const markdown = buildMobileMarkdownTab(inputs, file, unifiedTab) - const fallbackTab = markdown ?? buildMobileFileTab(inputs, file, unifiedTab) - tabs.push(fallbackTab) - fallbackEditorTabs.push({ - tabId: fallbackTab.id, - groupId: unifiedTab.groupId - }) - emittedEditorTabIds.add(unifiedTab.id) - } - for (const file of openFilesForWorktree.values()) { - if (!isMobilePublishableOpenFile(file)) { - continue - } - if (emittedEditorFileIds.has(file.id)) { - continue - } - if (unifiedEditorFileIds.has(file.id)) { - emittedEditorFileIds.add(file.id) - continue - } - const markdown = buildMobileMarkdownTab(inputs, file) - const fallbackTab = markdown ?? buildMobileFileTab(inputs, file) - tabs.push(fallbackTab) - fallbackEditorTabs.push({ - tabId: fallbackTab.id, - groupId: null - }) - emittedEditorFileIds.add(file.id) - } - } - - const active = tabs.find((tab) => tab.isActive) ?? null - const tabGroups = appendFallbackEditorTabsToGroups( - groupProjection.tabGroups, - inputs.groups, - activeGroupId, - fallbackEditorTabs, - active?.id ?? null - ) - const tabGroupLayout = - tabGroups && tabGroups.length > 0 - ? pruneTabGroupLayout(inputs.tabGroupLayout, new Set(tabGroups.map((group) => group.id))) - : groupProjection.tabGroupLayout - const content = { - activeGroupId, - activeTabId: active?.id ?? null, - activeTabType: active?.type ?? null, - ...(tabGroups && tabGroups.length > 0 ? { tabGroups } : {}), - ...(tabGroupLayout ? { tabGroupLayout } : {}), - tabs - } - // Why: main suppresses per-worktree fanout on an unchanged (epoch, version) - // pair, so reuse the cached version for structurally-identical content — - // the backstop for inputs that churn by reference without changing output. - // The counter only ever advances, so a later real change still outranks it. - const candidateVersion = ++mobileSessionSnapshotVersion - if (cached && jsonContentEquals(cached.content, content)) { - mobileSessionSnapshotCacheByWorktree.set(worktreeId, { - inputs, - content, - snapshot: cached.snapshot - }) - snapshots.push(cached.snapshot) - continue - } - const snapshot: RuntimeMobileSessionTabsSnapshot = { - worktree: worktreeId, - publicationEpoch: mobileSessionPublicationEpoch, - snapshotVersion: candidateVersion, - ...content - } - mobileSessionSnapshotCacheByWorktree.set(worktreeId, { inputs, content, snapshot }) - snapshots.push(snapshot) - } - - for (const worktreeId of mobileSessionSnapshotCacheByWorktree.keys()) { - if (!worktreeIds.has(worktreeId)) { - mobileSessionSnapshotCacheByWorktree.delete(worktreeId) - } - } - - return snapshots -} - -function isEditorSurfaceTab(tab: Pick): boolean { - // Why: mobile can mirror ordinary edit/diff files; conflict-review and check-details tabs need metadata this contract lacks. - return tab.contentType === 'editor' || tab.contentType === 'diff' -} - -function getEditorUnifiedTabsForWorktree( - inputs: Pick -): Tab[] { - return inputs.unifiedTabs.filter(isEditorSurfaceTab) -} - -function applyUnifiedEditorTabIdsToLegacyOrder( - order: readonly VisibleTabRef[], - inputs: Pick -): VisibleTabRef[] { - const unifiedEditorTabs = getEditorUnifiedTabsForWorktree(inputs) - if (unifiedEditorTabs.length === 0) { - return [...order] - } - const firstUnifiedTabByFileId = new Map() - for (const tab of unifiedEditorTabs) { - if (!firstUnifiedTabByFileId.has(tab.entityId)) { - firstUnifiedTabByFileId.set(tab.entityId, tab.id) - } - } - return order.map((item) => { - if (item.type !== 'editor' || item.tabId) { - return item - } - const tabId = firstUnifiedTabByFileId.get(item.id) - return tabId ? { ...item, tabId } : item - }) -} - -function appendFallbackEditorTabsToGroups( - tabGroups: RuntimeMobileSessionTabGroup[] | undefined, - sourceGroups: readonly TabGroup[], - activeGroupId: string | null, - fallbackTabs: readonly FallbackEditorTabTarget[], - activeTabId: string | null -): RuntimeMobileSessionTabGroup[] | undefined { - if (fallbackTabs.length === 0) { - return tabGroups - } - const result = [...(tabGroups ?? [])] - const sourceGroupsById = new Map(sourceGroups.map((group) => [group.id, group])) - const groupIndexById = new Map(result.map((group, index) => [group.id, index])) - const firstTargetGroupId = - result[0]?.id ?? - (activeGroupId && sourceGroupsById.has(activeGroupId) ? activeGroupId : null) ?? - sourceGroups[0]?.id ?? - null - const fallbackTabIdSet = new Set(fallbackTabs.map((tab) => tab.tabId)) - - for (const fallback of fallbackTabs) { - const targetGroupId = - fallback.groupId ?? - (activeGroupId && (groupIndexById.has(activeGroupId) || sourceGroupsById.has(activeGroupId)) - ? activeGroupId - : firstTargetGroupId) - if (!targetGroupId) { - continue - } - let targetIndex = groupIndexById.get(targetGroupId) - if (targetIndex === undefined) { - const sourceGroup = sourceGroupsById.get(targetGroupId) - const group: RuntimeMobileSessionTabGroup = { - id: targetGroupId, - activeTabId: sourceGroup?.activeTabId ?? null, - tabOrder: [], - recentTabIds: sourceGroup?.recentTabIds ?? [] - } - targetIndex = result.length - groupIndexById.set(targetGroupId, targetIndex) - result.push(group) - } - const group = result[targetIndex]! - if (!group.tabOrder.includes(fallback.tabId)) { - result[targetIndex] = { - ...group, - tabOrder: [...group.tabOrder, fallback.tabId] - } - } - } - - if (result.length === 0) { - return tabGroups - } - - const activeFallbackTabId = activeTabId && fallbackTabIdSet.has(activeTabId) ? activeTabId : null - - return result.map((group) => { - const tabOrder = [...group.tabOrder] - const tabOrderSet = new Set(tabOrder) - const activeFallbackTabIdForGroup = - activeFallbackTabId && tabOrderSet.has(activeFallbackTabId) ? activeFallbackTabId : null - const activeTabIdForGroup = - activeFallbackTabIdForGroup ?? - (group.activeTabId && tabOrderSet.has(group.activeTabId) ? group.activeTabId : null) - const recentTabIds = (group.recentTabIds ?? []).filter((tabId) => tabOrderSet.has(tabId)) - if ( - activeFallbackTabId && - tabOrderSet.has(activeFallbackTabId) && - !recentTabIds.includes(activeFallbackTabId) - ) { - recentTabIds.push(activeFallbackTabId) - } - return { - ...group, - activeTabId: activeTabIdForGroup, - tabOrder, - recentTabIds - } - }) -} - -function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean { - return typeof ptyId === 'string' && parseRemoteRuntimePtyId(ptyId) !== null -} - -function isWebOnlyMirroredTerminalTab( - tab: Pick[number], 'id' | 'ptyId'>, - layout: AppState['terminalLayoutsByTabId'][string] | undefined -): boolean { - if (!isWebTerminalSurfaceTabId(tab.id)) { - return false - } - const layoutPtyIds = Object.values(layout?.ptyIdsByLeafId ?? {}) - const ptyIds = [tab.ptyId, ...layoutPtyIds].filter( - (ptyId): ptyId is string => typeof ptyId === 'string' && ptyId.length > 0 - ) - // Why: only-remote/no-PTY tabs are web mirrors, not host state; legacy local-PTY tabs still publish for desktop/web parity. - return ptyIds.every(isRemoteRuntimePtyId) -} - -function getOpenFileIndexes(openFiles: AppState['openFiles']): OpenFileIndexes { - if (cachedOpenFileIndexesSource === openFiles && cachedOpenFileIndexes) { - return cachedOpenFileIndexes - } - - const byWorktreeAndId: OpenFileByWorktreeAndId = new Map() - const idsByWorktree = new Map() - for (const file of openFiles) { - let filesById = byWorktreeAndId.get(file.worktreeId) - if (!filesById) { - filesById = new Map() - byWorktreeAndId.set(file.worktreeId, filesById) - } - let ids = idsByWorktree.get(file.worktreeId) - if (!ids) { - ids = [] - idsByWorktree.set(file.worktreeId, ids) - } - if (!filesById.has(file.id)) { - filesById.set(file.id, file) - ids.push(file.id) - } - } - - cachedOpenFileIndexesSource = openFiles - cachedOpenFileIndexes = { byWorktreeAndId, idsByWorktree } - return cachedOpenFileIndexes -} - -function collectTabGroupLayoutIds(layout: TabGroupLayoutNode | undefined): string[] { - const result: string[] = [] - const visit = (node: TabGroupLayoutNode | undefined): void => { - if (!node) { - return - } - if (node.type === 'leaf') { - result.push(node.groupId) - return - } - visit(node.first) - visit(node.second) - } - visit(layout) - return result -} - -function pruneTabGroupLayout( - layout: TabGroupLayoutNode | undefined, - validGroupIds: ReadonlySet -): 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 getOrderedTabGroups( - groups: readonly TabGroup[], - layout: TabGroupLayoutNode | undefined -): TabGroup[] { - const byId = new Map(groups.map((group) => [group.id, group])) - const seen = new Set() - const ordered: TabGroup[] = [] - for (const groupId of collectTabGroupLayoutIds(layout)) { - const group = byId.get(groupId) - if (!group || seen.has(group.id)) { - continue - } - seen.add(group.id) - ordered.push(group) - } - for (const group of groups) { - if (!seen.has(group.id)) { - ordered.push(group) - } - } - return ordered -} - -// Why: getActiveTabNavOrder only reads the [worktreeId] entry of each slice, so a single-key view keeps this path off AppState. -function buildLegacyNavOrderView( - inputs: MobileSessionWorktreeInputs -): Parameters[0] { - const { worktreeId } = inputs - return { - activeGroupIdByWorktree: inputs.activeGroupId ? { [worktreeId]: inputs.activeGroupId } : {}, - groupsByWorktree: { [worktreeId]: inputs.groups }, - unifiedTabsByWorktree: { [worktreeId]: inputs.unifiedTabs }, - tabBarOrderByWorktree: inputs.tabBarOrder ? { [worktreeId]: inputs.tabBarOrder } : {}, - tabsByWorktree: { [worktreeId]: inputs.terminalTabs }, - openFiles: inputs.openFilesById ? [...inputs.openFilesById.values()] : [], - browserTabsByWorktree: { [worktreeId]: inputs.browserWorkspaces } - } -} - -function buildMobileSessionGroupProjection( - inputs: MobileSessionWorktreeInputs, - ids: { - terminalIds: string[] - editorIds: string[] - browserIds: string[] - } -): { - order: VisibleTabRef[] - tabGroups?: RuntimeMobileSessionTabGroup[] - tabGroupLayout?: TabGroupLayoutNode | null -} { - const groups = inputs.groups - if (groups.length === 0) { - return { - order: applyUnifiedEditorTabIdsToLegacyOrder( - getActiveTabNavOrder(buildLegacyNavOrderView(inputs), inputs.worktreeId, { - editorIds: ids.editorIds - }), - inputs - ) - } - } - - const terminalIds = new Set(ids.terminalIds) - const editorIds = new Set(ids.editorIds) - const browserIds = new Set(ids.browserIds) - const tabs = inputs.unifiedTabs - const order: VisibleTabRef[] = [] - const tabGroups: RuntimeMobileSessionTabGroup[] = [] - - for (const group of getOrderedTabGroups(groups, inputs.tabGroupLayout)) { - const groupTabs = tabs.filter((tab) => tab.groupId === group.id) - const visibleOrder = getGroupVisibleTabOrder( - group, - groupTabs, - terminalIds, - editorIds, - browserIds, - new Set(), - true - ) - if (visibleOrder.length === 0) { - continue - } - const tabOrder = visibleOrder.map((item) => item.tabId ?? item.id) - const tabOrderSet = new Set(tabOrder) - // Why: persisted split groups can have very large tab orders; append iteratively to avoid V8's argument-list limit. - for (const item of visibleOrder) { - order.push(item) - } - tabGroups.push({ - id: group.id, - activeTabId: - group.activeTabId && tabOrderSet.has(group.activeTabId) ? group.activeTabId : null, - tabOrder, - recentTabIds: group.recentTabIds?.filter((tabId) => tabOrderSet.has(tabId)) ?? [] - }) - } - - const validGroupIds = new Set(tabGroups.map((group) => group.id)) - return { - order, - tabGroups, - tabGroupLayout: pruneTabGroupLayout(inputs.tabGroupLayout, validGroupIds) - } -} - -function getEditorDraftVersionByFileId( - editorDrafts: AppState['editorDrafts'] -): Map { - if (cachedEditorDraftsSource === editorDrafts && cachedEditorDraftVersionByFileId) { - return cachedEditorDraftVersionByFileId - } - - const versions = new Map() - for (const [fileId, content] of Object.entries(editorDrafts)) { - versions.set(fileId, stableHashString(content)) - } - cachedEditorDraftsSource = editorDrafts - cachedEditorDraftVersionByFileId = versions - return versions -} - -function mobileTerminalSurfaceId(parentTabId: string, leafId: string): string { - return `${parentTabId}::${leafId}` -} - -function hexToRgba(hex: string, alpha: number): string { - let clean = hex.replace('#', '') - if (clean.length === 3) { - clean = clean - .split('') - .map((c) => c + c) - .join('') - } - const r = Number.parseInt(clean.slice(0, 2), 16) - const g = Number.parseInt(clean.slice(2, 4), 16) - const b = Number.parseInt(clean.slice(4, 6), 16) - return `rgba(${r}, ${g}, ${b}, ${alpha})` -} - -function isHexColor(value: string | undefined): value is string { - return typeof value === 'string' && /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value) -} - -function resolveMobileTerminalTheme( - state: AppState, - systemPrefersDark: boolean -): RuntimeMobileTerminalTheme | undefined { - const settings = state.settings - if (!settings) { - return undefined - } - const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark) - const resolvedTheme = appearance.theme - ? { ...appearance.theme, ...settings.terminalColorOverrides } - : undefined - if (!resolvedTheme) { - return undefined - } - if (settings.terminalBackgroundOpacity !== undefined && isHexColor(resolvedTheme.background)) { - resolvedTheme.background = hexToRgba( - resolvedTheme.background, - settings.terminalBackgroundOpacity - ) - } - if (settings.terminalCursorOpacity !== undefined && isHexColor(resolvedTheme.cursor)) { - resolvedTheme.cursor = hexToRgba(resolvedTheme.cursor, settings.terminalCursorOpacity) - } - - const theme: Record = {} - for (const [key, value] of Object.entries(resolvedTheme)) { - if (typeof value === 'string') { - theme[key] = value - } - } - return { mode: appearance.mode, theme: theme as RuntimeMobileTerminalTheme['theme'] } -} - -function getRuntimeLeafIdsForTerminal( - capture: MountedTerminalSurfaceCapture | undefined, - savedLayout: AppState['terminalLayoutsByTabId'][string] | undefined -): readonly string[] { - const liveLeafIds = capture?.paneLeafIds ?? [] - if (liveLeafIds.length > 0) { - return liveLeafIds - } - - const persistedLeafIds = collectLeafIdsInOrder(savedLayout?.root).filter(isTerminalLeafId) - if (persistedLeafIds.length > 0) { - return persistedLeafIds - } - - // Why: a new tab can predate TerminalPane mount; fabricating pane:1 with no live/persisted leaf would go stale after mount. - return [] -} - -function resolveMobileTabWideAgentHintLeafId( - capture: MountedTerminalSurfaceCapture | undefined, - savedLayout: AppState['terminalLayoutsByTabId'][string] | undefined -): string | null { - if (capture) { - return capture.tabWideAgentHintLeafId - } - return isNativeChatTabWideFallbackSafe(savedLayout) - ? resolveNativeChatActiveLayoutLeafId(savedLayout) - : null -} - -function buildMobileLaunchDraftsByPaneKey(args: { - terminalTabs: AppState['tabsByWorktree'][string] - terminalLayoutByTabId: MobileSessionWorktreeInputs['terminalLayoutByTabId'] - mountedSurfaceCaptureByTabId: MobileSessionWorktreeInputs['mountedSurfaceCaptureByTabId'] - launchDraftByTabId: ReadonlyMap< - string, - NonNullable[string] - > -}): MobileSessionWorktreeInputs['launchDraftByPaneKey'] { - if (args.launchDraftByTabId.size === 0) { - return EMPTY_NARROWED_BY_KEY - } - let draftsByPaneKey: Map< - string, - NonNullable[string] - > | null = null - for (const terminal of args.terminalTabs) { - const draft = args.launchDraftByTabId.get(terminal.id) - if (!draft || draft.resolved) { - continue - } - const capture = args.mountedSurfaceCaptureByTabId.get(terminal.id) - const savedLayout = args.terminalLayoutByTabId.get(terminal.id) - const leafIds = getRuntimeLeafIdsForTerminal(capture, savedLayout) - const ownerLeafId = resolveMobileTabWideAgentHintLeafId(capture, savedLayout) - const launchAgent = nativeChatLaunchAgentForLeaf({ - launchAgent: terminal.launchAgent, - launchAgentLeafId: ownerLeafId, - leafId: ownerLeafId, - leafIds - }) - if (!launchAgent || launchAgent !== draft.agent || !ownerLeafId) { - continue - } - draftsByPaneKey ??= new Map() - draftsByPaneKey.set(makePaneKey(terminal.id, ownerLeafId), draft) - } - return draftsByPaneKey ?? EMPTY_NARROWED_BY_KEY -} - -function buildMobileTerminalSurfaceTabs( - inputs: MobileSessionWorktreeInputs, - terminal: NonNullable[number], - unifiedTabId?: string -): RuntimeMobileSessionSnapshotTab[] { - const capture = inputs.mountedSurfaceCaptureByTabId.get(terminal.id) - const isDesktopTabActive = unifiedTabId - ? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId) - : inputs.activeTerminalTabId === terminal.id - const savedLayout = inputs.terminalLayoutByTabId.get(terminal.id) - const leafIds = getRuntimeLeafIdsForTerminal(capture, savedLayout) - const launchAgentLeafId = resolveMobileTabWideAgentHintLeafId(capture, savedLayout) - const activeLeafId = capture?.hasLiveActivePane - ? capture.liveActiveLeafId - : (savedLayout?.activeLeafId ?? leafIds[0] ?? null) - const paneTitles = inputs.paneTitlesByTabId.get(terminal.id) ?? {} - const generatedTitlesEnabled = inputs.generatedTitlesEnabled - const sanitizedSavedLayout = savedLayout - ? sanitizeTerminalLayoutPaneTitles(savedLayout, terminal) - : undefined - const savedPtyIdsByLeafId = sanitizedSavedLayout?.ptyIdsByLeafId ?? {} - const terminalTheme = inputs.terminalTheme - const liveLayoutRoot = capture?.liveLayoutRoot ?? null - const parentLayout = normalizeTerminalLayoutSnapshot({ - // Why: live DOM tree is authoritative when mounted, else the saved tree; synthesize only as a last resort, never re-guess. - root: resolveTerminalLayoutRoot({ - authoritativeRoot: liveLayoutRoot, - existingRoot: sanitizedSavedLayout?.root, - leafIds, - onSynthesize: (leafCount) => - console.warn( - `[sync-runtime-graph] synthesized parentLayout for ${leafCount} leaves with no live or saved tree` - ) - }), - activeLeafId, - expandedLeafId: sanitizedSavedLayout?.expandedLeafId ?? null, - ...(Object.keys(savedPtyIdsByLeafId).length > 0 ? { ptyIdsByLeafId: savedPtyIdsByLeafId } : {}), - ...(sanitizedSavedLayout?.titlesByLeafId - ? { titlesByLeafId: sanitizedSavedLayout.titlesByLeafId } - : {}) - } satisfies TerminalLayoutSnapshot).snapshot - return leafIds.map((leafId) => { - const numericPaneId = capture?.numericPaneIdByLeafId.get(leafId) ?? null - const ptyId = - numericPaneId === null - ? (savedPtyIdsByLeafId[leafId] ?? (leafIds.length === 1 ? terminal.ptyId : null)) - : (capture?.ptyIdByNumericPaneId.get(numericPaneId) ?? savedPtyIdsByLeafId[leafId] ?? null) - const legacyPaneId = numericPaneId === null ? /^pane:(\d+)$/.exec(leafId)?.[1] : null - const paneTitle = - numericPaneId !== null - ? paneTitles[numericPaneId] - : legacyPaneId - ? paneTitles[Number(legacyPaneId)] - : undefined - const leafTitle = paneTitle?.trim() || sanitizedSavedLayout?.titlesByLeafId?.[leafId]?.trim() - const paneKey = isTerminalLeafId(leafId) ? makePaneKey(terminal.id, leafId) : null - const tabWideFallbackSafe = - isNativeChatTabWideFallbackSafe(parentLayout) && launchAgentLeafId === leafId - const title = tabWideFallbackSafe - ? resolveRuntimeTerminalTitle( - terminal, - generatedTitlesEnabled, - leafTitle ?? terminal.title ?? 'Terminal' - ) - : (leafTitle ?? 'Terminal') - const agentStatusTitle = leafTitle ?? (tabWideFallbackSafe ? terminal.title : '') ?? '' - const agentStatus = - paneKey && !isClaudeManagementTitle(agentStatusTitle) - ? inputs.agentStatusByPaneKey.get(paneKey) - : undefined - const launchAgent = nativeChatLaunchAgentForLeaf({ - launchAgent: terminal.launchAgent, - launchAgentLeafId, - leafId, - leafIds - }) - const launchDraft = paneKey ? inputs.launchDraftByPaneKey.get(paneKey) : undefined - const publishedLaunchDraft = - launchDraft && launchDraft.agent === launchAgent && launchDraft.text.trim() - ? launchDraft - : null - return { - type: 'terminal' as const, - id: mobileTerminalSurfaceId(terminal.id, leafId), - title, - ...(tabWideFallbackSafe && terminal.quickCommandLabel?.trim() - ? { quickCommandLabel: terminal.quickCommandLabel.trim() } - : {}), - parentTabId: terminal.id, - leafId, - ptyId, - ...(terminalTheme ? { terminalTheme } : {}), - ...(agentStatus ? { agentStatus } : {}), - ...(launchAgent ? { launchAgent } : {}), - // Launch context that exists only as an unsent TUI-input draft; mobile - // prefills its chat composer from it (desktop keeps its own seed store). - ...(publishedLaunchDraft - ? { - launchDraft: publishedLaunchDraft.text, - launchDraftCreatedAt: publishedLaunchDraft.createdAt - } - : {}), - parentLayout, - isActive: isDesktopTabActive && leafId === activeLeafId - } - }) -} - -function buildMobileMarkdownTab( - inputs: MobileSessionWorktreeInputs, - file: AppState['openFiles'][number], - unifiedTab?: Tab -): RuntimeMobileSessionMarkdownTab | null { - if (file.mode !== 'edit' && file.mode !== 'markdown-preview') { - return null - } - if (file.language !== 'markdown' && file.mode !== 'markdown-preview') { - return null - } - - const sourceFile = - file.mode === 'markdown-preview' && file.markdownPreviewSourceFileId - ? (inputs.openFilesById?.get(file.markdownPreviewSourceFileId) ?? file) - : file - const draftVersion = inputs.editorDraftVersionByFileId.get(sourceFile.id) - const title = file.relativePath.split(/[\\/]/).pop() || file.relativePath || 'Markdown' - const unifiedTabId = unifiedTab?.id - - return { - type: 'markdown', - id: unifiedTabId ?? file.id, - title, - filePath: file.filePath, - relativePath: file.relativePath, - language: 'markdown', - mode: file.mode, - isDirty: file.isDirty || sourceFile.isDirty, - isActive: unifiedTabId - ? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId) - : isFileActiveEditorSurface(inputs, file), - sourceFileId: sourceFile.id, - sourceFilePath: sourceFile.filePath, - sourceRelativePath: sourceFile.relativePath, - documentVersion: draftVersion ?? `file:${sourceFile.id}`, - color: unifiedTab?.color ?? null, - isPinned: unifiedTab?.isPinned === true - } -} - -function buildMobileFileTab( - inputs: MobileSessionWorktreeInputs, - file: AppState['openFiles'][number], - unifiedTab?: Tab -): RuntimeMobileSessionFileTab { - const title = file.relativePath.split(/[\\/]/).pop() || file.relativePath || 'File' - const diffSource = isMobileFileDiffSource(file.diffSource) ? file.diffSource : undefined - const unifiedTabId = unifiedTab?.id - - return { - type: 'file', - id: unifiedTabId ?? file.id, - title, - filePath: file.filePath, - relativePath: file.relativePath, - language: file.language, - mode: file.mode === 'diff' ? 'diff' : 'edit', - ...(diffSource ? { diffSource } : {}), - isDirty: file.isDirty, - color: unifiedTab?.color ?? null, - isPinned: unifiedTab?.isPinned === true, - isActive: unifiedTabId - ? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId) - : isFileActiveEditorSurface(inputs, file) - } -} - -function isFileActiveEditorSurface( - inputs: Pick, - file: Pick -): boolean { - return inputs.activeEditorTabType === 'editor' && inputs.activeEditorFileId === file.id -} - -function isMobileFileDiffSource( - diffSource: AppState['openFiles'][number]['diffSource'] -): diffSource is 'staged' | 'unstaged' { - return diffSource === 'staged' || diffSource === 'unstaged' -} - -function isMobileUnsupportedCombinedDiffSource( - diffSource: AppState['openFiles'][number]['diffSource'] -): boolean { - return ( - diffSource === 'combined-all' || - diffSource === 'combined-uncommitted' || - diffSource === 'combined-branch' || - diffSource === 'combined-commit' - ) -} - -function isMobilePublishableOpenFile(file: AppState['openFiles'][number]): boolean { - // Why: combined diff tabs use display labels as paths and need the desktop renderer; mobile would mis-call files.read. - return !isMobileUnsupportedCombinedDiffSource(file.diffSource) -} - -/** - * Why a workspace document is held back: it is served to one desktop guest through a grant no - * mobile client holds, so there is nothing on the other side that could render it — and the wire - * has no tab kind for it, so an old client would take it for an ordinary browser tab and offer - * navigation for a page that has no URL. Host and phone parity ships behind capability negotiation. - */ -function isMobilePublishableBrowserWorkspace( - workspace: NonNullable[number] -): boolean { - return !workspace.docLocation -} - -// Why: the store buckets a workspace under its own worktreeId, so this worktree's scoped inputs are the workspace's own scope. -function buildMobileBrowserTab( - inputs: MobileSessionWorktreeInputs, - workspace: NonNullable[number], - unifiedTab?: Tab -): RuntimeMobileSessionBrowserTab { - const pages = inputs.pagesByBrowserWorkspaceId.get(workspace.id) ?? [] - const activePage = pages.find((page) => page.id === workspace.activePageId) ?? pages[0] ?? null - const title = - activePage?.title || workspace.title || activePage?.url || workspace.url || 'Browser' - const unifiedTabId = unifiedTab?.id - - return { - type: 'browser', - id: unifiedTabId ?? workspace.id, - title, - browserWorkspaceId: workspace.id, - browserPageId: activePage?.id ?? workspace.activePageId ?? null, - url: activePage?.url ?? workspace.url ?? 'about:blank', - loading: activePage?.loading ?? workspace.loading, - canGoBack: activePage?.canGoBack ?? workspace.canGoBack, - canGoForward: activePage?.canGoForward ?? workspace.canGoForward, - // Why: null means the active page cleared its failure; ?? would resurrect a stale workspace-level error. - loadError: activePage ? activePage.loadError : workspace.loadError, - certificateFailure: activePage - ? (inputs.certificateFailureByBrowserPageId.get(activePage.id) ?? null) - : null, - color: unifiedTab?.color ?? null, - isPinned: unifiedTab?.isPinned === true, - isActive: unifiedTabId - ? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId) - : inputs.activeBrowserWorkspaceId === workspace.id - } -} - -function isUnifiedTabActiveInActiveGroup( - inputs: Pick, - unifiedTabId: string -): boolean { - return inputs.groups.some( - (group) => group.id === inputs.activeGroupId && group.activeTabId === unifiedTabId - ) -} - -function stableHashString(value: string): string { - let hash = 2166136261 - for (let i = 0; i < value.length; i += 1) { - hash ^= value.charCodeAt(i) - hash = Math.imul(hash, 16777619) - } - return `draft:${value.length}:${(hash >>> 0).toString(16)}` -} +setTrailingGraphSyncScheduler(scheduleRuntimeGraphSync) diff --git a/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts b/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts new file mode 100644 index 00000000000..16b93d96a46 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts @@ -0,0 +1,76 @@ +import type { AppState } from '@/store/types' +import { AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS, graphState } from './graph-state' +import type { AgentStatusProjectionCacheEntry } from './types' + +function serializeAgentStatusEntry( + paneKey: string, + entry: AppState['agentStatusByPaneKey'][string] +): string { + return JSON.stringify({ + paneKey, + entryPaneKey: entry.paneKey, + state: entry.state, + workingMode: entry.workingMode ?? null, + prompt: entry.prompt, + updatedAtBucket: Math.floor(entry.updatedAt / AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS), + stateStartedAt: entry.stateStartedAt, + agentType: entry.agentType ?? null, + terminalTitle: entry.terminalTitle ?? null, + stateHistory: entry.stateHistory.map((history) => ({ + state: history.state, + prompt: history.prompt, + startedAt: history.startedAt, + interrupted: history.interrupted ?? null + })), + toolName: entry.toolName ?? null, + toolInput: entry.toolInput ?? null, + // Include the prompt so a newly captured question re-fires mobile publication. + interactivePrompt: entry.interactivePrompt ?? null, + lastAssistantMessage: entry.lastAssistantMessage ?? null, + interrupted: entry.interrupted ?? null + }) +} + +export function buildRuntimeMobileAgentStatusProjection( + agentStatusByPaneKey: AppState['agentStatusByPaneKey'] +): string { + const cached = graphState.cachedAgentStatusProjection + if (cached?.source === agentStatusByPaneKey) { + return cached.projection + } + + // A status ping replaces one entry and re-spreads the map; reuse every other entry. + const entries = new Map() + const parts: string[] = [] + for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey).sort(([a], [b]) => + a.localeCompare(b) + )) { + const previous = cached?.entries.get(paneKey) + const entryCache = + previous?.entry === entry + ? previous + : { entry, projection: serializeAgentStatusEntry(paneKey, entry) } + entries.set(paneKey, entryCache) + parts.push(entryCache.projection) + } + const projection = `[${parts.join(',')}]` + graphState.cachedAgentStatusProjection = { + source: agentStatusByPaneKey, + entries, + projection + } + return projection +} + +export function buildRuntimeMobileAgentStatusProjectionForTests( + agentStatusByPaneKey: AppState['agentStatusByPaneKey'] +): string { + return buildRuntimeMobileAgentStatusProjection(agentStatusByPaneKey) +} + +export const AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS = + AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS + +export function resetRuntimeMobileAgentStatusProjectionCacheForTests(): void { + graphState.cachedAgentStatusProjection = null +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts b/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts new file mode 100644 index 00000000000..2cdb068e20e --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts @@ -0,0 +1,215 @@ +import { getEagerPtyBufferHandle } from '@/components/terminal-pane/pty-dispatcher' +import { warnTerminalLifecycleAnomaly } from '@/components/terminal-pane/terminal-lifecycle-diagnostics' +import { + collectParkedTerminalWatcherPtyIds, + getParkedTerminalWatcherPaneIdsByPtyId +} from '@/components/terminal-pane/terminal-parked-watcher-registry' +import { serializePaneTree } from '@/components/terminal-pane/layout-serialization' +import { getSystemPrefersDark } from '@/lib/terminal-theme' +import type { + RuntimeMobileSessionTabsSnapshot, + RuntimeRendererSyncWindowGraph +} from '../../../../shared/runtime-types' +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' +import { applyNativeChatLaunchDraftResolved } from '../native-chat-launch-draft-runtime-resolution' +import { resolveTerminalLayoutRoot } from '../remote-terminal-layout-resolution' +import { buildMobileSessionTabSnapshots } from './mobile-session-snapshots' +import { isWebOnlyMirroredTerminalTab } from './mobile-session-tab-helpers' +import { resolveRuntimeTerminalTitle } from './sync-projections' +import { graphState, mobilePublicationEpoch, NO_TRANSPORT_GRACE_MS } from './graph-state' + +export async function syncRuntimeGraph(): Promise { + if (!graphState.syncEnabled || !graphState.getStoreState) { + return + } + // The store getter is injected to break the terminal-slice construction cycle. + const state = graphState.getStoreState() + const systemPrefersDark = getSystemPrefersDark() + const terminalTabById = new Map( + Object.values(state.tabsByWorktree) + .flat() + .map((tab) => [tab.id, tab]) + ) + const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true + const mobileSessionTabs = buildMobileSessionTabSnapshots(state, systemPrefersDark) + const publication = partitionMobileSessionPublication(mobileSessionTabs) + const graph: RuntimeRendererSyncWindowGraph = { + tabs: [], + leaves: [], + rendererGeneration: mobilePublicationEpoch, + mobileSessionTabs: publication.changed, + unchangedMobileSessionWorktrees: publication.unchangedWorktrees + } + + for (const [tabId, registeredTab] of graphState.registeredTabs) { + const tab = terminalTabById.get(tabId) + if (!tab) { + continue + } + if (isWebOnlyMirroredTerminalTab(tab, state.terminalLayoutsByTabId[tabId])) { + continue + } + const manager = registeredTab.getManager() + const container = registeredTab.getContainer() + const activePaneId = manager?.getActivePane()?.id ?? null + const root = + container?.firstElementChild instanceof HTMLElement ? container.firstElementChild : null + graph.tabs.push({ + tabId, + worktreeId: registeredTab.worktreeId, + title: resolveRuntimeTerminalTitle(tab, generatedTitlesEnabled), + activeLeafId: activePaneId === null ? null : (manager?.getLeafId(activePaneId) ?? null), + layout: serializePaneTree(root) + }) + const savedPtyIdsByLeafId = state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {} + for (const pane of manager?.getPanes() ?? []) { + const leafId = pane.leafId + const ptyId = registeredTab.getPtyIdForPane(pane.id) + const savedPtyId = savedPtyIdsByLeafId[leafId] ?? null + const registeredTime = graphState.tabRegisteredAt.get(tabId) ?? 0 + if (!ptyId && savedPtyId && Date.now() - registeredTime > NO_TRANSPORT_GRACE_MS) { + warnTerminalLifecycleAnomaly('mounted terminal leaf has saved PTY but no live transport', { + tabId, + worktreeId: registeredTab.worktreeId, + leafId, + paneId: pane.id, + ptyId: savedPtyId + }) + } + const paneTitles = state.runtimePaneTitlesByTabId[tabId] ?? {} + graph.leaves.push({ + tabId, + worktreeId: registeredTab.worktreeId, + leafId, + paneRuntimeId: pane.id, + ptyId, + paneTitle: paneTitles[pane.id] ?? null, + title: resolveRuntimeTerminalTitle( + tab, + generatedTitlesEnabled, + state.runtimePaneTitlesByTabId[tabId]?.[pane.id] ?? tab.title + ) + }) + } + } + + // Inactive automation/cold-parked tabs do not mount a TerminalPane; publish persisted leaves + // only when a live eager buffer or parked watcher proves the PTY is still owned. + const parkedWatcherPtyIds = collectParkedTerminalWatcherPtyIds() + for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { + for (const tab of tabs) { + const layout = state.terminalLayoutsByTabId[tab.id] + if (graphState.registeredTabs.has(tab.id) || isWebOnlyMirroredTerminalTab(tab, layout)) { + continue + } + const savedPtyIdsByLeafId = layout?.ptyIdsByLeafId + if (!savedPtyIdsByLeafId) { + continue + } + const liveLeaves = Object.entries(savedPtyIdsByLeafId).filter( + ([leafId, ptyId]) => + typeof ptyId === 'string' && + ptyId.length > 0 && + isTerminalLeafId(leafId) && + (Boolean(getEagerPtyBufferHandle(ptyId)) || parkedWatcherPtyIds.has(ptyId)) + ) + if (liveLeaves.length === 0) { + continue + } + const title = resolveRuntimeTerminalTitle(tab, generatedTitlesEnabled) + const publishedLeafIds = new Set(liveLeaves.map(([leafId]) => leafId)) + const savedActiveLeafId = layout?.activeLeafId + const parkedPaneIdsByPtyId = getParkedTerminalWatcherPaneIdsByPtyId(tab.id) + const parkedPaneTitles = state.runtimePaneTitlesByTabId[tab.id] ?? {} + graph.tabs.push({ + tabId: tab.id, + worktreeId, + title, + activeLeafId: + savedActiveLeafId && publishedLeafIds.has(savedActiveLeafId) + ? savedActiveLeafId + : liveLeaves[0][0], + layout: resolveTerminalLayoutRoot({ + authoritativeRoot: layout?.root, + leafIds: liveLeaves.map(([leafId]) => leafId), + onSynthesize: (leafCount) => + console.warn( + `[sync-runtime-graph] synthesized layout for ${leafCount} unmounted leaves with no saved tree` + ) + }) + }) + liveLeaves.forEach(([leafId, ptyId], index) => { + const parkedPaneId = parkedPaneIdsByPtyId.get(ptyId) + graph.leaves.push({ + tabId: tab.id, + worktreeId, + leafId, + paneRuntimeId: parkedPaneId ?? index + 1, + ptyId, + paneTitle: (parkedPaneId === undefined ? null : parkedPaneTitles[parkedPaneId]) ?? null, + title + }) + }) + } + } + + try { + const result = await window.api.runtime.syncWindowGraph(graph) + commitMobileSessionPublication(mobileSessionTabs, result?.mobileSessionResyncWorktrees) + const currentState = graphState.getStoreState() + currentState?.setRuntimeAgentOrchestrationByPaneKey?.(result?.agentOrchestrationByPaneKey ?? {}) + for (const resolution of result?.nativeChatLaunchDraftResolutions ?? []) { + if (currentState) { + applyNativeChatLaunchDraftResolved(currentState, { + type: 'nativeChatLaunchDraftResolved', + ...resolution + }) + } + } + if (result?.mobileSessionResyncWorktrees?.length) { + scheduleTrailingGraphSync() + } + } catch (error) { + console.error('[runtime] Failed to sync renderer graph:', error) + } +} + +let scheduleTrailingGraphSync: () => void = () => undefined +export function setTrailingGraphSyncScheduler(scheduler: () => void): void { + scheduleTrailingGraphSync = scheduler +} + +function partitionMobileSessionPublication(snapshots: RuntimeMobileSessionTabsSnapshot[]): { + changed: RuntimeMobileSessionTabsSnapshot[] + unchangedWorktrees: string[] +} { + const changed: RuntimeMobileSessionTabsSnapshot[] = [] + const unchangedWorktrees: string[] = [] + for (const snapshot of snapshots) { + if (graphState.publishedMobileSessionSnapshotByWorktree.get(snapshot.worktree) === snapshot) { + unchangedWorktrees.push(snapshot.worktree) + } else { + changed.push(snapshot) + } + } + return { changed, unchangedWorktrees } +} + +function commitMobileSessionPublication( + snapshots: RuntimeMobileSessionTabsSnapshot[], + resyncWorktrees: string[] | undefined +): void { + const published = new Set() + for (const snapshot of snapshots) { + published.add(snapshot.worktree) + graphState.publishedMobileSessionSnapshotByWorktree.set(snapshot.worktree, snapshot) + } + for (const worktreeId of graphState.publishedMobileSessionSnapshotByWorktree.keys()) { + if (!published.has(worktreeId)) { + graphState.publishedMobileSessionSnapshotByWorktree.delete(worktreeId) + } + } + for (const worktreeId of resyncWorktrees ?? []) { + graphState.publishedMobileSessionSnapshotByWorktree.delete(worktreeId) + } +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/graph-state.ts b/src/renderer/src/runtime/sync-runtime-graph/graph-state.ts new file mode 100644 index 00000000000..eb2d4acf477 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/graph-state.ts @@ -0,0 +1,101 @@ +import { createBrowserUuid } from '@/lib/browser-uuid' +import type { AppState } from '@/store/types' +import type { + RuntimeMobileSessionSnapshotTab, + RuntimeMobileSessionTabsSnapshot, + RuntimeMobileTerminalTheme +} from '../../../../shared/runtime-types' +import type { + AgentStatusProjectionCache, + MobileSessionWorktreeInputs, + OpenFileIndexes, + RegisteredTerminalTab, + TabsProjectionCache +} from './types' + +export const NO_TRANSPORT_GRACE_MS = 10_000 +export const AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS = 30_000 +export const RUNTIME_GRAPH_SYNC_COALESCE_MS = 16 + +export const EMPTY_ACTIVE_BROWSER_TAB_ID_BY_WORKTREE: AppState['activeBrowserTabIdByWorktree'] = {} +export const EMPTY_BROWSER_TABS_BY_WORKTREE: AppState['browserTabsByWorktree'] = {} +export const EMPTY_BROWSER_PAGES_BY_WORKSPACE: AppState['browserPagesByWorkspace'] = {} +export const EMPTY_LAYOUT_BY_WORKTREE: AppState['layoutByWorktree'] = {} +export const EMPTY_AGENT_STATUS_BY_PANE_KEY: AppState['agentStatusByPaneKey'] = {} +export const EMPTY_NARROWED_BY_KEY: ReadonlyMap = new Map() +export const EMPTY_WORKTREE_TERMINAL_TABS: AppState['tabsByWorktree'][string] = [] +export const EMPTY_WORKTREE_BROWSER_WORKSPACES: AppState['browserTabsByWorktree'][string] = [] +export const EMPTY_WORKTREE_UNIFIED_TABS: AppState['unifiedTabsByWorktree'][string] = [] +export const EMPTY_WORKTREE_TAB_GROUPS: AppState['groupsByWorktree'][string] = [] +export const EMPTY_WORKTREE_OPEN_FILE_IDS: readonly string[] = [] + +export const mobilePublicationEpoch = `renderer:${createBrowserUuid()}` + +export const graphState = { + registeredTabs: new Map(), + tabRegisteredAt: new Map(), + syncScheduled: false, + syncInFlight: false, + syncPendingAfterFlight: false, + syncEnabled: false, + syncTimer: null as ReturnType | null, + getStoreState: null as (() => AppState) | null, + mobileSessionSnapshotVersion: 0, + mobileSessionSnapshotCacheByWorktree: new Map< + string, + { + inputs: MobileSessionWorktreeInputs + content: unknown + snapshot: RuntimeMobileSessionTabsSnapshot + } + >(), + publishedMobileSessionSnapshotByWorktree: new Map(), + cachedTabsProjection: null as TabsProjectionCache | null, + cachedAgentStatusProjection: null as AgentStatusProjectionCache | null, + cachedOpenFileIndexesSource: null as AppState['openFiles'] | null, + cachedOpenFileIndexes: null as OpenFileIndexes | null, + cachedEditorDraftsSource: null as AppState['editorDrafts'] | null, + cachedEditorDraftVersionByFileId: null as Map | null, + cachedMobileTerminalThemeSettings: null as AppState['settings'] | null, + cachedMobileTerminalThemeSystemPrefersDark: null as boolean | null, + cachedMobileTerminalTheme: undefined as RuntimeMobileTerminalTheme | undefined, + hasCachedMobileTerminalTheme: false +} + +// Structural equality under JSON-serialization semantics (undefined-valued keys are absent). +export function jsonContentEquals(a: unknown, b: unknown): boolean { + if (a === b) { + return true + } + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { + return false + } + return a.every((item, index) => jsonContentEquals(item, b[index])) + } + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) { + return false + } + const aRecord = a as Record + const bRecord = b as Record + for (const key of Object.keys(aRecord)) { + if (!jsonContentEquals(aRecord[key], bRecord[key])) { + return false + } + } + for (const key of Object.keys(bRecord)) { + if (bRecord[key] !== undefined && aRecord[key] === undefined) { + return false + } + } + return true +} + +export type MobileSessionContent = { + activeGroupId: string | null + activeTabId: string | null + activeTabType: RuntimeMobileSessionSnapshotTab['type'] | null + tabGroups?: NonNullable + tabGroupLayout?: RuntimeMobileSessionTabsSnapshot['tabGroupLayout'] + tabs: RuntimeMobileSessionSnapshotTab[] +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-browser-tabs.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-browser-tabs.ts new file mode 100644 index 00000000000..e1621493832 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-browser-tabs.ts @@ -0,0 +1,38 @@ +import type { AppState } from '@/store/types' +import type { RuntimeMobileSessionBrowserTab } from '../../../../shared/runtime-types' +import type { Tab } from '../../../../shared/tab-types' +import type { MobileSessionWorktreeInputs } from './types' +import { isUnifiedTabActiveInActiveGroup } from './mobile-session-tab-helpers' + +export function buildMobileBrowserTab( + inputs: MobileSessionWorktreeInputs, + workspace: NonNullable[number], + unifiedTab?: Tab +): RuntimeMobileSessionBrowserTab { + const pages = inputs.pagesByBrowserWorkspaceId.get(workspace.id) ?? [] + const activePage = pages.find((page) => page.id === workspace.activePageId) ?? pages[0] ?? null + const title = + activePage?.title || workspace.title || activePage?.url || workspace.url || 'Browser' + const unifiedTabId = unifiedTab?.id + return { + type: 'browser', + id: unifiedTabId ?? workspace.id, + title, + browserWorkspaceId: workspace.id, + browserPageId: activePage?.id ?? workspace.activePageId ?? null, + url: activePage?.url ?? workspace.url ?? 'about:blank', + loading: activePage?.loading ?? workspace.loading, + canGoBack: activePage?.canGoBack ?? workspace.canGoBack, + canGoForward: activePage?.canGoForward ?? workspace.canGoForward, + // Null means the active page cleared its failure; do not resurrect a stale workspace error. + loadError: activePage ? activePage.loadError : workspace.loadError, + certificateFailure: activePage + ? (inputs.certificateFailureByBrowserPageId.get(activePage.id) ?? null) + : null, + color: unifiedTab?.color ?? null, + isPinned: unifiedTab?.isPinned === true, + isActive: unifiedTabId + ? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId) + : inputs.activeBrowserWorkspaceId === workspace.id + } +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-capture.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-capture.ts new file mode 100644 index 00000000000..bb6f2a16480 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-capture.ts @@ -0,0 +1,189 @@ +import { + collectLeafIdsInOrder, + serializePaneTree +} from '@/components/terminal-pane/layout-serialization' +import type { AppState } from '@/store/types' +import type { + RegisteredTerminalTab, + MobileSessionWorktreeInputs, + MountedTerminalSurfaceCapture +} from './types' +import { EMPTY_NARROWED_BY_KEY, graphState, jsonContentEquals } from './graph-state' +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' + +export function narrowRecordByKeys( + source: Record | undefined, + keys: readonly string[] +): ReadonlyMap { + if (!source || keys.length === 0) { + return EMPTY_NARROWED_BY_KEY + } + let narrowed: Map | null = null + for (const key of keys) { + const value = source[key] + if (value === undefined) { + continue + } + narrowed ??= new Map() + narrowed.set(key, value) + } + return narrowed ?? EMPTY_NARROWED_BY_KEY +} + +export function narrowMapByKeys( + source: ReadonlyMap, + keys: readonly string[] +): ReadonlyMap { + if (source.size === 0 || keys.length === 0) { + return EMPTY_NARROWED_BY_KEY + } + let narrowed: Map | null = null + for (const key of keys) { + if (!source.has(key)) { + continue + } + narrowed ??= new Map() + narrowed.set(key, source.get(key) as T) + } + return narrowed ?? EMPTY_NARROWED_BY_KEY +} + +export function captureMountedTerminalSurfaces( + terminalTabs: AppState['tabsByWorktree'][string], + terminalLayoutsByTabId: AppState['terminalLayoutsByTabId'] +): ReadonlyMap { + let captures: Map | null = null + for (const tab of terminalTabs) { + const registered = graphState.registeredTabs.get(tab.id) + if (!registered) { + continue + } + captures ??= new Map() + captures.set(tab.id, captureMountedTerminalSurface(registered, terminalLayoutsByTabId[tab.id])) + } + return captures ?? EMPTY_NARROWED_BY_KEY +} + +function captureMountedTerminalSurface( + registered: RegisteredTerminalTab, + savedLayout: AppState['terminalLayoutsByTabId'][string] | undefined +): MountedTerminalSurfaceCapture { + const manager = registered.getManager() + const paneLeafIds = manager?.getPanes().map((pane) => pane.leafId) ?? [] + const activePane = manager?.getActivePane() ?? null + const firstChild = registered.getContainer()?.firstElementChild + // Mirrors getRuntimeLeafIdsForTerminal so captured pane ids cover every resolved leaf. + const effectiveLeafIds = + paneLeafIds.length > 0 + ? paneLeafIds + : collectLeafIdsInOrder(savedLayout?.root).filter(isTerminalLeafId) + const numericPaneIdByLeafId = new Map() + const ptyIdByNumericPaneId = new Map() + for (const leafId of effectiveLeafIds) { + const numericPaneId = manager?.getNumericIdForLeaf(leafId) ?? null + numericPaneIdByLeafId.set(leafId, numericPaneId) + if (numericPaneId !== null) { + ptyIdByNumericPaneId.set(numericPaneId, registered.getPtyIdForPane(numericPaneId)) + } + } + return { + paneLeafIds, + hasLiveActivePane: activePane !== null, + liveActiveLeafId: activePane !== null ? (manager?.getLeafId(activePane.id) ?? null) : null, + liveLayoutRoot: serializePaneTree( + typeof HTMLElement !== 'undefined' && firstChild instanceof HTMLElement ? firstChild : null + ), + numericPaneIdByLeafId, + ptyIdByNumericPaneId, + tabWideAgentHintLeafId: registered.getTabWideAgentHintLeafId() + } +} + +function narrowedEntriesEqual(a: ReadonlyMap, b: ReadonlyMap): boolean { + if (a === b) { + return true + } + if (a.size !== b.size) { + return false + } + for (const [key, value] of a) { + if (b.get(key) !== value) { + return false + } + } + return true +} + +function mountedTerminalSurfaceCaptureEquals( + a: MountedTerminalSurfaceCapture, + b: MountedTerminalSurfaceCapture +): boolean { + return ( + a.hasLiveActivePane === b.hasLiveActivePane && + a.liveActiveLeafId === b.liveActiveLeafId && + a.paneLeafIds.length === b.paneLeafIds.length && + a.paneLeafIds.every((leafId, index) => b.paneLeafIds[index] === leafId) && + narrowedEntriesEqual(a.numericPaneIdByLeafId, b.numericPaneIdByLeafId) && + narrowedEntriesEqual(a.ptyIdByNumericPaneId, b.ptyIdByNumericPaneId) && + a.tabWideAgentHintLeafId === b.tabWideAgentHintLeafId && + // Serialization allocates a fresh tree per capture, so compare its content. + jsonContentEquals(a.liveLayoutRoot, b.liveLayoutRoot) + ) +} + +export function mountedSurfaceCapturesEqual( + a: ReadonlyMap, + b: ReadonlyMap +): boolean { + if (a === b) { + return true + } + if (a.size !== b.size) { + return false + } + for (const [tabId, capture] of a) { + const other = b.get(tabId) + if (!other || !mountedTerminalSurfaceCaptureEquals(capture, other)) { + return false + } + } + return true +} + +export function canReuseMobileSessionSnapshot( + previous: MobileSessionWorktreeInputs, + next: MobileSessionWorktreeInputs +): boolean { + return ( + previous.worktreeId === next.worktreeId && + previous.terminalTabs === next.terminalTabs && + previous.browserWorkspaces === next.browserWorkspaces && + previous.unifiedTabs === next.unifiedTabs && + previous.groups === next.groups && + previous.tabBarOrder === next.tabBarOrder && + previous.activeGroupId === next.activeGroupId && + previous.tabGroupLayout === next.tabGroupLayout && + previous.openFilesById === next.openFilesById && + previous.openFileIds === next.openFileIds && + previous.activeEditorFileId === next.activeEditorFileId && + previous.activeEditorTabType === next.activeEditorTabType && + previous.activeTerminalTabId === next.activeTerminalTabId && + previous.activeBrowserWorkspaceId === next.activeBrowserWorkspaceId && + previous.generatedTitlesEnabled === next.generatedTitlesEnabled && + previous.terminalTheme === next.terminalTheme && + narrowedEntriesEqual(previous.terminalLayoutByTabId, next.terminalLayoutByTabId) && + narrowedEntriesEqual(previous.paneTitlesByTabId, next.paneTitlesByTabId) && + narrowedEntriesEqual(previous.launchDraftByPaneKey, next.launchDraftByPaneKey) && + narrowedEntriesEqual(previous.agentStatusByPaneKey, next.agentStatusByPaneKey) && + narrowedEntriesEqual(previous.editorDraftVersionByFileId, next.editorDraftVersionByFileId) && + narrowedEntriesEqual(previous.pagesByBrowserWorkspaceId, next.pagesByBrowserWorkspaceId) && + narrowedEntriesEqual( + previous.certificateFailureByBrowserPageId, + next.certificateFailureByBrowserPageId + ) && + mountedSurfaceCapturesEqual( + previous.mountedSurfaceCaptureByTabId, + next.mountedSurfaceCaptureByTabId + ) + ) +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.ts new file mode 100644 index 00000000000..3d10aa48dc4 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.ts @@ -0,0 +1,77 @@ +import type { AppState } from '@/store/types' +import type { + RuntimeMobileSessionFileTab, + RuntimeMobileSessionMarkdownTab +} from '../../../../shared/runtime-types' +import type { Tab } from '../../../../shared/tab-types' +import type { MobileSessionWorktreeInputs } from './types' +import { + isFileActiveEditorSurface, + isMobileFileDiffSource, + isUnifiedTabActiveInActiveGroup +} from './mobile-session-tab-helpers' + +export function buildMobileMarkdownTab( + inputs: MobileSessionWorktreeInputs, + file: AppState['openFiles'][number], + unifiedTab?: Tab +): RuntimeMobileSessionMarkdownTab | null { + if (file.mode !== 'edit' && file.mode !== 'markdown-preview') { + return null + } + if (file.language !== 'markdown' && file.mode !== 'markdown-preview') { + return null + } + const sourceFile = + file.mode === 'markdown-preview' && file.markdownPreviewSourceFileId + ? (inputs.openFilesById?.get(file.markdownPreviewSourceFileId) ?? file) + : file + const draftVersion = inputs.editorDraftVersionByFileId.get(sourceFile.id) + const title = file.relativePath.split(/[\\/]/).pop() || file.relativePath || 'Markdown' + const unifiedTabId = unifiedTab?.id + return { + type: 'markdown', + id: unifiedTabId ?? file.id, + title, + filePath: file.filePath, + relativePath: file.relativePath, + language: 'markdown', + mode: file.mode, + isDirty: file.isDirty || sourceFile.isDirty, + isActive: unifiedTabId + ? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId) + : isFileActiveEditorSurface(inputs, file), + sourceFileId: sourceFile.id, + sourceFilePath: sourceFile.filePath, + sourceRelativePath: sourceFile.relativePath, + documentVersion: draftVersion ?? `file:${sourceFile.id}`, + color: unifiedTab?.color ?? null, + isPinned: unifiedTab?.isPinned === true + } +} + +export function buildMobileFileTab( + inputs: MobileSessionWorktreeInputs, + file: AppState['openFiles'][number], + unifiedTab?: Tab +): RuntimeMobileSessionFileTab { + const title = file.relativePath.split(/[\\/]/).pop() || file.relativePath || 'File' + const diffSource = isMobileFileDiffSource(file.diffSource) ? file.diffSource : undefined + const unifiedTabId = unifiedTab?.id + return { + type: 'file', + id: unifiedTabId ?? file.id, + title, + filePath: file.filePath, + relativePath: file.relativePath, + language: file.language, + mode: file.mode === 'diff' ? 'diff' : 'edit', + ...(diffSource ? { diffSource } : {}), + isDirty: file.isDirty, + color: unifiedTab?.color ?? null, + isPinned: unifiedTab?.isPinned === true, + isActive: unifiedTabId + ? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId) + : isFileActiveEditorSurface(inputs, file) + } +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-group-projection.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-group-projection.ts new file mode 100644 index 00000000000..f590bc6f226 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-group-projection.ts @@ -0,0 +1,242 @@ +import type { RuntimeMobileSessionTabGroup } from '../../../../shared/runtime-types' +import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../../shared/tab-types' +import { + getActiveTabNavOrder, + getGroupVisibleTabOrder, + type VisibleTabRef +} from '../../components/tab-bar/group-tab-order' +import type { MobileSessionWorktreeInputs } from './types' +import { isEditorSurfaceTab } from './mobile-session-tab-helpers' + +export function getEditorUnifiedTabsForWorktree( + inputs: Pick +): Tab[] { + return inputs.unifiedTabs.filter(isEditorSurfaceTab) +} + +export function applyUnifiedEditorTabIdsToLegacyOrder( + order: readonly VisibleTabRef[], + inputs: Pick +): VisibleTabRef[] { + const unifiedEditorTabs = getEditorUnifiedTabsForWorktree(inputs) + if (unifiedEditorTabs.length === 0) { + return [...order] + } + const firstUnifiedTabByFileId = new Map() + for (const tab of unifiedEditorTabs) { + if (!firstUnifiedTabByFileId.has(tab.entityId)) { + firstUnifiedTabByFileId.set(tab.entityId, tab.id) + } + } + return order.map((item) => { + if (item.type !== 'editor' || item.tabId) { + return item + } + const tabId = firstUnifiedTabByFileId.get(item.id) + return tabId ? { ...item, tabId } : item + }) +} + +export function appendFallbackEditorTabsToGroups( + tabGroups: RuntimeMobileSessionTabGroup[] | undefined, + sourceGroups: readonly TabGroup[], + activeGroupId: string | null, + fallbackTabs: readonly { tabId: string; groupId: string | null }[], + activeTabId: string | null +): RuntimeMobileSessionTabGroup[] | undefined { + if (fallbackTabs.length === 0) { + return tabGroups + } + const result = [...(tabGroups ?? [])] + const sourceGroupsById = new Map(sourceGroups.map((group) => [group.id, group])) + const groupIndexById = new Map(result.map((group, index) => [group.id, index])) + const firstTargetGroupId = + result[0]?.id ?? + (activeGroupId && sourceGroupsById.has(activeGroupId) ? activeGroupId : null) ?? + sourceGroups[0]?.id ?? + null + const fallbackTabIdSet = new Set(fallbackTabs.map((tab) => tab.tabId)) + + for (const fallback of fallbackTabs) { + const targetGroupId = + fallback.groupId ?? + (activeGroupId && (groupIndexById.has(activeGroupId) || sourceGroupsById.has(activeGroupId)) + ? activeGroupId + : firstTargetGroupId) + if (!targetGroupId) { + continue + } + let targetIndex = groupIndexById.get(targetGroupId) + if (targetIndex === undefined) { + const sourceGroup = sourceGroupsById.get(targetGroupId) + const group: RuntimeMobileSessionTabGroup = { + id: targetGroupId, + activeTabId: sourceGroup?.activeTabId ?? null, + tabOrder: [], + recentTabIds: sourceGroup?.recentTabIds ?? [] + } + targetIndex = result.length + groupIndexById.set(targetGroupId, targetIndex) + result.push(group) + } + const group = result[targetIndex]! + if (!group.tabOrder.includes(fallback.tabId)) { + result[targetIndex] = { ...group, tabOrder: [...group.tabOrder, fallback.tabId] } + } + } + if (result.length === 0) { + return tabGroups + } + const activeFallbackTabId = activeTabId && fallbackTabIdSet.has(activeTabId) ? activeTabId : null + return result.map((group) => { + const tabOrder = [...group.tabOrder] + const tabOrderSet = new Set(tabOrder) + const activeFallbackTabIdForGroup = + activeFallbackTabId && tabOrderSet.has(activeFallbackTabId) ? activeFallbackTabId : null + const activeTabIdForGroup = + activeFallbackTabIdForGroup ?? + (group.activeTabId && tabOrderSet.has(group.activeTabId) ? group.activeTabId : null) + const recentTabIds = (group.recentTabIds ?? []).filter((tabId) => tabOrderSet.has(tabId)) + if ( + activeFallbackTabId && + tabOrderSet.has(activeFallbackTabId) && + !recentTabIds.includes(activeFallbackTabId) + ) { + recentTabIds.push(activeFallbackTabId) + } + return { ...group, activeTabId: activeTabIdForGroup, tabOrder, recentTabIds } + }) +} + +function getOrderedTabGroups( + groups: readonly TabGroup[], + layout: TabGroupLayoutNode | undefined +): TabGroup[] { + const byId = new Map(groups.map((group) => [group.id, group])) + const seen = new Set() + const ordered: TabGroup[] = [] + for (const groupId of collectTabGroupLayoutIds(layout)) { + const group = byId.get(groupId) + if (!group || seen.has(group.id)) { + continue + } + seen.add(group.id) + ordered.push(group) + } + for (const group of groups) { + if (!seen.has(group.id)) { + ordered.push(group) + } + } + return ordered +} + +function buildLegacyNavOrderView( + inputs: MobileSessionWorktreeInputs +): Parameters[0] { + const { worktreeId } = inputs + return { + activeGroupIdByWorktree: inputs.activeGroupId ? { [worktreeId]: inputs.activeGroupId } : {}, + groupsByWorktree: { [worktreeId]: inputs.groups }, + unifiedTabsByWorktree: { [worktreeId]: inputs.unifiedTabs }, + tabBarOrderByWorktree: inputs.tabBarOrder ? { [worktreeId]: inputs.tabBarOrder } : {}, + tabsByWorktree: { [worktreeId]: inputs.terminalTabs }, + openFiles: inputs.openFilesById ? [...inputs.openFilesById.values()] : [], + browserTabsByWorktree: { [worktreeId]: inputs.browserWorkspaces } + } +} + +export function buildMobileSessionGroupProjection( + inputs: MobileSessionWorktreeInputs, + ids: { terminalIds: string[]; editorIds: string[]; browserIds: string[] } +): { + order: VisibleTabRef[] + tabGroups?: RuntimeMobileSessionTabGroup[] + tabGroupLayout?: TabGroupLayoutNode | null +} { + const groups = inputs.groups + if (groups.length === 0) { + return { + order: applyUnifiedEditorTabIdsToLegacyOrder( + getActiveTabNavOrder(buildLegacyNavOrderView(inputs), inputs.worktreeId, { + editorIds: ids.editorIds + }), + inputs + ) + } + } + + const terminalIds = new Set(ids.terminalIds) + const editorIds = new Set(ids.editorIds) + const browserIds = new Set(ids.browserIds) + const order: VisibleTabRef[] = [] + const tabGroups: RuntimeMobileSessionTabGroup[] = [] + for (const group of getOrderedTabGroups(groups, inputs.tabGroupLayout)) { + const groupTabs = inputs.unifiedTabs.filter((tab) => tab.groupId === group.id) + const visibleOrder = getGroupVisibleTabOrder( + group, + groupTabs, + terminalIds, + editorIds, + browserIds, + new Set(), + true + ) + if (visibleOrder.length === 0) { + continue + } + const tabOrder = visibleOrder.map((item) => item.tabId ?? item.id) + const tabOrderSet = new Set(tabOrder) + for (const item of visibleOrder) { + order.push(item) + } + tabGroups.push({ + id: group.id, + activeTabId: + group.activeTabId && tabOrderSet.has(group.activeTabId) ? group.activeTabId : null, + tabOrder, + recentTabIds: group.recentTabIds?.filter((tabId) => tabOrderSet.has(tabId)) ?? [] + }) + } + const validGroupIds = new Set(tabGroups.map((group) => group.id)) + return { + order, + tabGroups, + tabGroupLayout: pruneTabGroupLayout(inputs.tabGroupLayout, validGroupIds) + } +} + +export function collectTabGroupLayoutIds(layout: TabGroupLayoutNode | undefined): string[] { + const result: string[] = [] + const visit = (node: TabGroupLayoutNode | undefined): void => { + if (!node) { + return + } + if (node.type === 'leaf') { + result.push(node.groupId) + return + } + visit(node.first) + visit(node.second) + } + visit(layout) + return result +} + +export function pruneTabGroupLayout( + layout: TabGroupLayoutNode | undefined, + validGroupIds: ReadonlySet +): 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 +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts new file mode 100644 index 00000000000..0df4fea53ef --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts @@ -0,0 +1,237 @@ +import type { AppState } from '@/store/types' +import { parsePaneKey, makePaneKey } from '../../../../shared/stable-pane-id' +import { nativeChatLaunchAgentForLeaf } from '../../components/native-chat/native-chat-leaf-routing' +import { + EMPTY_NARROWED_BY_KEY, + EMPTY_WORKTREE_BROWSER_WORKSPACES, + EMPTY_WORKTREE_OPEN_FILE_IDS, + EMPTY_WORKTREE_TAB_GROUPS, + EMPTY_WORKTREE_TERMINAL_TABS, + EMPTY_WORKTREE_UNIFIED_TABS, + EMPTY_LAYOUT_BY_WORKTREE, + graphState +} from './graph-state' +import type { + MobileSessionAgentStatusByWorktree, + MobileSessionPublicationInputs, + MobileSessionWorktreeInputs, + OpenFileIndexes +} from './types' +import { captureMountedTerminalSurfaces, narrowRecordByKeys } from './mobile-session-capture' +import { + getRuntimeLeafIdsForTerminal, + resolveMobileTabWideAgentHintLeafId +} from './mobile-session-tab-helpers' + +export function getOpenFileIndexes(openFiles: AppState['openFiles']): OpenFileIndexes { + if (graphState.cachedOpenFileIndexesSource === openFiles && graphState.cachedOpenFileIndexes) { + return graphState.cachedOpenFileIndexes + } + const byWorktreeAndId = new Map>() + const idsByWorktree = new Map() + for (const file of openFiles) { + let filesById = byWorktreeAndId.get(file.worktreeId) + if (!filesById) { + filesById = new Map() + byWorktreeAndId.set(file.worktreeId, filesById) + } + let ids = idsByWorktree.get(file.worktreeId) + if (!ids) { + ids = [] + idsByWorktree.set(file.worktreeId, ids) + } + if (!filesById.has(file.id)) { + filesById.set(file.id, file) + ids.push(file.id) + } + } + graphState.cachedOpenFileIndexesSource = openFiles + graphState.cachedOpenFileIndexes = { byWorktreeAndId, idsByWorktree } + return graphState.cachedOpenFileIndexes +} + +export function getEditorDraftVersionByFileId( + editorDrafts: AppState['editorDrafts'] +): Map { + if ( + graphState.cachedEditorDraftsSource === editorDrafts && + graphState.cachedEditorDraftVersionByFileId + ) { + return graphState.cachedEditorDraftVersionByFileId + } + const versions = new Map() + for (const [fileId, content] of Object.entries(editorDrafts)) { + let hash = 2166136261 + for (let index = 0; index < content.length; index += 1) { + hash ^= content.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + versions.set(fileId, `draft:${content.length}:${(hash >>> 0).toString(16)}`) + } + graphState.cachedEditorDraftsSource = editorDrafts + graphState.cachedEditorDraftVersionByFileId = versions + return versions +} + +export function buildMobileSessionAgentStatusByWorktree( + agentStatusByPaneKey: AppState['agentStatusByPaneKey'], + tabsByWorktree: AppState['tabsByWorktree'] +): MobileSessionAgentStatusByWorktree { + const byWorktreeId = new Map>() + const paneKeys = Object.keys(agentStatusByPaneKey) + if (paneKeys.length === 0) { + return byWorktreeId + } + const worktreeIdByTabId = new Map() + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + for (const tab of tabs) { + worktreeIdByTabId.set(tab.id, worktreeId) + } + } + for (const paneKey of paneKeys) { + const tabId = parsePaneKey(paneKey)?.tabId + const worktreeId = tabId === undefined ? undefined : worktreeIdByTabId.get(tabId) + if (worktreeId === undefined) { + continue + } + let bucket = byWorktreeId.get(worktreeId) + if (!bucket) { + bucket = new Map() + byWorktreeId.set(worktreeId, bucket) + } + bucket.set(paneKey, agentStatusByPaneKey[paneKey]) + } + return byWorktreeId +} + +export function buildMobileSessionWorktreeInputs( + state: AppState, + worktreeId: string, + publication: MobileSessionPublicationInputs +): MobileSessionWorktreeInputs { + const terminalTabs = state.tabsByWorktree[worktreeId] ?? EMPTY_WORKTREE_TERMINAL_TABS + const terminalTabIds = terminalTabs.map((tab) => tab.id) + const terminalLayoutByTabId = narrowRecordByKeys(state.terminalLayoutsByTabId, terminalTabIds) + const mountedSurfaceCaptureByTabId = captureMountedTerminalSurfaces( + terminalTabs, + state.terminalLayoutsByTabId + ) + const browserWorkspaces = + publication.browserTabsByWorktree[worktreeId] ?? EMPTY_WORKTREE_BROWSER_WORKSPACES + const pagesByBrowserWorkspaceId = narrowRecordByKeys( + state.browserPagesByWorkspace, + browserWorkspaces.map((workspace) => workspace.id) + ) + const browserPageIds: string[] = [] + for (const pages of pagesByBrowserWorkspaceId.values()) { + for (const page of pages) { + browserPageIds.push(page.id) + } + } + const openFilesById = publication.openFileIndexes.byWorktreeAndId.get(worktreeId) + const openFileIds = + publication.openFileIndexes.idsByWorktree.get(worktreeId) ?? EMPTY_WORKTREE_OPEN_FILE_IDS + const resolvedActiveFileId = state.activeFileIdByWorktree?.[worktreeId] ?? state.activeFileId + const activeEditorFileId = + resolvedActiveFileId && openFilesById?.has(resolvedActiveFileId) ? resolvedActiveFileId : null + const activeTabId = state.activeTabId + return { + worktreeId, + terminalTabs, + browserWorkspaces, + unifiedTabs: state.unifiedTabsByWorktree[worktreeId] ?? EMPTY_WORKTREE_UNIFIED_TABS, + groups: state.groupsByWorktree[worktreeId] ?? EMPTY_WORKTREE_TAB_GROUPS, + tabBarOrder: state.tabBarOrderByWorktree[worktreeId], + activeGroupId: state.activeGroupIdByWorktree[worktreeId] ?? null, + tabGroupLayout: (state.layoutByWorktree ?? EMPTY_LAYOUT_BY_WORKTREE)[worktreeId], + openFilesById, + openFileIds, + terminalLayoutByTabId, + paneTitlesByTabId: narrowRecordByKeys(state.runtimePaneTitlesByTabId, terminalTabIds), + launchDraftByPaneKey: buildMobileLaunchDraftsByPaneKey({ + terminalTabs, + terminalLayoutByTabId, + mountedSurfaceCaptureByTabId, + launchDraftByTabId: narrowRecordByKeys(state.nativeChatLaunchDraftByTabId, terminalTabIds) + }), + agentStatusByPaneKey: + publication.agentStatusByWorktreeId.get(worktreeId) ?? EMPTY_NARROWED_BY_KEY, + editorDraftVersionByFileId: narrowMapByKeys( + publication.editorDraftVersionByFileId, + openFileIds + ), + pagesByBrowserWorkspaceId, + certificateFailureByBrowserPageId: narrowRecordByKeys( + state.browserCertificateFailuresByPageId, + browserPageIds + ), + activeEditorFileId, + activeEditorTabType: activeEditorFileId + ? (state.activeTabTypeByWorktree?.[worktreeId] ?? state.activeTabType) + : null, + activeTerminalTabId: + activeTabId !== null && terminalTabIds.includes(activeTabId) ? activeTabId : null, + activeBrowserWorkspaceId: state.activeBrowserTabIdByWorktree?.[worktreeId] ?? null, + generatedTitlesEnabled: publication.generatedTitlesEnabled, + terminalTheme: publication.terminalTheme, + mountedSurfaceCaptureByTabId + } +} + +function narrowMapByKeys( + source: ReadonlyMap, + keys: readonly string[] +): ReadonlyMap { + if (source.size === 0 || keys.length === 0) { + return EMPTY_NARROWED_BY_KEY + } + let narrowed: Map | null = null + for (const key of keys) { + if (!source.has(key)) { + continue + } + narrowed ??= new Map() + narrowed.set(key, source.get(key) as T) + } + return narrowed ?? EMPTY_NARROWED_BY_KEY +} + +export function buildMobileLaunchDraftsByPaneKey(args: { + terminalTabs: AppState['tabsByWorktree'][string] + terminalLayoutByTabId: MobileSessionWorktreeInputs['terminalLayoutByTabId'] + mountedSurfaceCaptureByTabId: MobileSessionWorktreeInputs['mountedSurfaceCaptureByTabId'] + launchDraftByTabId: ReadonlyMap< + string, + NonNullable[string] + > +}): MobileSessionWorktreeInputs['launchDraftByPaneKey'] { + if (args.launchDraftByTabId.size === 0) { + return EMPTY_NARROWED_BY_KEY + } + let draftsByPaneKey: Map< + string, + NonNullable[string] + > | null = null + for (const terminal of args.terminalTabs) { + const draft = args.launchDraftByTabId.get(terminal.id) + if (!draft || draft.resolved) { + continue + } + const capture = args.mountedSurfaceCaptureByTabId.get(terminal.id) + const savedLayout = args.terminalLayoutByTabId.get(terminal.id) + const leafIds = getRuntimeLeafIdsForTerminal(capture, savedLayout) + const ownerLeafId = resolveMobileTabWideAgentHintLeafId(capture, savedLayout) + const launchAgent = nativeChatLaunchAgentForLeaf({ + launchAgent: terminal.launchAgent, + launchAgentLeafId: ownerLeafId, + leafId: ownerLeafId, + leafIds + }) + if (!launchAgent || launchAgent !== draft.agent || !ownerLeafId) { + continue + } + draftsByPaneKey ??= new Map() + draftsByPaneKey.set(makePaneKey(terminal.id, ownerLeafId), draft) + } + return draftsByPaneKey ?? EMPTY_NARROWED_BY_KEY +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts new file mode 100644 index 00000000000..976da56f9e4 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts @@ -0,0 +1,221 @@ +import { getSystemPrefersDark } from '@/lib/terminal-theme' +import type { AppState } from '@/store/types' +import type { + RuntimeMobileSessionSnapshotTab, + RuntimeMobileSessionTabsSnapshot +} from '../../../../shared/runtime-types' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { graphState, jsonContentEquals, mobilePublicationEpoch } from './graph-state' +import { + buildMobileSessionAgentStatusByWorktree, + buildMobileSessionWorktreeInputs, + getEditorDraftVersionByFileId, + getOpenFileIndexes +} from './mobile-session-inputs' +import { getMobileTerminalTheme } from './mobile-terminal-theme' +import { + isMobilePublishableBrowserWorkspace, + isMobilePublishableOpenFile, + isWebOnlyMirroredTerminalTab +} from './mobile-session-tab-helpers' +import { + appendFallbackEditorTabsToGroups, + buildMobileSessionGroupProjection, + pruneTabGroupLayout +} from './mobile-session-group-projection' +import { buildMobileTerminalSurfaceTabs } from './mobile-session-terminal-tabs' +import { buildMobileMarkdownTab, buildMobileFileTab } from './mobile-session-editor-tabs' +import { buildMobileBrowserTab } from './mobile-session-browser-tabs' +import type { MobileSessionPublicationInputs } from './types' +import { canReuseMobileSessionSnapshot } from './mobile-session-capture' + +export function buildMobileSessionTabSnapshots( + state: AppState, + systemPrefersDark = getSystemPrefersDark() +): RuntimeMobileSessionTabsSnapshot[] { + const openFileIndexes = getOpenFileIndexes(state.openFiles) + const browserTabsByWorktree = state.browserTabsByWorktree ?? {} + const publicationInputs: MobileSessionPublicationInputs = { + browserTabsByWorktree, + openFileIndexes, + editorDraftVersionByFileId: getEditorDraftVersionByFileId(state.editorDrafts), + agentStatusByWorktreeId: buildMobileSessionAgentStatusByWorktree( + state.agentStatusByPaneKey ?? {}, + state.tabsByWorktree + ), + generatedTitlesEnabled: state.settings?.tabAutoGenerateTitle === true, + terminalTheme: getMobileTerminalTheme(state, systemPrefersDark) + } + const liveFolderWorkspaceIds = new Set( + (state.folderWorkspaces ?? []).map((workspace) => workspace.id) + ) + const worktreeIds = new Set([ + ...Object.keys(state.tabsByWorktree), + ...Object.keys(state.groupsByWorktree), + ...Object.keys(state.unifiedTabsByWorktree), + ...Object.keys(browserTabsByWorktree), + ...state.openFiles.map((file) => file.worktreeId) + ]) + const snapshots: RuntimeMobileSessionTabsSnapshot[] = [] + + for (const worktreeId of worktreeIds) { + const workspaceScope = parseWorkspaceKey(worktreeId) + if ( + workspaceScope?.type === 'folder' && + !liveFolderWorkspaceIds.has(workspaceScope.folderWorkspaceId) + ) { + graphState.mobileSessionSnapshotCacheByWorktree.delete(worktreeId) + continue + } + const inputs = buildMobileSessionWorktreeInputs(state, worktreeId, publicationInputs) + const cached = graphState.mobileSessionSnapshotCacheByWorktree.get(worktreeId) + if (cached && canReuseMobileSessionSnapshot(cached.inputs, inputs)) { + snapshots.push(cached.snapshot) + continue + } + const terminalTabById = new Map(inputs.terminalTabs.map((tab) => [tab.id, tab])) + const browserWorkspaceById = new Map( + inputs.browserWorkspaces.map((workspace) => [workspace.id, workspace]) + ) + const unifiedTabById = new Map(inputs.unifiedTabs.map((tab) => [tab.id, tab])) + const openFilesForWorktree = inputs.openFilesById + const editorIds = inputs.openFileIds.filter((fileId) => { + const file = openFilesForWorktree?.get(fileId) + return file ? isMobilePublishableOpenFile(file) : false + }) + const terminalIds = [...terminalTabById.values()] + .filter( + (terminal) => + !isWebOnlyMirroredTerminalTab(terminal, inputs.terminalLayoutByTabId.get(terminal.id)) + ) + .map((terminal) => terminal.id) + const groupProjection = buildMobileSessionGroupProjection(inputs, { + terminalIds, + editorIds, + browserIds: [...browserWorkspaceById.values()] + .filter(isMobilePublishableBrowserWorkspace) + .map((workspace) => workspace.id) + }) + const tabs: RuntimeMobileSessionSnapshotTab[] = [] + const emittedEditorFileIds = new Set() + const emittedEditorTabIds = new Set() + + for (const item of groupProjection.order) { + if (item.type === 'terminal') { + const terminal = terminalTabById.get(item.id) + if ( + !terminal || + isWebOnlyMirroredTerminalTab(terminal, inputs.terminalLayoutByTabId.get(terminal.id)) + ) { + continue + } + tabs.push(...buildMobileTerminalSurfaceTabs(inputs, terminal, item.tabId)) + } else if (item.type === 'editor') { + const file = openFilesForWorktree?.get(item.id) + if (!file || !isMobilePublishableOpenFile(file)) { + continue + } + const unifiedTab = item.tabId ? unifiedTabById.get(item.tabId) : undefined + tabs.push( + buildMobileMarkdownTab(inputs, file, unifiedTab) ?? + buildMobileFileTab(inputs, file, unifiedTab) + ) + emittedEditorFileIds.add(file.id) + emittedEditorTabIds.add(item.tabId ?? item.id) + } else if (item.type === 'browser') { + const workspace = browserWorkspaceById.get(item.id) + if (!workspace || !isMobilePublishableBrowserWorkspace(workspace)) { + continue + } + tabs.push( + buildMobileBrowserTab( + inputs, + workspace, + item.tabId ? unifiedTabById.get(item.tabId) : undefined + ) + ) + } + } + + const fallbackEditorTabs: { tabId: string; groupId: string | null }[] = [] + if (openFilesForWorktree) { + const unifiedEditorTabs = inputs.unifiedTabs.filter( + (tab) => tab.contentType === 'editor' || tab.contentType === 'diff' + ) + const unifiedEditorFileIds = new Set(unifiedEditorTabs.map((tab) => tab.entityId)) + for (const unifiedTab of unifiedEditorTabs) { + if (emittedEditorTabIds.has(unifiedTab.id)) { + continue + } + const file = openFilesForWorktree.get(unifiedTab.entityId) + if (!file || !isMobilePublishableOpenFile(file)) { + continue + } + const fallbackTab = + buildMobileMarkdownTab(inputs, file, unifiedTab) ?? + buildMobileFileTab(inputs, file, unifiedTab) + tabs.push(fallbackTab) + fallbackEditorTabs.push({ tabId: fallbackTab.id, groupId: unifiedTab.groupId }) + emittedEditorTabIds.add(unifiedTab.id) + } + for (const file of openFilesForWorktree.values()) { + if (!isMobilePublishableOpenFile(file) || emittedEditorFileIds.has(file.id)) { + continue + } + if (unifiedEditorFileIds.has(file.id)) { + emittedEditorFileIds.add(file.id) + continue + } + const fallbackTab = buildMobileMarkdownTab(inputs, file) ?? buildMobileFileTab(inputs, file) + tabs.push(fallbackTab) + fallbackEditorTabs.push({ tabId: fallbackTab.id, groupId: null }) + emittedEditorFileIds.add(file.id) + } + } + + const active = tabs.find((tab) => tab.isActive) ?? null + const tabGroups = appendFallbackEditorTabsToGroups( + groupProjection.tabGroups, + inputs.groups, + inputs.activeGroupId, + fallbackEditorTabs, + active?.id ?? null + ) + const tabGroupLayout = + tabGroups && tabGroups.length > 0 + ? pruneTabGroupLayout(inputs.tabGroupLayout, new Set(tabGroups.map((group) => group.id))) + : groupProjection.tabGroupLayout + const content = { + activeGroupId: inputs.activeGroupId, + activeTabId: active?.id ?? null, + activeTabType: active?.type ?? null, + ...(tabGroups && tabGroups.length > 0 ? { tabGroups } : {}), + ...(tabGroupLayout ? { tabGroupLayout } : {}), + tabs + } + const candidateVersion = ++graphState.mobileSessionSnapshotVersion + if (cached && jsonContentEquals(cached.content, content)) { + graphState.mobileSessionSnapshotCacheByWorktree.set(worktreeId, { + inputs, + content, + snapshot: cached.snapshot + }) + snapshots.push(cached.snapshot) + continue + } + const snapshot: RuntimeMobileSessionTabsSnapshot = { + worktree: worktreeId, + publicationEpoch: mobilePublicationEpoch, + snapshotVersion: candidateVersion, + ...content + } + graphState.mobileSessionSnapshotCacheByWorktree.set(worktreeId, { inputs, content, snapshot }) + snapshots.push(snapshot) + } + for (const worktreeId of graphState.mobileSessionSnapshotCacheByWorktree.keys()) { + if (!worktreeIds.has(worktreeId)) { + graphState.mobileSessionSnapshotCacheByWorktree.delete(worktreeId) + } + } + return snapshots +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-tab-helpers.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-tab-helpers.ts new file mode 100644 index 00000000000..9012a0fc0d1 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-tab-helpers.ts @@ -0,0 +1,110 @@ +import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization' +import type { AppState } from '@/store/types' +import { isClaudeManagementTitle } from '../../../../shared/agent-detection' +import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' +import { isWebTerminalSurfaceTabId } from '../../../../shared/terminal-surface-id' +import type { Tab } from '../../../../shared/tab-types' +import { parseRemoteRuntimePtyId } from '../runtime-terminal-stream' +import { + isNativeChatTabWideFallbackSafe, + nativeChatLaunchAgentForLeaf, + resolveNativeChatActiveLayoutLeafId +} from '../../components/native-chat/native-chat-leaf-routing' +import type { MobileSessionWorktreeInputs, MountedTerminalSurfaceCapture } from './types' + +export function mobileTerminalSurfaceId(parentTabId: string, leafId: string): string { + return `${parentTabId}::${leafId}` +} + +export function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean { + return typeof ptyId === 'string' && parseRemoteRuntimePtyId(ptyId) !== null +} + +export function isWebOnlyMirroredTerminalTab( + tab: Pick[number], 'id' | 'ptyId'>, + layout: AppState['terminalLayoutsByTabId'][string] | undefined +): boolean { + if (!isWebTerminalSurfaceTabId(tab.id)) { + return false + } + const layoutPtyIds = Object.values(layout?.ptyIdsByLeafId ?? {}) + const ptyIds = [tab.ptyId, ...layoutPtyIds].filter( + (ptyId): ptyId is string => typeof ptyId === 'string' && ptyId.length > 0 + ) + // Only-remote/no-PTY tabs are web mirrors; legacy local-PTY tabs still publish. + return ptyIds.every(isRemoteRuntimePtyId) +} + +export function getRuntimeLeafIdsForTerminal( + capture: MountedTerminalSurfaceCapture | undefined, + savedLayout: AppState['terminalLayoutsByTabId'][string] | undefined +): readonly string[] { + const liveLeafIds = capture?.paneLeafIds ?? [] + if (liveLeafIds.length > 0) { + return liveLeafIds + } + const persistedLeafIds = collectLeafIdsInOrder(savedLayout?.root).filter(isTerminalLeafId) + if (persistedLeafIds.length > 0) { + return persistedLeafIds + } + // A new tab can predate TerminalPane mount; do not fabricate a stale pane:1. + return [] +} + +export function resolveMobileTabWideAgentHintLeafId( + capture: MountedTerminalSurfaceCapture | undefined, + savedLayout: AppState['terminalLayoutsByTabId'][string] | undefined +): string | null { + if (capture) { + return capture.tabWideAgentHintLeafId + } + return isNativeChatTabWideFallbackSafe(savedLayout) + ? resolveNativeChatActiveLayoutLeafId(savedLayout) + : null +} + +export function isEditorSurfaceTab(tab: Pick): boolean { + // Mobile can mirror ordinary edit/diff files; other editor tabs need extra metadata. + return tab.contentType === 'editor' || tab.contentType === 'diff' +} + +export function isFileActiveEditorSurface( + inputs: Pick, + file: Pick +): boolean { + return inputs.activeEditorTabType === 'editor' && inputs.activeEditorFileId === file.id +} + +export function isMobileFileDiffSource( + diffSource: AppState['openFiles'][number]['diffSource'] +): diffSource is 'staged' | 'unstaged' { + return diffSource === 'staged' || diffSource === 'unstaged' +} + +export function isMobilePublishableOpenFile(file: AppState['openFiles'][number]): boolean { + // Combined diff tabs use display labels as paths and need the desktop renderer. + return !( + file.diffSource === 'combined-all' || + file.diffSource === 'combined-uncommitted' || + file.diffSource === 'combined-branch' || + file.diffSource === 'combined-commit' + ) +} + +export function isMobilePublishableBrowserWorkspace( + workspace: NonNullable[number] +): boolean { + // Document previews are served through a desktop-only grant. + return !workspace.docLocation +} + +export function isUnifiedTabActiveInActiveGroup( + inputs: Pick, + unifiedTabId: string +): boolean { + return inputs.groups.some( + (group) => group.id === inputs.activeGroupId && group.activeTabId === unifiedTabId + ) +} + +export { isClaudeManagementTitle, isTerminalLeafId, makePaneKey, nativeChatLaunchAgentForLeaf } diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-terminal-tabs.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-terminal-tabs.ts new file mode 100644 index 00000000000..0d364c92970 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-terminal-tabs.ts @@ -0,0 +1,126 @@ +import { normalizeTerminalLayoutSnapshot } from '@/components/terminal-pane/layout-serialization' +import { sanitizeTerminalLayoutPaneTitles } from '@/lib/terminal-pane-title-sanitization' +import type { AppState } from '@/store/types' +import type { RuntimeMobileSessionSnapshotTab } from '../../../../shared/runtime-types' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' +import { + isNativeChatTabWideFallbackSafe, + nativeChatLaunchAgentForLeaf +} from '../../components/native-chat/native-chat-leaf-routing' +import type { MobileSessionWorktreeInputs } from './types' +import { + isClaudeManagementTitle, + isTerminalLeafId, + makePaneKey, + mobileTerminalSurfaceId, + getRuntimeLeafIdsForTerminal, + resolveMobileTabWideAgentHintLeafId, + isUnifiedTabActiveInActiveGroup +} from './mobile-session-tab-helpers' +import { resolveRuntimeTerminalTitle } from './sync-projections' +import { resolveTerminalLayoutRoot } from '../remote-terminal-layout-resolution' + +export function buildMobileTerminalSurfaceTabs( + inputs: MobileSessionWorktreeInputs, + terminal: NonNullable[number], + unifiedTabId?: string +): RuntimeMobileSessionSnapshotTab[] { + const capture = inputs.mountedSurfaceCaptureByTabId.get(terminal.id) + const isDesktopTabActive = unifiedTabId + ? isUnifiedTabActiveInActiveGroup(inputs, unifiedTabId) + : inputs.activeTerminalTabId === terminal.id + const savedLayout = inputs.terminalLayoutByTabId.get(terminal.id) + const leafIds = getRuntimeLeafIdsForTerminal(capture, savedLayout) + const launchAgentLeafId = resolveMobileTabWideAgentHintLeafId(capture, savedLayout) + const activeLeafId = capture?.hasLiveActivePane + ? capture.liveActiveLeafId + : (savedLayout?.activeLeafId ?? leafIds[0] ?? null) + const paneTitles = inputs.paneTitlesByTabId.get(terminal.id) ?? {} + const sanitizedSavedLayout = savedLayout + ? sanitizeTerminalLayoutPaneTitles(savedLayout, terminal) + : undefined + const savedPtyIdsByLeafId = sanitizedSavedLayout?.ptyIdsByLeafId ?? {} + const liveLayoutRoot = capture?.liveLayoutRoot ?? null + const parentLayout = normalizeTerminalLayoutSnapshot({ + // Live DOM is authoritative when mounted; use saved tree otherwise. + root: resolveTerminalLayoutRoot({ + authoritativeRoot: liveLayoutRoot, + existingRoot: sanitizedSavedLayout?.root, + leafIds, + onSynthesize: (leafCount) => + console.warn( + `[sync-runtime-graph] synthesized parentLayout for ${leafCount} leaves with no live or saved tree` + ) + }), + activeLeafId, + expandedLeafId: sanitizedSavedLayout?.expandedLeafId ?? null, + ...(Object.keys(savedPtyIdsByLeafId).length > 0 ? { ptyIdsByLeafId: savedPtyIdsByLeafId } : {}), + ...(sanitizedSavedLayout?.titlesByLeafId + ? { titlesByLeafId: sanitizedSavedLayout.titlesByLeafId } + : {}) + } satisfies TerminalLayoutSnapshot).snapshot + + return leafIds.map((leafId) => { + const numericPaneId = capture?.numericPaneIdByLeafId.get(leafId) ?? null + const ptyId = + numericPaneId === null + ? (savedPtyIdsByLeafId[leafId] ?? (leafIds.length === 1 ? terminal.ptyId : null)) + : (capture?.ptyIdByNumericPaneId.get(numericPaneId) ?? savedPtyIdsByLeafId[leafId] ?? null) + const legacyPaneId = numericPaneId === null ? /^pane:(\d+)$/.exec(leafId)?.[1] : null + const paneTitle = + numericPaneId !== null + ? paneTitles[numericPaneId] + : legacyPaneId + ? paneTitles[Number(legacyPaneId)] + : undefined + const leafTitle = paneTitle?.trim() || sanitizedSavedLayout?.titlesByLeafId?.[leafId]?.trim() + const paneKey = isTerminalLeafId(leafId) ? makePaneKey(terminal.id, leafId) : null + const tabWideFallbackSafe = + isNativeChatTabWideFallbackSafe(parentLayout) && launchAgentLeafId === leafId + const title = tabWideFallbackSafe + ? resolveRuntimeTerminalTitle( + terminal, + inputs.generatedTitlesEnabled, + leafTitle ?? terminal.title ?? 'Terminal' + ) + : (leafTitle ?? 'Terminal') + const agentStatusTitle = leafTitle ?? (tabWideFallbackSafe ? terminal.title : '') ?? '' + const agentStatus = + paneKey && !isClaudeManagementTitle(agentStatusTitle) + ? inputs.agentStatusByPaneKey.get(paneKey) + : undefined + const launchAgent = nativeChatLaunchAgentForLeaf({ + launchAgent: terminal.launchAgent, + launchAgentLeafId, + leafId, + leafIds + }) + const launchDraft = paneKey ? inputs.launchDraftByPaneKey.get(paneKey) : undefined + const publishedLaunchDraft = + launchDraft && launchDraft.agent === launchAgent && launchDraft.text.trim() + ? launchDraft + : null + return { + type: 'terminal' as const, + id: mobileTerminalSurfaceId(terminal.id, leafId), + title, + ...(tabWideFallbackSafe && terminal.quickCommandLabel?.trim() + ? { quickCommandLabel: terminal.quickCommandLabel.trim() } + : {}), + parentTabId: terminal.id, + leafId, + ptyId, + ...(inputs.terminalTheme ? { terminalTheme: inputs.terminalTheme } : {}), + ...(agentStatus ? { agentStatus } : {}), + ...(launchAgent ? { launchAgent } : {}), + ...(publishedLaunchDraft + ? { + launchDraft: publishedLaunchDraft.text, + launchDraftCreatedAt: publishedLaunchDraft.createdAt + } + : {}), + parentLayout, + isActive: isDesktopTabActive && leafId === activeLeafId + } + }) +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-terminal-theme.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-terminal-theme.ts new file mode 100644 index 00000000000..ab9e2c05042 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-terminal-theme.ts @@ -0,0 +1,74 @@ +import { getSystemPrefersDark, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' +import type { AppState } from '@/store/types' +import type { RuntimeMobileTerminalTheme } from '../../../../shared/runtime-types' +import { graphState } from './graph-state' + +function hexToRgba(hex: string, alpha: number): string { + let clean = hex.replace('#', '') + if (clean.length === 3) { + clean = clean + .split('') + .map((character) => character + character) + .join('') + } + const r = Number.parseInt(clean.slice(0, 2), 16) + const g = Number.parseInt(clean.slice(2, 4), 16) + const b = Number.parseInt(clean.slice(4, 6), 16) + return `rgba(${r}, ${g}, ${b}, ${alpha})` +} + +function isHexColor(value: string | undefined): value is string { + return typeof value === 'string' && /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value) +} + +export function resolveMobileTerminalTheme( + state: AppState, + systemPrefersDark: boolean +): RuntimeMobileTerminalTheme | undefined { + const settings = state.settings + if (!settings) { + return undefined + } + const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark) + const resolvedTheme = appearance.theme + ? { ...appearance.theme, ...settings.terminalColorOverrides } + : undefined + if (!resolvedTheme) { + return undefined + } + if (settings.terminalBackgroundOpacity !== undefined && isHexColor(resolvedTheme.background)) { + resolvedTheme.background = hexToRgba( + resolvedTheme.background, + settings.terminalBackgroundOpacity + ) + } + if (settings.terminalCursorOpacity !== undefined && isHexColor(resolvedTheme.cursor)) { + resolvedTheme.cursor = hexToRgba(resolvedTheme.cursor, settings.terminalCursorOpacity) + } + + const theme: Record = {} + for (const [key, value] of Object.entries(resolvedTheme)) { + if (typeof value === 'string') { + theme[key] = value + } + } + return { mode: appearance.mode, theme: theme as RuntimeMobileTerminalTheme['theme'] } +} + +export function getMobileTerminalTheme( + state: AppState, + systemPrefersDark = getSystemPrefersDark() +): RuntimeMobileTerminalTheme | undefined { + if ( + graphState.hasCachedMobileTerminalTheme && + graphState.cachedMobileTerminalThemeSettings === state.settings && + graphState.cachedMobileTerminalThemeSystemPrefersDark === systemPrefersDark + ) { + return graphState.cachedMobileTerminalTheme + } + graphState.cachedMobileTerminalTheme = resolveMobileTerminalTheme(state, systemPrefersDark) + graphState.cachedMobileTerminalThemeSettings = state.settings + graphState.cachedMobileTerminalThemeSystemPrefersDark = systemPrefersDark + graphState.hasCachedMobileTerminalTheme = true + return graphState.cachedMobileTerminalTheme +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/sync-key.ts b/src/renderer/src/runtime/sync-runtime-graph/sync-key.ts new file mode 100644 index 00000000000..5eac69789cf --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/sync-key.ts @@ -0,0 +1,182 @@ +import { getSystemPrefersDark } from '@/lib/terminal-theme' +import type { AppState } from '@/store/types' +import { + EMPTY_ACTIVE_BROWSER_TAB_ID_BY_WORKTREE, + EMPTY_AGENT_STATUS_BY_PANE_KEY, + EMPTY_BROWSER_PAGES_BY_WORKSPACE, + EMPTY_BROWSER_TABS_BY_WORKTREE, + EMPTY_LAYOUT_BY_WORKTREE +} from './graph-state' +import { + buildRuntimeMobileAgentStatusProjection, + buildRuntimeMobileAgentStatusProjectionForTests, + resetRuntimeMobileAgentStatusProjectionCacheForTests, + AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS +} from './agent-status-projection' +import { + buildRuntimeMobileBrowserProjection, + buildRuntimeMobileEditorDraftsProjection, + buildRuntimeMobileOpenFilesProjection, + buildRuntimeMobileTabsProjection, + getBrowserPagesByWorkspace, + getBrowserTabsByWorktree +} from './sync-projections' +import { resolveMobileTerminalTheme } from './mobile-terminal-theme' +import type { RuntimeMobileSessionSyncKey } from './types' + +export { + AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS, + buildRuntimeMobileAgentStatusProjectionForTests, + resetRuntimeMobileAgentStatusProjectionCacheForTests +} +export type { RuntimeMobileSessionSyncKey } from './types' + +function getTerminalThemeSystemPrefersDark( + state: Pick, + systemPrefersDark: boolean | null | undefined +): boolean | null { + return state.settings?.theme === 'system' ? (systemPrefersDark ?? null) : null +} + +export function canSkipRuntimeMobileSessionSyncKeyBuild( + state: AppState, + previousState: AppState, + systemPrefersDark?: boolean, + previousSystemPrefersDark: boolean | null | undefined = systemPrefersDark +): boolean { + const currentThemePreference = getTerminalThemeSystemPrefersDark(state, systemPrefersDark) + const previousThemePreference = getTerminalThemeSystemPrefersDark( + previousState, + previousSystemPrefersDark + ) + return ( + currentThemePreference === previousThemePreference && + state.tabsByWorktree === previousState.tabsByWorktree && + state.groupsByWorktree === previousState.groupsByWorktree && + state.activeGroupIdByWorktree === previousState.activeGroupIdByWorktree && + state.layoutByWorktree === previousState.layoutByWorktree && + state.unifiedTabsByWorktree === previousState.unifiedTabsByWorktree && + state.tabBarOrderByWorktree === previousState.tabBarOrderByWorktree && + state.activeFileId === previousState.activeFileId && + state.activeFileIdByWorktree === previousState.activeFileIdByWorktree && + state.activeTabType === previousState.activeTabType && + state.activeTabTypeByWorktree === previousState.activeTabTypeByWorktree && + getBrowserTabsByWorktree(state) === getBrowserTabsByWorktree(previousState) && + getBrowserPagesByWorkspace(state) === getBrowserPagesByWorkspace(previousState) && + (state.activeBrowserTabIdByWorktree ?? EMPTY_ACTIVE_BROWSER_TAB_ID_BY_WORKTREE) === + (previousState.activeBrowserTabIdByWorktree ?? EMPTY_ACTIVE_BROWSER_TAB_ID_BY_WORKTREE) && + state.openFiles === previousState.openFiles && + state.editorDrafts === previousState.editorDrafts && + state.settings === previousState.settings && + state.activeTabId === previousState.activeTabId && + state.terminalLayoutsByTabId === previousState.terminalLayoutsByTabId && + state.runtimePaneTitlesByTabId === previousState.runtimePaneTitlesByTabId && + state.nativeChatLaunchDraftByTabId === previousState.nativeChatLaunchDraftByTabId && + state.folderWorkspaces === previousState.folderWorkspaces && + state.agentStatusEpoch === previousState.agentStatusEpoch && + state.agentStatusByPaneKey === previousState.agentStatusByPaneKey + ) +} + +export function getRuntimeMobileSessionSyncKey( + state: AppState, + previousState?: AppState, + previousKey?: RuntimeMobileSessionSyncKey, + systemPrefersDark = getSystemPrefersDark() +): RuntimeMobileSessionSyncKey { + const canReusePrevious = previousState !== undefined && previousKey !== undefined + const themeSystemPrefersDark = getTerminalThemeSystemPrefersDark(state, systemPrefersDark) + const browserTabsByWorktree = getBrowserTabsByWorktree(state) + const browserPagesByWorkspace = getBrowserPagesByWorkspace(state) + const agentStatusByPaneKey = state.agentStatusByPaneKey ?? EMPTY_AGENT_STATUS_BY_PANE_KEY + const previousBrowserTabsByWorktree = previousState + ? getBrowserTabsByWorktree(previousState) + : EMPTY_BROWSER_TABS_BY_WORKTREE + const previousBrowserPagesByWorkspace = previousState + ? getBrowserPagesByWorkspace(previousState) + : EMPTY_BROWSER_PAGES_BY_WORKSPACE + const previousAgentStatusByPaneKey = previousState + ? (previousState.agentStatusByPaneKey ?? EMPTY_AGENT_STATUS_BY_PANE_KEY) + : EMPTY_AGENT_STATUS_BY_PANE_KEY + return { + terminalLayoutsByTabId: state.terminalLayoutsByTabId, + runtimePaneTitlesByTabId: state.runtimePaneTitlesByTabId, + nativeChatLaunchDraftByTabId: state.nativeChatLaunchDraftByTabId, + folderWorkspaces: state.folderWorkspaces, + groupsByWorktree: state.groupsByWorktree, + activeGroupIdByWorktree: state.activeGroupIdByWorktree, + layoutByWorktree: state.layoutByWorktree ?? EMPTY_LAYOUT_BY_WORKTREE, + unifiedTabsByWorktree: state.unifiedTabsByWorktree, + tabBarOrderByWorktree: state.tabBarOrderByWorktree, + activeFileId: state.activeFileId, + activeFileIdByWorktree: state.activeFileIdByWorktree, + activeTabType: state.activeTabType, + activeTabTypeByWorktree: state.activeTabTypeByWorktree, + activeTabId: state.activeTabId, + activeBrowserTabIdByWorktree: + state.activeBrowserTabIdByWorktree ?? EMPTY_ACTIVE_BROWSER_TAB_ID_BY_WORKTREE, + agentStatusEpoch: state.agentStatusEpoch ?? 0, + agentStatusProjection: + canReusePrevious && agentStatusByPaneKey === previousAgentStatusByPaneKey + ? previousKey.agentStatusProjection + : buildRuntimeMobileAgentStatusProjection(agentStatusByPaneKey), + generatedTabTitlesEnabled: state.settings?.tabAutoGenerateTitle === true, + systemPrefersDark: themeSystemPrefersDark, + terminalThemeProjection: + canReusePrevious && + state.settings === previousState.settings && + previousKey.systemPrefersDark === themeSystemPrefersDark + ? previousKey.terminalThemeProjection + : JSON.stringify(resolveMobileTerminalTheme(state, systemPrefersDark) ?? null), + tabsProjection: + canReusePrevious && state.tabsByWorktree === previousState.tabsByWorktree + ? previousKey.tabsProjection + : buildRuntimeMobileTabsProjection(state.tabsByWorktree), + openFilesProjection: + canReusePrevious && state.openFiles === previousState.openFiles + ? previousKey.openFilesProjection + : buildRuntimeMobileOpenFilesProjection(state.openFiles), + browserProjection: + canReusePrevious && + browserTabsByWorktree === previousBrowserTabsByWorktree && + browserPagesByWorkspace === previousBrowserPagesByWorkspace + ? previousKey.browserProjection + : buildRuntimeMobileBrowserProjection(state), + editorDraftsProjection: + canReusePrevious && state.editorDrafts === previousState.editorDrafts + ? previousKey.editorDraftsProjection + : buildRuntimeMobileEditorDraftsProjection(state.editorDrafts) + } +} + +export function runtimeMobileSessionSyncKeysEqual( + a: RuntimeMobileSessionSyncKey, + b: RuntimeMobileSessionSyncKey +): boolean { + return ( + a.terminalLayoutsByTabId === b.terminalLayoutsByTabId && + a.runtimePaneTitlesByTabId === b.runtimePaneTitlesByTabId && + a.nativeChatLaunchDraftByTabId === b.nativeChatLaunchDraftByTabId && + a.folderWorkspaces === b.folderWorkspaces && + a.groupsByWorktree === b.groupsByWorktree && + a.activeGroupIdByWorktree === b.activeGroupIdByWorktree && + a.layoutByWorktree === b.layoutByWorktree && + a.unifiedTabsByWorktree === b.unifiedTabsByWorktree && + a.tabBarOrderByWorktree === b.tabBarOrderByWorktree && + a.activeFileId === b.activeFileId && + a.activeFileIdByWorktree === b.activeFileIdByWorktree && + a.activeTabType === b.activeTabType && + a.activeTabTypeByWorktree === b.activeTabTypeByWorktree && + a.activeTabId === b.activeTabId && + a.activeBrowserTabIdByWorktree === b.activeBrowserTabIdByWorktree && + a.agentStatusEpoch === b.agentStatusEpoch && + a.agentStatusProjection === b.agentStatusProjection && + a.generatedTabTitlesEnabled === b.generatedTabTitlesEnabled && + a.systemPrefersDark === b.systemPrefersDark && + a.terminalThemeProjection === b.terminalThemeProjection && + a.tabsProjection === b.tabsProjection && + a.openFilesProjection === b.openFilesProjection && + a.browserProjection === b.browserProjection && + a.editorDraftsProjection === b.editorDraftsProjection + ) +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts b/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts new file mode 100644 index 00000000000..911348fae64 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts @@ -0,0 +1,157 @@ +import type { AppState } from '@/store/types' +import { resolveTerminalTabTitle } from '../../../../shared/tab-title-resolution' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { + EMPTY_AGENT_STATUS_BY_PANE_KEY, + EMPTY_BROWSER_PAGES_BY_WORKSPACE, + EMPTY_BROWSER_TABS_BY_WORKTREE, + graphState +} from './graph-state' +import { buildRuntimeMobileAgentStatusProjection } from './agent-status-projection' + +export function getBrowserTabsByWorktree(state: AppState): AppState['browserTabsByWorktree'] { + // Some callers/tests build partial pre-browser states; treat missing slices as empty. + return state.browserTabsByWorktree ?? EMPTY_BROWSER_TABS_BY_WORKTREE +} + +export function getBrowserPagesByWorkspace(state: AppState): AppState['browserPagesByWorkspace'] { + return state.browserPagesByWorkspace ?? EMPTY_BROWSER_PAGES_BY_WORKSPACE +} + +export function resolveRuntimeTerminalTitle( + tab: Pick< + TerminalTab, + 'customTitle' | 'quickCommandLabel' | 'aiVaultTitle' | 'generatedTitle' | 'title' + >, + generatedTitlesEnabled: boolean, + liveTitle = tab.title +): string { + return resolveTerminalTabTitle({ ...tab, title: liveTitle }, generatedTitlesEnabled, liveTitle) +} + +export function buildRuntimeMobileTabsProjection( + tabsByWorktree: AppState['tabsByWorktree'] +): string { + if (graphState.cachedTabsProjection?.source === tabsByWorktree) { + return graphState.cachedTabsProjection.projection + } + + const previousEntries = graphState.cachedTabsProjection?.entries + const entries = new Map< + string, + { + tabs: NonNullable + worktreeIdJson: string + projection: string + } + >() + const parts: string[] = [] + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + const previous = previousEntries?.get(worktreeId) + const entry = + previous?.tabs === tabs + ? previous + : { + tabs, + worktreeIdJson: previous?.worktreeIdJson ?? JSON.stringify(worktreeId), + projection: JSON.stringify( + tabs.map((tab) => ({ + id: tab.id, + title: tab.title, + quickCommandLabel: tab.quickCommandLabel, + aiVaultTitle: tab.aiVaultTitle, + generatedTitle: tab.generatedTitle, + customTitle: tab.customTitle, + launchAgent: tab.launchAgent + })) + ) + } + entries.set(worktreeId, entry) + parts.push(`${entry.worktreeIdJson}:${entry.projection}`) + } + graphState.cachedTabsProjection = { + source: tabsByWorktree, + entries, + projection: `{${parts.join(',')}}` + } + return graphState.cachedTabsProjection.projection +} + +export function buildRuntimeMobileOpenFilesProjection(openFiles: AppState['openFiles']): string { + return JSON.stringify( + openFiles.map((file) => ({ + id: file.id, + filePath: file.filePath, + relativePath: file.relativePath, + worktreeId: file.worktreeId, + language: file.language, + mode: file.mode, + diffSource: file.diffSource, + isDirty: file.isDirty, + isUntitled: file.isUntitled, + deleteUntouchedOnClose: file.deleteUntouchedOnClose, + markdownPreviewSourceFileId: file.markdownPreviewSourceFileId + })) + ) +} + +export function buildRuntimeMobileBrowserProjection(state: AppState): string { + const browserTabsByWorktree = getBrowserTabsByWorktree(state) + const browserPagesByWorkspace = getBrowserPagesByWorkspace(state) + return JSON.stringify({ + workspacesByWorktree: Object.fromEntries( + Object.entries(browserTabsByWorktree).map(([worktreeId, workspaces]) => [ + worktreeId, + workspaces.map((workspace) => ({ + id: workspace.id, + activePageId: workspace.activePageId, + title: workspace.title, + url: workspace.url, + loading: workspace.loading, + canGoBack: workspace.canGoBack, + canGoForward: workspace.canGoForward + })) + ]) + ), + pagesByWorkspace: Object.fromEntries( + Object.entries(browserPagesByWorkspace).map(([workspaceId, pages]) => [ + workspaceId, + pages.map((page) => ({ + id: page.id, + title: page.title, + url: page.url, + loading: page.loading, + canGoBack: page.canGoBack, + canGoForward: page.canGoForward + })) + ]) + ) + }) +} + +export function buildRuntimeMobileEditorDraftsProjection( + editorDrafts: AppState['editorDrafts'] +): string { + return JSON.stringify( + Object.fromEntries( + Object.entries(editorDrafts).map(([fileId, content]) => [fileId, stableHashString(content)]) + ) + ) +} + +export function stableHashString(value: string): string { + let hash = 2166136261 + for (let i = 0; i < value.length; i += 1) { + hash ^= value.charCodeAt(i) + hash = Math.imul(hash, 16777619) + } + return `draft:${value.length}:${(hash >>> 0).toString(16)}` +} + +export function buildRuntimeMobileAgentStatusProjectionForState( + agentStatusByPaneKey: AppState['agentStatusByPaneKey'] | undefined +): string { + return buildRuntimeMobileAgentStatusProjection( + agentStatusByPaneKey ?? EMPTY_AGENT_STATUS_BY_PANE_KEY + ) +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/types.ts b/src/renderer/src/runtime/sync-runtime-graph/types.ts new file mode 100644 index 00000000000..85b1d961d5f --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/types.ts @@ -0,0 +1,142 @@ +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import type { AppState } from '@/store/types' +import type { RuntimeMobileTerminalTheme } from '../../../../shared/runtime-types' +import type { TabGroupLayoutNode } from '../../../../shared/tab-types' +import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' + +export type RuntimeMobileSessionSyncKey = { + // Reference changes signal layout/title updates without stringifying thousands of tabs. + terminalLayoutsByTabId: AppState['terminalLayoutsByTabId'] + runtimePaneTitlesByTabId: AppState['runtimePaneTitlesByTabId'] + nativeChatLaunchDraftByTabId: AppState['nativeChatLaunchDraftByTabId'] + folderWorkspaces: AppState['folderWorkspaces'] + groupsByWorktree: AppState['groupsByWorktree'] + activeGroupIdByWorktree: AppState['activeGroupIdByWorktree'] + layoutByWorktree: AppState['layoutByWorktree'] + unifiedTabsByWorktree: AppState['unifiedTabsByWorktree'] + tabBarOrderByWorktree: AppState['tabBarOrderByWorktree'] + activeFileId: AppState['activeFileId'] + activeFileIdByWorktree: AppState['activeFileIdByWorktree'] + activeTabType: AppState['activeTabType'] + activeTabTypeByWorktree: AppState['activeTabTypeByWorktree'] + activeTabId: AppState['activeTabId'] + activeBrowserTabIdByWorktree: AppState['activeBrowserTabIdByWorktree'] + agentStatusEpoch: number + agentStatusProjection: string + generatedTabTitlesEnabled: boolean + systemPrefersDark: boolean | null + terminalThemeProjection: string + tabsProjection: string + openFilesProjection: string + browserProjection: string + editorDraftsProjection: string +} + +export type RegisteredTerminalTab = { + tabId: string + worktreeId: string + getManager: () => PaneManager | null + getContainer: () => HTMLDivElement | null + getPtyIdForPane: (paneId: number) => string | null + getTabWideAgentHintLeafId: () => string | null +} + +export type OpenFileByWorktreeAndId = Map> +export type OpenFileIndexes = { + byWorktreeAndId: OpenFileByWorktreeAndId + idsByWorktree: Map +} +export type FallbackEditorTabTarget = { + tabId: string + groupId: string | null +} +export type TabsProjectionCacheEntry = { + tabs: NonNullable + worktreeIdJson: string + projection: string +} +export type TabsProjectionCache = { + source: AppState['tabsByWorktree'] + entries: Map + projection: string +} +export type AgentStatusProjectionCacheEntry = { + entry: AppState['agentStatusByPaneKey'][string] + projection: string +} +export type AgentStatusProjectionCache = { + source: AppState['agentStatusByPaneKey'] + entries: Map + projection: string +} +export type MobileSessionAgentStatusByWorktree = ReadonlyMap< + string, + ReadonlyMap +> +/** Slices shared by every worktree in one publication; derived from `AppState` exactly once. */ +export type MobileSessionPublicationInputs = { + browserTabsByWorktree: AppState['browserTabsByWorktree'] + openFileIndexes: OpenFileIndexes + editorDraftVersionByFileId: ReadonlyMap + agentStatusByWorktreeId: MobileSessionAgentStatusByWorktree + generatedTitlesEnabled: boolean + terminalTheme: RuntimeMobileTerminalTheme | undefined +} +/** + * Live PaneManager/DOM reads for one mounted terminal tab, captured once per + * publication. + * + * Why: builders read this instead of the registry, so live state the memo + * cannot witness is unrepresentable — mounted worktrees memoize like the rest + * instead of rebuilding on every publication. + */ +export type MountedTerminalSurfaceCapture = { + paneLeafIds: readonly string[] + hasLiveActivePane: boolean + liveActiveLeafId: string | null + liveLayoutRoot: TerminalPaneLayoutNode | null + numericPaneIdByLeafId: ReadonlyMap + ptyIdByNumericPaneId: ReadonlyMap + tabWideAgentHintLeafId: string | null +} +/** + * One worktree's complete mobile-snapshot input set. + * + * Why: every builder below takes this instead of `AppState`, so the compiler — + * not a reviewer — proves what a worktree's snapshot actually depends on. + */ +export type MobileSessionWorktreeInputs = { + worktreeId: string + terminalTabs: AppState['tabsByWorktree'][string] + browserWorkspaces: AppState['browserTabsByWorktree'][string] + unifiedTabs: AppState['unifiedTabsByWorktree'][string] + groups: AppState['groupsByWorktree'][string] + tabBarOrder: AppState['tabBarOrderByWorktree'][string] | undefined + activeGroupId: string | null + tabGroupLayout: TabGroupLayoutNode | undefined + openFilesById: ReadonlyMap | undefined + openFileIds: readonly string[] + terminalLayoutByTabId: ReadonlyMap + paneTitlesByTabId: ReadonlyMap + launchDraftByPaneKey: ReadonlyMap< + string, + NonNullable[string] + > + agentStatusByPaneKey: ReadonlyMap + editorDraftVersionByFileId: ReadonlyMap + pagesByBrowserWorkspaceId: ReadonlyMap< + string, + NonNullable[string] + > + certificateFailureByBrowserPageId: ReadonlyMap< + string, + NonNullable[string] + > + activeEditorFileId: string | null + activeEditorTabType: AppState['activeTabType'] | null + activeTerminalTabId: string | null + activeBrowserWorkspaceId: string | null + generatedTitlesEnabled: boolean + terminalTheme: RuntimeMobileTerminalTheme | undefined + mountedSurfaceCaptureByTabId: ReadonlyMap +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 83e5297cd6d..693fb3133cb 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -1,5663 +1,55 @@ -/* eslint-disable max-lines -- web session-tab sync reconciles terminal, unified-tab, group, and PTY maps atomically to avoid split-brain tab state */ -import { useEffect, useLayoutEffect, useRef } 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 { toRuntimeExecutionHostId } from '../../../shared/execution-host' -import { - AGENT_STATUS_STALE_AFTER_MS, - pickParsedAgentStatusPayload, - type AgentStatusEntry -} from '../../../shared/agent-status-types' -import { agentEntryCompletionAt } from '../../../shared/agent-completion-time' -import { normalizeTurnCompletedAtField } from '../../../shared/agent-status-field-normalization' -import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume' -import type { - RuntimeMobileSessionTabsResult, - RuntimeMobileSessionAgentTab, - RuntimeMobileSessionTabsRemovedResult, - RuntimeMobileSessionBrowserTab, - RuntimeMobileSessionFileTab, - RuntimeMobileSessionMarkdownTab, - RuntimeMobileSessionTabGroup, - RuntimeMobileSessionTerminalClientTab -} from '../../../shared/runtime-types' -import { hostSnapshotAffirmsClientHostedPages } from './host-session-snapshot-authority' -import type { - BrowserCertificateFailure, - BrowserPage, - BrowserWorkspace -} from '../../../shared/browser-workspace-types' -import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../shared/tab-types' -import { reconcileClientOwnedTabPlacement } from './web-session-client-owned-tab-placement' -import { - clearWebSessionTerminalPlacementsForEnvironment, - clearWebSessionTerminalPlacementsForWorktree, - peekWebSessionTerminalPlacementGroup -} from './web-session-terminal-placement' -import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' -import type { OpenFile } from '../store/slices/editor' -import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' -import { - buildRetiredTerminalTabStateSweepPatch, - type RetiredTerminalTabSweepState -} from '../store/slices/retired-terminal-tab-state-sweep' -import { getRemoteRuntimePtyEnvironmentId, toRemoteRuntimePtyId } from './runtime-terminal-stream' -import { sanitizeTerminalLayoutPaneTitlesForLabels } from '@/lib/terminal-pane-title-sanitization' -import { terminalLayoutEqual } from '@/lib/terminal-layout-equality' -import { normalizeTerminalLayoutPtyOwnership } from '@/components/terminal-pane/terminal-layout-pty-ownership' -import { isClientAuthoritativeAgentStatusPane } from '@/components/terminal-pane/renderer-owned-agent-status-registry' -import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { - clearHostSessionMirrorHydration, - markHostSessionMirrorHydrated, - markHostSessionMirrorWorktreeHydrated -} from './host-session-mirror-hydration' -import { probeHostLiveTerminals } from './host-live-terminal-probe' -import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' -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 { resolvePaneAgentOwnerRecord } from '../../../shared/pane-agent-owner' -import { resolveTerminalLayoutRoot } from './remote-terminal-layout-resolution' -import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' -import { readBrowserClientHostId } from './browser-client-host-identity' -import { - clearWebSessionFocusIntent, - clearWebSessionFocusIntentsForOwner, - peekWebSessionFocusIntent, - resolveWebSessionSiblingVisibleTabId, - resolveWebSessionVisibleTabId -} from './web-session-focus-intent' -import { - clearWebSessionCloseIntentsForOwner, - clearWebSessionCloseIntentsForWorktree, - isWebSessionCloseIntentPending, - reconcileWebSessionCloseIntents -} from './web-session-close-intent' -import { - clearWebSessionReorderIntentsForOwner, - 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' -import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' -import { queueAcceptedWebSessionTerminalSnapshot } from './web-session-terminal-handle-events' -import { recoverWebSessionTerminalOrphansBeforeApply } from './web-session-terminal-orphan-recovery' -import { - clearWebAgentSessionHandoff, - clearWebAgentSessionHandoffsForEnvironment, - clearWebAgentSessionHandoffsForWorktree, - isWebAgentSessionHandoffPostCreateSnapshotConfirmed, - resolveWebAgentSessionHandoff -} from './web-agent-session-handoff' -import { getRuntimeEnvironmentRevision } from './runtime-environment-revision' -import { useRuntimeSessionMirrorEnvironmentKey } from './use-runtime-session-mirror-environment-key' -import { - installWindowVisibilitySubscriptionParking, - type WindowVisibilitySubscriptionSpec -} from './window-visibility-subscription-parking' -import { - clearWebSessionBrowserPlacementsForEnvironment, - clearWebSessionBrowserPlacementsForWorktree, - isWebSessionBrowserPlacementGroupReserved, - markWebSessionBrowserPlacementAdopted, - peekWebSessionBrowserPlacementGroup, - resetWebSessionBrowserPlacementsForTests -} from './web-session-browser-placement' -import { suppressE2eWebRuntimeBrowserSnapshot } from './web-runtime-browser-creation-e2e-fault' -import { observeAgentHookCompletionForNotification } from '@/hooks/agent-hook-completion-notifications' -import { - buildWebSessionExistingTabIndex, - type WebSessionExistingTabIndex -} from './web-session-existing-tab-index' -import { - sameRuntimeBrowserPlacement, - type RuntimeBrowserPlacement -} from '../../../shared/runtime-browser-placement' - -const WEB_SESSION_GROUP_PREFIX = 'web-session-tabs:' -export const WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS = 100 - -type SessionTabsStreamEvent = - | (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }) - | { - type: 'snapshots' - snapshots: RuntimeMobileSessionTabsResult[] - authoritative?: boolean - } - | { type: 'end' } - -type SessionTabsListAllResult = { - snapshots: RuntimeMobileSessionTabsResult[] - authoritative?: boolean -} - -type SnapshotFreshness = { - publicationEpoch: string - snapshotVersion: number -} - -type ReceivedSessionTabsSnapshot = SnapshotFreshness & { - receivedFrame: number -} - -type SessionTabsRecoveryState = { - pendingCount: number -} - -type SessionTabsRemovalFence = { - receivedFrame: number - recoveryState: SessionTabsRecoveryState - pendingCount: number -} - -export type WebSessionTabsSnapshotApplyOptions = { - contentScope?: 'all' | 'agent-session' - preserveLocalLayout?: boolean - terminalPtyMode?: 'local' | 'remote' -} - -type TrackedWebSessionTabsWorktree = { - worktree: string - freshness: SnapshotFreshness -} - -type VisibilityResumeOmission = { - baseline: SnapshotFreshness - environmentId: string - inventoryReceivedFrame: number - superseded: boolean - visibilityGeneration: number -} - -const latestSessionTabsSnapshotByWorktree = new Map() -const replayableSessionTabsSnapshotByWorktree = new Map() -const latestReceivedSessionTabsSnapshotByWorktree = new Map() -const latestSessionTabsRemovalFenceByWorktree = new Map() -const sessionTabsRecoveryStateByWorktree = new Map() -const trackedSessionTabsWorktreeIdsByEnvironment = new Map>() -const sessionTabsEnvironmentsByWorktree = new Map>() -const sessionTabsTrackingGenerationByEnvironment = new Map() -const lastHostTerminalTabCountByWorktree = new Map() -const hostSessionTabIdByLocalKey = new Map() -const hostSessionTabMappingKeysByEnvironmentAndWorktree = new Map< - string, - Map> ->() -// Why: a delayed host stamp belongs to the client boundary seen with its preceding ordered frame. -const hostWorkingClientBoundaryByPaneKey = new Map< - string, - { - hostStateStartedAt: number - hostPrompt: string - clientStateStartedAt: number - stamped: boolean - } ->() -const HOST_WORKING_CLIENT_BOUNDARY_LIMIT = 512 -let receivedSessionTabsFrameSequence = 0 - -type TerminalSurface = RuntimeMobileSessionTerminalClientTab -type ReadyTerminalSurface = RuntimeMobileSessionTerminalClientTab & { status: 'ready' } -type ReadyBrowserSurface = RuntimeMobileSessionBrowserTab & { browserPageId: string } -type ReadyEditorSurface = RuntimeMobileSessionMarkdownTab | RuntimeMobileSessionFileTab - -type MirroredAgentTab = { - hostTabId: string - unifiedTab: Tab -} - -type MirroredTerminalTab = { - tab: TerminalTab - hostTabId: string - ptyIds: string[] - layout: TerminalLayoutSnapshot - retainedSurfaceByPrunedLeafId?: ReadonlyMap -} - -type MirroredBrowserTab = { - workspace: BrowserWorkspace - page: BrowserPage - certificateFailure: BrowserCertificateFailure | null - remotePageId: string - placement?: RuntimeBrowserPlacement - unifiedTab: Tab - hostTabId: string - clientGroupId?: 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' - | 'browserCertificateFailuresByPageId' - | 'browserTabsByWorktree' - | 'groupsByWorktree' - | 'layoutByWorktree' - | 'openFiles' - | 'ptyIdsByTabId' - | 'remoteBrowserPageHandlesByPageId' - | 'tabBarOrderByWorktree' - | 'tabsByWorktree' - | 'terminalLayoutsByTabId' - | 'unifiedTabsByWorktree' - | 'unreadTerminalTabs' - | 'sortEpoch' -> & - Partial< - Pick< - AppState, - | 'acknowledgedAgentsByPaneKey' - | 'agentLaunchConfigByPaneKey' - | 'automaticAgentResumeClaimsByTabId' - | 'migrationUnsupportedByPtyId' - | 'paneForegroundAgentByPaneKey' - | 'pendingStartupByTabId' - | 'recentlyClosedAgentStatusTabIds' - | 'recentlyRetiredAgentStatusPaneKeys' - | 'retainedAgentsByPaneKey' - | 'retentionSuppressedPaneKeys' - > - > - -type WebSessionTabsBatchRecordKey = - | 'activeBrowserTabIdByWorktree' - | 'activeFileIdByWorktree' - | 'activeGroupIdByWorktree' - | 'activeTabIdByWorktree' - | 'activeTabTypeByWorktree' - | 'agentStatusByPaneKey' - | 'automaticAgentResumeClaimsByTabId' - | 'browserCertificateFailuresByPageId' - | 'browserPagesByWorkspace' - | 'browserTabsByWorktree' - | 'groupsByWorktree' - | 'layoutByWorktree' - | 'pendingStartupByTabId' - | 'ptyIdsByTabId' - | 'remoteBrowserPageHandlesByPageId' - | 'tabBarOrderByWorktree' - | 'tabsByWorktree' - | 'terminalLayoutsByTabId' - | 'unifiedTabsByWorktree' - | 'unreadTerminalTabs' - -/** Open files bucketed by worktree. A snapshot only reconciles its own worktree, so a - * batch can decide there is nothing to do without walking every open file in the app. - * `source` pins the array it describes; each rebuild updates it in place instead of - * re-bucketing, so a batch never pays for the whole array twice. */ -type WebSessionOpenFilesIndex = { - source: readonly OpenFile[] - byWorktree: Map -} - -type WebSessionTabsBatchContext = { - agentPaneKeysByTabId: Map> | null - changedRecords: Set - openFilesIndex: WebSessionOpenFilesIndex | null -} - -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 advancesSessionTabsFreshness( - snapshot: RuntimeMobileSessionTabsResult, - baseline: SnapshotFreshness -): boolean { - return ( - snapshot.publicationEpoch !== baseline.publicationEpoch || - snapshot.snapshotVersion > baseline.snapshotVersion - ) -} - -function getTrackedWebSessionTabsWorktrees(environmentId: string): TrackedWebSessionTabsWorktree[] { - return [...(trackedSessionTabsWorktreeIdsByEnvironment.get(environmentId) ?? [])].flatMap( - (worktree) => { - const key = sessionTabsFreshnessKey(environmentId, worktree) - const freshness = latestSessionTabsSnapshotByWorktree.get(key) - return freshness - ? [ - { - worktree, - freshness - } - ] - : [] - } - ) -} - -function trackWebSessionTabsWorktree(environmentId: string, worktreeId: string): void { - const worktrees = trackedSessionTabsWorktreeIdsByEnvironment.get(environmentId) ?? new Set() - worktrees.add(worktreeId) - trackedSessionTabsWorktreeIdsByEnvironment.set(environmentId, worktrees) -} - -function untrackWebSessionTabsWorktree(environmentId: string, worktreeId: string): void { - const worktrees = trackedSessionTabsWorktreeIdsByEnvironment.get(environmentId) - if (!worktrees) { - return - } - worktrees.delete(worktreeId) - if (worktrees.size === 0) { - trackedSessionTabsWorktreeIdsByEnvironment.delete(environmentId) - } -} - -function recordReceivedWebSessionTabsSnapshot( - environmentId: string, - snapshot: RuntimeMobileSessionTabsResult -): number { - const receivedFrame = (receivedSessionTabsFrameSequence += 1) - const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) - latestReceivedSessionTabsSnapshotByWorktree.set(key, { - receivedFrame, - publicationEpoch: snapshot.publicationEpoch, - snapshotVersion: snapshot.snapshotVersion - }) - if ((snapshot as { removed?: unknown }).removed === true) { - recordReceivedWebSessionTabsRemoval(environmentId, snapshot.worktree, receivedFrame) - } - return receivedFrame -} - -function recordReceivedWebSessionTabsInventory(): number { - return (receivedSessionTabsFrameSequence += 1) -} - -function beginWebSessionTabsSnapshotRecovery( - environmentId: string, - worktreeId: string, - receivedFrame: number -): () => void { - const key = sessionTabsFreshnessKey(environmentId, worktreeId) - const recoveryState = sessionTabsRecoveryStateByWorktree.get(key) ?? { pendingCount: 0 } - recoveryState.pendingCount += 1 - sessionTabsRecoveryStateByWorktree.set(key, recoveryState) - let settled = false - return () => { - if (settled) { - return - } - settled = true - recoveryState.pendingCount -= 1 - if ( - recoveryState.pendingCount === 0 && - sessionTabsRecoveryStateByWorktree.get(key) === recoveryState - ) { - sessionTabsRecoveryStateByWorktree.delete(key) - } - const removalFence = latestSessionTabsRemovalFenceByWorktree.get(key) - if ( - removalFence?.recoveryState === recoveryState && - receivedFrame < removalFence.receivedFrame - ) { - removalFence.pendingCount -= 1 - if (removalFence.pendingCount === 0) { - latestSessionTabsRemovalFenceByWorktree.delete(key) - } - } - } -} - -function recordReceivedWebSessionTabsRemoval( - environmentId: string, - worktreeId: string, - receivedFrame: number -): void { - const key = sessionTabsFreshnessKey(environmentId, worktreeId) - const current = latestSessionTabsRemovalFenceByWorktree.get(key) - if (current && current.receivedFrame >= receivedFrame) { - return - } - const recoveryState = sessionTabsRecoveryStateByWorktree.get(key) - if (!recoveryState || recoveryState.pendingCount === 0) { - latestSessionTabsRemovalFenceByWorktree.delete(key) - return - } - latestSessionTabsRemovalFenceByWorktree.set(key, { - receivedFrame, - recoveryState, - pendingCount: recoveryState.pendingCount - }) -} - -function shouldApplyRecoveredWebSessionTabsSnapshot( - environmentId: string, - snapshot: RuntimeMobileSessionTabsResult, - receivedFrame: number -): boolean { - const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) - const removalFrame = latestSessionTabsRemovalFenceByWorktree.get(key)?.receivedFrame - if (removalFrame !== undefined && receivedFrame < removalFrame) { - return false - } - const latest = latestReceivedSessionTabsSnapshotByWorktree.get(key) - if (!latest || latest.receivedFrame === receivedFrame) { - return latest !== undefined - } - if (latest.publicationEpoch !== snapshot.publicationEpoch) { - return receivedFrame > latest.receivedFrame - } - return snapshot.snapshotVersion >= latest.snapshotVersion -} - -function isTrackedWebSessionTabsOmissionCurrent( - environmentId: string, - trackedWorktree: TrackedWebSessionTabsWorktree -): boolean { - const key = sessionTabsFreshnessKey(environmentId, trackedWorktree.worktree) - const current = latestSessionTabsSnapshotByWorktree.get(key) - return ( - current?.publicationEpoch === trackedWorktree.freshness.publicationEpoch && - current.snapshotVersion === trackedWorktree.freshness.snapshotVersion - ) -} - -function recordAcceptedWebSessionTabsEnvironment( - environmentId: string, - snapshot: RuntimeMobileSessionTabsResult -): void { - const environments = new Set(sessionTabsEnvironmentsByWorktree.get(snapshot.worktree) ?? []) - if (snapshot.tabs.length > 0) { - environments.add(environmentId) - } else { - environments.delete(environmentId) - } - if (environments.size > 0) { - sessionTabsEnvironmentsByWorktree.set(snapshot.worktree, environments) - } else { - sessionTabsEnvironmentsByWorktree.delete(snapshot.worktree) - } -} - -function removeWebSessionTabsEnvironment(environmentId: string, worktreeId: string): void { - const environments = new Set(sessionTabsEnvironmentsByWorktree.get(worktreeId) ?? []) - environments.delete(environmentId) - if (environments.size > 0) { - sessionTabsEnvironmentsByWorktree.set(worktreeId, environments) - } else { - sessionTabsEnvironmentsByWorktree.delete(worktreeId) - } -} - -const VISIBILITY_INVENTORY_REMOVAL_EPOCH = 'visibility-inventory-removal' - -// Why: a tombstone empties the whole worktree mirror — including tabs a still-live sibling environment publishes — so it is a -// visibility fact, never evidence that the host closed anything. -function isWebSessionTabsWorktreeRemovalFrame(snapshot: RuntimeMobileSessionTabsResult): boolean { - return ( - (snapshot as { removed?: unknown }).removed === true || - snapshot.publicationEpoch === VISIBILITY_INVENTORY_REMOVAL_EPOCH - ) -} - -// Why: omission means removal only because `listAllMobileSessionTabs` publishes every worktree it knows unfiltered; if a host ever -// scopes that map, this turns live worktrees into tombstones, so the fence below is deliberately short-lived. -function buildMissingWebSessionTabsRemovals( - environmentId: string, - trackedWorktrees: readonly TrackedWebSessionTabsWorktree[], - publishedWorktrees: ReadonlySet -): { - trackedWorktree: TrackedWebSessionTabsWorktree - snapshot: RuntimeMobileSessionTabsRemovedResult -}[] { - return trackedWorktrees - .filter( - (trackedWorktree) => - !publishedWorktrees.has(trackedWorktree.worktree) && - isTrackedWebSessionTabsOmissionCurrent(environmentId, trackedWorktree) - ) - .map((trackedWorktree) => ({ - trackedWorktree, - snapshot: { - worktree: trackedWorktree.worktree, - publicationEpoch: VISIBILITY_INVENTORY_REMOVAL_EPOCH, - snapshotVersion: 0, - removed: true, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: [] - } - })) -} - -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 - ) -} - -export function getLatestWebSessionTabsPublicationEpoch( - environmentId: string, - worktreeId: string -): string | null { - return ( - latestSessionTabsSnapshotByWorktree.get(sessionTabsFreshnessKey(environmentId, worktreeId)) - ?.publicationEpoch ?? null - ) -} - -// Why: a replay may repeat the current epoch/version; permit only that exact -// identity once so an older concurrent frame cannot bypass monotonic ordering. -export function acceptReplayedWebSessionTabsSnapshot( - environmentId: string, - worktreeId: string -): void { - const key = sessionTabsFreshnessKey(environmentId, worktreeId) - const current = latestSessionTabsSnapshotByWorktree.get(key) - if (current) { - replayableSessionTabsSnapshotByWorktree.set(key, current) - } -} - -/** - * A frame's fate, paired with whether that fate is host evidence for the - * worktree — the mirror latch reads the pair, never a bare boolean. - * - * Settling on a REJECTED frame is correct only when the store already holds an - * equal-or-newer accepted view of that worktree, so every rejection branch has - * to state its answer here. A `false` that meant "the mirror never writes this workspace" - * would otherwise read as staleness and drain parked resume work — the sweep - * would treat an undecidable pane as decidable and fork a live agent. - */ -export type WebSessionTabsSnapshotDecision = { - readonly apply: boolean - readonly settlesHostMirror: boolean -} - -/** The frame's own store patch carries the verdict. */ -const WEB_SESSION_TABS_FRAME_APPLIED = { - apply: true, - settlesHostMirror: true -} as const satisfies WebSessionTabsSnapshotDecision - -/** Outranked by an equal-or-newer accepted view: the host HAS answered here. */ -const WEB_SESSION_TABS_FRAME_OUTRANKED = { - apply: false, - settlesHostMirror: true -} as const satisfies WebSessionTabsSnapshotDecision - -/** Discarded because the mirror never writes this workspace at all, so no - * accepted view backs the rejection and the pane stays unaccounted for. */ -const WEB_SESSION_TABS_FRAME_UNMIRRORED = { - apply: false, - settlesHostMirror: false -} as const satisfies WebSessionTabsSnapshotDecision - -/** Why: the floating workspace is a local synthetic terminal the mirror never - * writes, so its frames are no evidence about any mirrored pane. */ -function isHostMirroredWorktree(worktreeId: string): boolean { - return worktreeId !== FLOATING_TERMINAL_WORKTREE_ID -} - -export function shouldApplyWebSessionTabsSnapshot( - snapshot: RuntimeMobileSessionTabsResult, - environmentId: string -): boolean { - return decideWebSessionTabsSnapshot(snapshot, environmentId).apply -} - -export function decideWebSessionTabsSnapshot( - snapshot: RuntimeMobileSessionTabsResult, - environmentId: string -): WebSessionTabsSnapshotDecision { - const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) - if ((snapshot as { removed?: unknown }).removed === true) { - // Why: removed worktrees can stop publishing, so clean up their tracking now instead of waiting for a replacement snapshot that may never arrive. - clearWebSessionTabsTrackingForWorktree(environmentId, snapshot.worktree) - queueAcceptedWebSessionTerminalSnapshot(snapshot, environmentId) - return WEB_SESSION_TABS_FRAME_APPLIED - } - if (!isHostMirroredWorktree(snapshot.worktree)) { - // Why: a remote empty same-id snapshot would delete the user's local floating tabs. - return WEB_SESSION_TABS_FRAME_UNMIRRORED - } - rememberHostTerminalTabCount(environmentId, snapshot) - const current = latestSessionTabsSnapshotByWorktree.get(key) - const replayable = replayableSessionTabsSnapshotByWorktree.get(key) - const isExactCurrentReplay = Boolean( - current && - replayable && - current.publicationEpoch === replayable.publicationEpoch && - current.snapshotVersion === replayable.snapshotVersion && - snapshot.publicationEpoch === replayable.publicationEpoch && - snapshot.snapshotVersion === replayable.snapshotVersion - ) - // Why: reject stale snapshots only within an epoch; host restarts create a new epoch. - if ( - current && - current.publicationEpoch === snapshot.publicationEpoch && - snapshot.snapshotVersion <= current.snapshotVersion && - !isExactCurrentReplay - ) { - return WEB_SESSION_TABS_FRAME_OUTRANKED - } - replayableSessionTabsSnapshotByWorktree.delete(key) - latestSessionTabsSnapshotByWorktree.set(key, { - publicationEpoch: snapshot.publicationEpoch, - snapshotVersion: snapshot.snapshotVersion - }) - trackWebSessionTabsWorktree(environmentId, snapshot.worktree) - recordAcceptedWebSessionTabsEnvironment(environmentId, snapshot) - // Why: a mounted mirror that exhausted bounded polling needs fresh host evidence without subscribing to every store write. - queueAcceptedWebSessionTerminalSnapshot(snapshot, environmentId) - return WEB_SESSION_TABS_FRAME_APPLIED -} - -export function shouldBootstrapInitialWebRuntimeTerminal(args: { - event: SessionTabsStreamEvent - activeWorktreeId: string - requestedInitialTerminal: boolean - snapshotIsFresh: boolean - localTerminalCount: number -}): boolean { - return ( - args.snapshotIsFresh && - args.event.type === 'snapshot' && - args.event.tabs.length === 0 && - args.localTerminalCount === 0 && - !args.requestedInitialTerminal && - args.activeWorktreeId === args.event.worktree - ) -} - -export function shouldRespawnWebRuntimeTerminalAfterWake(args: { - event: SessionTabsStreamEvent - activeWorktreeId: string - requestedRespawnAfterWake: boolean - snapshotIsFresh: boolean - localTerminalCount: number - hasLiveLocalPty: boolean - skipWakeRespawn?: boolean -}): boolean { - if ( - !args.snapshotIsFresh || - args.requestedRespawnAfterWake || - args.skipWakeRespawn === true || - args.localTerminalCount === 0 || - args.hasLiveLocalPty || - (args.event.type !== 'snapshot' && args.event.type !== 'updated') - ) { - return false - } - if (args.activeWorktreeId !== args.event.worktree) { - return false - } - const hostTerminalTabCount = args.event.tabs.filter((tab) => tab.type === 'terminal').length - return hostTerminalTabCount === 0 -} - -export function shouldSyncRuntimeSessionTabs(args: { - activeWorktreeId?: string | null - activeWorktreeRuntimeEnvironmentId?: string | null - workspaceSessionReady: boolean -}): boolean { - const environmentId = args.activeWorktreeRuntimeEnvironmentId?.trim() - if (!environmentId || !args.workspaceSessionReady) { - return false - } - return Boolean(args.activeWorktreeId?.trim()) -} - -export function shouldSyncAllRuntimeSessionTabs(args: { - activeRuntimeEnvironmentId: string | null | undefined - workspaceSessionReady: boolean -}): boolean { - const environmentId = args.activeRuntimeEnvironmentId?.trim() - return Boolean(environmentId && args.workspaceSessionReady) -} - -export function resetWebSessionTabsSnapshotFreshnessForTests(): void { - latestSessionTabsSnapshotByWorktree.clear() - replayableSessionTabsSnapshotByWorktree.clear() - latestReceivedSessionTabsSnapshotByWorktree.clear() - latestSessionTabsRemovalFenceByWorktree.clear() - sessionTabsRecoveryStateByWorktree.clear() - trackedSessionTabsWorktreeIdsByEnvironment.clear() - sessionTabsEnvironmentsByWorktree.clear() - receivedSessionTabsFrameSequence = 0 - lastHostTerminalTabCountByWorktree.clear() - hostSessionTabIdByLocalKey.clear() - hostSessionTabMappingKeysByEnvironmentAndWorktree.clear() - hostWorkingClientBoundaryByPaneKey.clear() - resetWebSessionBrowserPlacementsForTests() -} - -export function _getWebSessionTabsTrackingCountsForTest(): { - freshness: number - hostMappings: number - hostMappingWorktrees: number -} { - let hostMappingWorktrees = 0 - for (const mappingKeysByWorktree of hostSessionTabMappingKeysByEnvironmentAndWorktree.values()) { - hostMappingWorktrees += mappingKeysByWorktree.size - } - return { - freshness: latestSessionTabsSnapshotByWorktree.size, - hostMappings: hostSessionTabIdByLocalKey.size, - // Why: the mapping index is a parallel structure, so leak tests must see it drain alongside the flat map. - hostMappingWorktrees - } -} - -export function _getWebSessionTabsRecoveryTrackingCountsForTest(): { - pendingRecoveries: number - removalFrames: number -} { - return { - pendingRecoveries: sessionTabsRecoveryStateByWorktree.size, - removalFrames: latestSessionTabsRemovalFenceByWorktree.size - } -} - -function clearWebSessionTabsTrackingForWorktree(environmentId: string, worktreeId: string): void { - const key = sessionTabsFreshnessKey(environmentId, worktreeId) - latestSessionTabsSnapshotByWorktree.delete(key) - replayableSessionTabsSnapshotByWorktree.delete(key) - latestReceivedSessionTabsSnapshotByWorktree.delete(key) - untrackWebSessionTabsWorktree(environmentId, worktreeId) - removeWebSessionTabsEnvironment(environmentId, worktreeId) - lastHostTerminalTabCountByWorktree.delete(key) - clearWebRuntimeWakeTerminalRespawnForWorktree(worktreeId) - clearWebSessionReorderIntentsForWorktree({ environmentId }, worktreeId) - clearWebSessionCloseIntentsForWorktree({ environmentId }, worktreeId) - clearWebAgentSessionHandoffsForWorktree(environmentId, worktreeId) - clearHostSessionTabIdMappings(environmentId, worktreeId) - clearWebSessionBrowserPlacementsForWorktree(environmentId, worktreeId) - clearWebSessionTerminalPlacementsForWorktree(environmentId, worktreeId) -} - -export function clearWebSessionTabsTrackingForEnvironment(environmentId: string): void { - const trimmedEnvironmentId = environmentId.trim() - if (!trimmedEnvironmentId) { - return - } - const keyPrefix = `${trimmedEnvironmentId}:` - sessionTabsTrackingGenerationByEnvironment.set( - trimmedEnvironmentId, - (sessionTabsTrackingGenerationByEnvironment.get(trimmedEnvironmentId) ?? 0) + 1 - ) - for (const key of latestSessionTabsSnapshotByWorktree.keys()) { - if (key.startsWith(keyPrefix)) { - latestSessionTabsSnapshotByWorktree.delete(key) - } - } - for (const key of replayableSessionTabsSnapshotByWorktree.keys()) { - if (key.startsWith(keyPrefix)) { - replayableSessionTabsSnapshotByWorktree.delete(key) - } - } - for (const key of latestReceivedSessionTabsSnapshotByWorktree.keys()) { - if (key.startsWith(keyPrefix)) { - latestReceivedSessionTabsSnapshotByWorktree.delete(key) - } - } - for (const key of latestSessionTabsRemovalFenceByWorktree.keys()) { - if (key.startsWith(keyPrefix)) { - latestSessionTabsRemovalFenceByWorktree.delete(key) - } - } - for (const key of sessionTabsRecoveryStateByWorktree.keys()) { - if (key.startsWith(keyPrefix)) { - sessionTabsRecoveryStateByWorktree.delete(key) - } - } - trackedSessionTabsWorktreeIdsByEnvironment.delete(trimmedEnvironmentId) - for (const worktreeId of sessionTabsEnvironmentsByWorktree.keys()) { - removeWebSessionTabsEnvironment(trimmedEnvironmentId, worktreeId) - } - for (const key of lastHostTerminalTabCountByWorktree.keys()) { - if (key.startsWith(keyPrefix)) { - lastHostTerminalTabCountByWorktree.delete(key) - } - } - const mappingKeysByWorktree = - hostSessionTabMappingKeysByEnvironmentAndWorktree.get(trimmedEnvironmentId) - if (mappingKeysByWorktree) { - for (const mappingKeys of mappingKeysByWorktree.values()) { - for (const mappingKey of mappingKeys) { - hostSessionTabIdByLocalKey.delete(mappingKey) - } - } - hostSessionTabMappingKeysByEnvironmentAndWorktree.delete(trimmedEnvironmentId) - } - clearWebAgentSessionHandoffsForEnvironment(trimmedEnvironmentId) - clearWebSessionBrowserPlacementsForEnvironment(trimmedEnvironmentId) - clearWebSessionTerminalPlacementsForEnvironment(trimmedEnvironmentId) - clearHostSessionMirrorHydration(trimmedEnvironmentId) - clearAllWebRuntimeWakeTerminalRespawn() -} - -export function getWebSessionTabsTrackingGeneration(environmentId: string): number { - return sessionTabsTrackingGenerationByEnvironment.get(environmentId.trim()) ?? 0 -} - -function hostSessionTabMappingKey(args: { - environmentId: string - worktreeId: string - tabId: string -}): string { - return `${args.environmentId}:${args.worktreeId}:${args.tabId}` -} - -function clearHostSessionTabIdMappings(environmentId: string, worktreeId: string): void { - const mappingKeysByWorktree = hostSessionTabMappingKeysByEnvironmentAndWorktree.get(environmentId) - const mappingKeys = mappingKeysByWorktree?.get(worktreeId) - if (!mappingKeys) { - return - } - for (const mappingKey of mappingKeys) { - hostSessionTabIdByLocalKey.delete(mappingKey) - } - mappingKeysByWorktree?.delete(worktreeId) - if (mappingKeysByWorktree?.size === 0) { - hostSessionTabMappingKeysByEnvironmentAndWorktree.delete(environmentId) - } -} - -function setHostSessionTabIdMapping( - args: { environmentId: string; worktreeId: string; tabId: string }, - hostTabId: string -): void { - const mappingKey = hostSessionTabMappingKey(args) - hostSessionTabIdByLocalKey.set(mappingKey, hostTabId) - const mappingKeysByWorktree = - hostSessionTabMappingKeysByEnvironmentAndWorktree.get(args.environmentId) ?? new Map() - const mappingKeys = mappingKeysByWorktree.get(args.worktreeId) ?? new Set() - mappingKeys.add(mappingKey) - mappingKeysByWorktree.set(args.worktreeId, mappingKeys) - hostSessionTabMappingKeysByEnvironmentAndWorktree.set(args.environmentId, mappingKeysByWorktree) -} - -export function resolveHostSessionTabIdForWebSessionTab( - _state: WebSessionTabsSyncState, - args: { - environmentId: string - worktreeId: string - tabId: string - } -): string | null { - return ( - hostSessionTabIdByLocalKey.get(hostSessionTabMappingKey(args)) ?? - // Why: structured create returns canonical identity before its confirming - // snapshot; an immediate user close must already target that host tab. - resolveWebAgentSessionHandoff({ - environmentId: args.environmentId, - worktreeId: args.worktreeId, - provisionalTabId: args.tabId - }) - ) -} - -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 isAgentSessionTab( - tab: RuntimeMobileSessionTabsResult['tabs'][number] -): tab is RuntimeMobileSessionAgentTab { - return tab.type === 'agent-session' -} - -function buildMirroredAgentTabs( - snapshot: RuntimeMobileSessionTabsResult, - hostGroupIdByTabId: ReadonlyMap, - fallbackGroupId: string, - sortOffset: number, - currentUnifiedTabs: readonly Tab[], - now: number -): MirroredAgentTab[] { - return snapshot.tabs.filter(isAgentSessionTab).map((tab, index) => { - const localId = structuredAgentSessionTabId(tab.sessionId) - const existing = currentUnifiedTabs.find( - (candidate) => candidate.contentType === 'agent-session' && candidate.id === localId - ) - return { - hostTabId: tab.id, - unifiedTab: { - id: localId, - entityId: tab.sessionId, - groupId: hostGroupIdByTabId.get(tab.id) ?? fallbackGroupId, - worktreeId: snapshot.worktree, - contentType: 'agent-session', - agentSessionAgent: tab.agent, - label: tab.title.trim() || 'Codex Chat', - customLabel: null, - color: tab.color !== undefined ? tab.color : (existing?.color ?? null), - sortOrder: sortOffset + index, - createdAt: existing?.createdAt ?? now + sortOffset + index, - isPinned: tab.isPinned !== undefined ? tab.isPinned : existing?.isPinned === true - } - } - }) -} - -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, - existingLayout?: TerminalLayoutSnapshot, - requestedActiveLeafId?: string -): TerminalLayoutSnapshot { - const leafIds = surfaces.map((surface) => surface.leafId) - const knownLeafIds = new Set(leafIds) - const parentLayoutSource = surfaces.find((surface) => surface.parentLayout) - const parentLayout = parentLayoutSource?.parentLayout - ? sanitizeTerminalLayoutPaneTitlesForLabels(parentLayoutSource.parentLayout, [ - parentLayoutSource.title - ]) - : undefined - const activeLeafId = - (requestedActiveLeafId && knownLeafIds.has(requestedActiveLeafId) - ? requestedActiveLeafId - : null) ?? - // Why: host title/status snapshots may still mark an agent pane active after this client selected a different split pane. - (existingLayout?.activeLeafId && knownLeafIds.has(existingLayout.activeLeafId) - ? existingLayout.activeLeafId - : null) ?? - (parentLayout?.activeLeafId && knownLeafIds.has(parentLayout.activeLeafId) - ? parentLayout.activeLeafId - : null) ?? - surfaces.find((surface) => surface.isActive)?.leafId ?? - leafIds[0] ?? - null - const expandedLeafId = - requestedActiveLeafId && - (Boolean(existingLayout?.expandedLeafId) || Boolean(parentLayout?.expandedLeafId)) - ? requestedActiveLeafId - : parentLayout?.expandedLeafId && knownLeafIds.has(parentLayout.expandedLeafId) - ? parentLayout.expandedLeafId - : null - return { - // Why: host parentLayout is authoritative for split direction; else keep the prior client tree, then degenerate — never re-guess a direction. - root: resolveTerminalLayoutRoot({ - authoritativeRoot: parentLayout?.root, - existingRoot: existingLayout?.root, - leafIds, - onSynthesize: (leafCount) => - console.warn( - `[web-session-tabs-sync] synthesized layout for ${leafCount} leaves; no authoritative or prior tree covered them` - ) - }), - activeLeafId, - expandedLeafId, - ptyIdsByLeafId, - // Why: surface.title is the tab/PTY label, not a pane title; restoring it as one renders a fake title bar. Only host layout titles are real pane titles. - ...(parentLayout?.titlesByLeafId ? { titlesByLeafId: parentLayout.titlesByLeafId } : {}) - } -} - -function shouldReplaceTerminalTab( - tab: TerminalTab, - environmentId: string, - nextRemotePtyIds: ReadonlySet, - nextMirroredTerminalIds: ReadonlySet, - exactProvisionalHandoffs: ReadonlySet -): boolean { - if (exactProvisionalHandoffs.has(tab.id)) { - // Why: agent kind is not session identity; retire only the provisional tab - // whose request or structured response identifies this exact host surface. - return true - } - if (isMirroredTerminalSurfaceId(tab.id)) { - // Why: host snapshots are authoritative for mirrored tabs; replace old mirrors even when the next surface still awaits a stream handle, else parity drifts. - return true - } - if (tab.pendingActivationSpawn && tab.ptyId === null && nextRemotePtyIds.size > 0) { - return true - } - if (!isRuntimeTerminalTabForEnvironment(tab, environmentId)) { - return false - } - // Why: web-created remote tabs use local UUIDs until the host publishes their surface; only retire them once their PTY appears in the snapshot. - return ( - tab.ptyId !== null && - (nextRemotePtyIds.has(tab.ptyId) || - nextMirroredTerminalIds.has(toWebTerminalSurfaceTabId(tab.id))) - ) -} - -/** 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, - existingLayoutsByTabId: Readonly>, - sortOffset: number, - now: number, - focusTarget?: { parentTabId: string; leafId: string }, - terminalPtyMode: 'local' | 'remote' = 'remote' -): MirroredTerminalTab[] { - const groups = new Map() - for (const tab of snapshot.tabs.filter(isTerminalSurfaceTab)) { - const group = groups.get(tab.parentTabId) ?? [] - group.push(tab) - groups.set(tab.parentTabId, group) - } - - return [...groups.entries()].map(([parentTabId, surfaces], index) => { - const localTabId = toWebTerminalSurfaceTabId(parentTabId) - const existingLayout = existingLayoutsByTabId[localTabId] - const requestedActiveLeafId = - focusTarget?.parentTabId === parentTabId ? focusTarget.leafId : undefined - const activeSurface = - (requestedActiveLeafId - ? surfaces.find((surface) => surface.leafId === requestedActiveLeafId) - : undefined) ?? - (existingLayout?.activeLeafId - ? surfaces.find((surface) => surface.leafId === existingLayout.activeLeafId) - : undefined) ?? - surfaces.find((surface) => surface.isActive) ?? - surfaces[0]! - const ptyIdForSurface = (handle: string): string => - terminalPtyMode === 'local' ? handle : toRemoteRuntimePtyId(handle, environmentId) - const ptyIdsByLeafId = Object.fromEntries( - surfaces - .filter((surface): surface is ReadyTerminalSurface => surface.status === 'ready') - .map((surface) => [surface.leafId, ptyIdForSurface(surface.terminal)]) - ) - const layout = normalizeTerminalLayoutPtyOwnership( - chooseRemoteTerminalLayout(surfaces, ptyIdsByLeafId, existingLayout, requestedActiveLeafId) - ).snapshot - const layoutPtyEntries = Object.entries(layout.ptyIdsByLeafId ?? {}) - const ptyIds = layoutPtyEntries.map(([, ptyId]) => ptyId) - let retainedSurfaceByPrunedLeafId: Map | undefined - if (layoutPtyEntries.length < Object.keys(ptyIdsByLeafId).length) { - const retainedLeafIdByPtyId = new Map( - layoutPtyEntries.map(([leafId, ptyId]) => [ptyId, leafId]) - ) - const surfaceByLeafId = new Map(surfaces.map((surface) => [surface.leafId, surface])) - retainedSurfaceByPrunedLeafId = new Map() - for (const [leafId, ptyId] of Object.entries(ptyIdsByLeafId)) { - const retainedLeafId = retainedLeafIdByPtyId.get(ptyId) - if (retainedLeafId && retainedLeafId !== leafId) { - const retainedSurface = surfaceByLeafId.get(retainedLeafId) - if (retainedSurface) { - retainedSurfaceByPrunedLeafId.set(leafId, retainedSurface) - } - } - } - } - const launchAgent = - activeSurface.launchAgent ?? surfaces.find((surface) => surface.launchAgent)?.launchAgent - const ownerRecord = resolvePaneAgentOwnerRecord({ - launchAgent, - hookAgent: activeSurface.agentStatus?.agentType, - siblingHookAgent: surfaces.find((surface) => surface.agentStatus?.agentType)?.agentStatus - ?.agentType - }) - const title = normalizeCompatibleAgentTitleForOwner( - activeSurface.title.trim() || surfaces[0]?.title.trim() || 'Terminal', - ownerRecord?.agent, - { ownerIsLaunch: ownerRecord?.ownerIsLaunch === true } - ) - const existing = - existingById.get(localTabId) ?? - existingById.get(parentTabId) ?? - surfaces - .map((surface) => existingById.get(toWebTerminalSurfaceTabId(surface.id))) - .find((tab): tab is TerminalTab => Boolean(tab)) - const quickCommandLabel = - activeSurface.quickCommandLabel?.trim() || - surfaces.find((surface) => surface.quickCommandLabel?.trim())?.quickCommandLabel?.trim() || - existing?.quickCommandLabel?.trim() - // Why: startupCwd is host-owned launch metadata; once the host omits it, don't resurrect stale subdirectory intent. - const startupCwd = - activeSurface.startupCwd || surfaces.find((surface) => surface.startupCwd)?.startupCwd - // Why: color/pin echo back through host snapshots, so prefer the client's own record and fall back to host only without a prior tab (avoids echo-window reverts). - const hostColorSurface = surfaces.find((surface) => surface.color != null) - const color = existing ? (existing.color ?? null) : (hostColorSurface?.color ?? null) - const isPinned = existing - ? existing.isPinned === true - : surfaces.some((surface) => surface.isPinned) - // Why: viewMode echoes back through host snapshots, so prefer the client's record during the echo window and adopt the host value only without a prior tab. - const hostViewModeSurface = surfaces.find((surface) => surface.viewMode) - const viewMode = existing ? existing.viewMode : hostViewModeSurface?.viewMode - return { - tab: { - id: localTabId, - ptyId: ptyIdsByLeafId[activeSurface.leafId] ?? null, - worktreeId: snapshot.worktree, - title, - defaultTitle: existing?.defaultTitle ?? title, - // Why: the host transport carries no generated title, so rebuilding the tab - // without this dropped the client's agent-prompt label on every snapshot. - ...(existing?.generatedTitle ? { generatedTitle: existing.generatedTitle } : {}), - ...(existing?.aiVaultTitle ? { aiVaultTitle: existing.aiVaultTitle } : {}), - ...(quickCommandLabel ? { quickCommandLabel } : {}), - ...(startupCwd ? { startupCwd } : {}), - customTitle: existing?.customTitle ?? null, - color, - isPinned, - ...(viewMode ? { viewMode } : {}), - sortOrder: sortOffset + index, - createdAt: existing?.createdAt ?? now + index, - // Why: launchAgent is host-owned lifecycle metadata; once the host omits it, don't resurrect stale startup intent. - ...(launchAgent ? { launchAgent } : {}) - }, - hostTabId: parentTabId, - ptyIds, - layout, - ...(retainedSurfaceByPrunedLeafId ? { retainedSurfaceByPrunedLeafId } : {}) - } - }) -} - -function toMirroredPaneKey(surface: TerminalSurface, leafId = surface.leafId): string | null { - if (!isTerminalLeafId(leafId)) { - return null - } - return makePaneKey(toWebTerminalSurfaceTabId(surface.parentTabId), leafId) -} - -/** Normalises and mirrors agent status updates from the host payload, preserving ownership metadata. */ -function remapHostAgentStatus( - surface: TerminalSurface, - retainedSurface?: TerminalSurface -): AgentStatusEntry | null { - if (!surface.agentStatus) { - return null - } - const paneKey = toMirroredPaneKey(surface, retainedSurface?.leafId) - if (!paneKey) { - return null - } - const ownerRecord = resolvePaneAgentOwnerRecord({ - launchAgent: retainedSurface?.launchAgent ?? surface.launchAgent, - hookAgent: surface.agentStatus.agentType - }) - return { - ...normalizeCompatibleAgentStatusEntryForOwner(surface.agentStatus, ownerRecord?.agent, { - ownerIsLaunch: ownerRecord?.ownerIsLaunch === true - }), - paneKey, - tabId: toWebTerminalSurfaceTabId(surface.parentTabId) - } -} - -function isMirroredAgentPaneKeyForTabs(paneKey: string, tabIds: ReadonlySet): boolean { - const parsed = parsePaneKey(paneKey) - return parsed !== null && tabIds.has(parsed.tabId) -} - -/** Host states the client's byte pipeline cannot observe: permission blocks and - * interactive question cards reach the host over its HTTP agent hook, never - * through PTY bytes, so they must pierce the client-authority fence. */ -function hostAgentStatusPiercesClientAuthority(entry: AgentStatusEntry): boolean { - return entry.state === 'blocked' || entry.interactivePrompt != null -} - -/** True while this renderer's own byte-derived status owns the pane: it claimed - * the pane at transport creation and wrote status from bytes. The claim is - * released on pane teardown, which is how the host takes the pane back. */ -function isClientOwnedAgentStatus( - paneKey: string, - existing: AgentStatusEntry | undefined -): existing is AgentStatusEntry { - return existing !== undefined && isClientAuthoritativeAgentStatusPane(paneKey) -} - -/** Owned AND still fresh — the arbitration rule for a pane the host also has an - * opinion about: an OSC-silent dead agent hands that contest back to the host. */ -function isFencedClientAgentStatus( - paneKey: string, - existing: AgentStatusEntry | undefined, - now: number -): existing is AgentStatusEntry { - return isClientOwnedAgentStatus(paneKey, existing) && isAgentStatusFresh(existing, now) -} - -function batchAgentPaneKeysForTabs( - state: WebSessionTabsSyncState, - tabIds: ReadonlySet, - batchContext?: WebSessionTabsBatchContext -): string[] { - if (!batchContext) { - return Object.keys(state.agentStatusByPaneKey) - } - if (!batchContext.agentPaneKeysByTabId) { - batchContext.agentPaneKeysByTabId = new Map() - for (const paneKey of Object.keys(state.agentStatusByPaneKey)) { - const tabId = parsePaneKey(paneKey)?.tabId - if (!tabId) { - continue - } - const paneKeys = batchContext.agentPaneKeysByTabId.get(tabId) ?? new Set() - paneKeys.add(paneKey) - batchContext.agentPaneKeysByTabId.set(tabId, paneKeys) - } - } - return [...tabIds].flatMap((tabId) => [...(batchContext.agentPaneKeysByTabId?.get(tabId) ?? [])]) -} - -function updateBatchAgentPaneKey( - paneKey: string, - present: boolean, - batchContext?: WebSessionTabsBatchContext -): void { - const tabId = parsePaneKey(paneKey)?.tabId - const index = batchContext?.agentPaneKeysByTabId - if (!tabId || !index) { - return - } - if (present) { - const paneKeys = index.get(tabId) ?? new Set() - paneKeys.add(paneKey) - index.set(tabId, paneKeys) - return - } - const paneKeys = index.get(tabId) - paneKeys?.delete(paneKey) - if (paneKeys?.size === 0) { - index.delete(tabId) - } -} - -// Why: the closed-tab marker has no TTL, and setAgentStatus hard-drops writes for a -// marked tab id — for a stable mirrored id that returns, that is a permanent silent -// blackhole unless presence in a snapshot lifts it. -function buildRemirroredClosedTabMarkerLiftPatch( - recentlyClosedAgentStatusTabIds: WebSessionTabsSyncState['recentlyClosedAgentStatusTabIds'], - mirroredTerminalIds: ReadonlySet -): Partial | null { - let next: WebSessionTabsSyncState['recentlyClosedAgentStatusTabIds'] | null = null - for (const tabId of mirroredTerminalIds) { - if (tabId in (recentlyClosedAgentStatusTabIds ?? {})) { - next ??= { ...recentlyClosedAgentStatusTabIds } - delete next[tabId] - } - } - return next ? { recentlyClosedAgentStatusTabIds: next } : null -} - -/** - * A host retraction owes the retracted tab closeTab's renderer-state sweep: without it, - * client-owned rows (STA-3107-exempt in the mirror's delete loop) and retention promotions - * outlive the tab forever (STA-4593). - */ -function buildRetractedMirroredTabSweepPatch( - state: WebSessionTabsSyncState, - worktreeId: string, - nextTabsByWorktree: WebSessionTabsSyncState['tabsByWorktree'], - agentStatusPatch: Pick< - WebSessionTabsSyncState, - 'agentStatusByPaneKey' | 'agentStatusEpoch' | 'sortEpoch' - > | null, - removedTerminalResourceIds: readonly string[], - batchContext?: WebSessionTabsBatchContext -): Partial | null { - // Why: only a mirrored id the host stopped publishing is a retraction — a local or provisional - // tab in this list is being renamed into its mirror, and a rename must keep its rows. - const retractedTabIds = removedTerminalResourceIds.filter(isMirroredTerminalSurfaceId) - if (retractedTabIds.length === 0) { - return null - } - const sweepState: RetiredTerminalTabSweepState = { - acknowledgedAgentsByPaneKey: state.acknowledgedAgentsByPaneKey ?? {}, - agentLaunchConfigByPaneKey: state.agentLaunchConfigByPaneKey ?? {}, - agentStatusByPaneKey: agentStatusPatch?.agentStatusByPaneKey ?? state.agentStatusByPaneKey, - agentStatusEpoch: agentStatusPatch?.agentStatusEpoch ?? state.agentStatusEpoch, - migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId ?? {}, - paneForegroundAgentByPaneKey: state.paneForegroundAgentByPaneKey ?? {}, - recentlyClosedAgentStatusTabIds: state.recentlyClosedAgentStatusTabIds ?? {}, - recentlyRetiredAgentStatusPaneKeys: state.recentlyRetiredAgentStatusPaneKeys ?? {}, - retainedAgentsByPaneKey: state.retainedAgentsByPaneKey ?? {}, - retentionSuppressedPaneKeys: state.retentionSuppressedPaneKeys ?? {}, - sortEpoch: agentStatusPatch?.sortEpoch ?? state.sortEpoch, - // Why: the drop's completed-orphan rule reads "keyed under a tab this worktree no longer has", - // so it must see the post-removal tab list, not the one the snapshot replaced. - tabsByWorktree: nextTabsByWorktree - } - const sweep = buildRetiredTerminalTabStateSweepPatch(sweepState, retractedTabIds, worktreeId) - if (!sweep?.agentStatusByPaneKey || !batchContext) { - return sweep ?? null - } - // Why: the batch republishes its own record copy at the end, which would undo the sweep. - const mutableState = state as unknown as Record - mutableState.agentStatusByPaneKey = sweep.agentStatusByPaneKey - batchContext.changedRecords.add('agentStatusByPaneKey') - for (const paneKey of Object.keys(sweepState.agentStatusByPaneKey)) { - if (!(paneKey in sweep.agentStatusByPaneKey)) { - updateBatchAgentPaneKey(paneKey, false, batchContext) - } - } - return sweep -} - -function buildMirroredAgentStatusPatch( - state: WebSessionTabsSyncState, - currentTerminalTabs: readonly TerminalTab[], - terminalSurfaceTabs: readonly TerminalSurface[], - mirroredTerminalTabs: readonly MirroredTerminalTab[], - now: number, - batchContext?: WebSessionTabsBatchContext -): Pick | null { - const mirroredTabIds = new Set() - for (const tab of currentTerminalTabs) { - if (isWebTerminalSurfaceTabId(tab.id)) { - mirroredTabIds.add(tab.id) - } - } - for (const surface of terminalSurfaceTabs) { - mirroredTabIds.add(toWebTerminalSurfaceTabId(surface.parentTabId)) - } - - if (mirroredTabIds.size === 0) { - return null - } - - let retainedSurfaceByHostTabAndPrunedLeafId: - | Map> - | undefined - for (const entry of mirroredTerminalTabs) { - if (entry.retainedSurfaceByPrunedLeafId) { - retainedSurfaceByHostTabAndPrunedLeafId ??= new Map() - retainedSurfaceByHostTabAndPrunedLeafId.set( - entry.hostTabId, - entry.retainedSurfaceByPrunedLeafId - ) - } - } - const nextByPaneKey = new Map() - for (const surface of terminalSurfaceTabs) { - const retainedSurface = retainedSurfaceByHostTabAndPrunedLeafId - ?.get(surface.parentTabId) - ?.get(surface.leafId) - const entry = remapHostAgentStatus(surface, retainedSurface) - if (!entry) { - continue - } - const existing = nextByPaneKey.get(entry.paneKey) ?? state.agentStatusByPaneKey[entry.paneKey] - // Why: keep fresher OSC state while taking remapped ownership metadata from the authoritative host snapshot. - const hostIdentityPredatesCurrentTurn = - existing !== undefined && - entry.state === 'done' && - existing.state !== 'done' && - existing.stateStartedAt > entry.stateStartedAt - // Why: cross-machine wall clocks are not comparable, so the host frame could - // outrank live client status forever; a proven client writer keeps its own - // state (still adopting the host's identity fields below) unless the host - // carries a state class the client's bytes can never see. - const clientOwnsEntry = - isFencedClientAgentStatus(entry.paneKey, existing, now) && - !hostAgentStatusPiercesClientAuthority(entry) - const nextEntry = - existing && (clientOwnsEntry || existing.updatedAt > entry.updatedAt) - ? { - ...normalizeCompatibleAgentStatusEntryForOwner(existing, entry.agentType), - ...(clientOwnsEntry && existing.state === 'working' && entry.state === 'working' - ? { workingMode: entry.workingMode } - : {}), - paneKey: entry.paneKey, - worktreeId: entry.worktreeId ?? existing.worktreeId, - tabId: entry.tabId, - providerSession: - existing.providerSession ?? - (hostIdentityPredatesCurrentTurn ? undefined : entry.providerSession), - // Why: hook-only content the byte pipeline can never see, and every OSC - // write blanks it, so a fenced pane's message line stayed empty forever - // (#12906). Host-first unlike providerSession: only the host can mint one. - lastAssistantMessage: - (hostIdentityPredatesCurrentTurn ? undefined : entry.lastAssistantMessage) ?? - existing.lastAssistantMessage - } - : entry - nextByPaneKey.set(entry.paneKey, nextEntry) - } - - let nextAgentStatusByPaneKey = state.agentStatusByPaneKey - let changed = false - let aggregateRelevantChange = false - let sortRelevantChange = false - - for (const paneKey of batchAgentPaneKeysForTabs(state, mirroredTabIds, batchContext)) { - if (!isMirroredAgentPaneKeyForTabs(paneKey, mirroredTabIds)) { - continue - } - if (nextByPaneKey.has(paneKey)) { - continue - } - // Why: the host surface carrying no status is not proof the agent stopped — - // hook-only hosts publish nothing for OSC-driven panes. Keep a live entry - // this renderer owns; it decays through the normal freshness boundary. - // Ownership, not freshness, is the gate here: with no competing host value - // there is nothing to arbitrate, and a client asleep past the stale - // boundary would otherwise erase every pane it owns on the first snapshot - // after wake (STA-3107) instead of decaying it like a local pane. - if (isClientOwnedAgentStatus(paneKey, state.agentStatusByPaneKey[paneKey])) { - continue - } - if (nextAgentStatusByPaneKey === state.agentStatusByPaneKey) { - nextAgentStatusByPaneKey = writableWebSessionTabsRecord( - state, - 'agentStatusByPaneKey', - batchContext - ) - } - delete nextAgentStatusByPaneKey[paneKey] - updateBatchAgentPaneKey(paneKey, false, batchContext) - changed = true - aggregateRelevantChange = true - sortRelevantChange = true - } - - for (const [paneKey, entry] of nextByPaneKey) { - const existing = nextAgentStatusByPaneKey[paneKey] - if (agentStatusEntryEqual(existing, entry)) { - continue - } - if (nextAgentStatusByPaneKey === state.agentStatusByPaneKey) { - nextAgentStatusByPaneKey = writableWebSessionTabsRecord( - state, - 'agentStatusByPaneKey', - batchContext - ) - } - nextAgentStatusByPaneKey[paneKey] = entry - updateBatchAgentPaneKey(paneKey, true, batchContext) - changed = true - const entryAttributionChanged = - existing?.worktreeId !== entry.worktreeId || existing?.tabId !== entry.tabId - const entryFreshnessChanged = - !!existing && isAgentStatusFresh(existing, now) !== isAgentStatusFresh(entry, now) - const doneAttentionChanged = - existing?.state === 'done' && - entry.state === 'done' && - agentEntryCompletionAt(existing) !== agentEntryCompletionAt(entry) - const workingModeChanged = existing?.workingMode !== entry.workingMode - const entrySortRelevantChange = - !existing || - existing.state !== entry.state || - !isAgentStatusFresh(existing, now) || - entryFreshnessChanged || - entryAttributionChanged || - doneAttentionChanged || - isMirroredCommandCodeTurnBump(existing, entry) - aggregateRelevantChange = - aggregateRelevantChange || entrySortRelevantChange || workingModeChanged - sortRelevantChange = sortRelevantChange || entrySortRelevantChange - } - - if (!changed) { - return null - } - - return { - agentStatusByPaneKey: nextAgentStatusByPaneKey, - agentStatusEpoch: aggregateRelevantChange ? state.agentStatusEpoch + 1 : state.agentStatusEpoch, - sortEpoch: sortRelevantChange ? state.sortEpoch + 1 : state.sortEpoch - } -} - -function buildTerminalUnifiedTab( - tab: TerminalTab, - groupId: string, - environmentId: string, - // Why: viewMode is host-tracked but the client's optimistic toggle must win during the echo window; callers pass the reconciled value. - viewMode?: Tab['viewMode'] -): Tab { - return { - id: tab.id, - entityId: tab.id, - groupId, - worktreeId: tab.worktreeId, - executionHostId: toRuntimeExecutionHostId(environmentId), - contentType: 'terminal', - label: tab.title, - ...(tab.quickCommandLabel?.trim() ? { quickCommandLabel: tab.quickCommandLabel.trim() } : {}), - ...(tab.generatedTitle?.trim() ? { generatedLabel: tab.generatedTitle.trim() } : {}), - ...(tab.aiVaultTitle ? { aiVaultTitle: tab.aiVaultTitle } : {}), - customLabel: tab.customTitle, - color: tab.color, - sortOrder: tab.sortOrder, - createdAt: tab.createdAt, - isPreview: false, - isPinned: tab.isPinned === true, - ...(viewMode ? { viewMode } : {}) - } -} - -function buildBrowserUnifiedTab( - tab: BrowserWorkspace, - hostTab: RuntimeMobileSessionBrowserTab, - existingUnifiedTab: Tab | null, - groupId: string, - environmentId: string -): Tab { - return { - id: existingUnifiedTab?.id ?? hostTab.id, - entityId: tab.id, - groupId, - worktreeId: tab.worktreeId, - executionHostId: toRuntimeExecutionHostId(environmentId), - contentType: 'browser', - label: tab.title, - customLabel: null, - color: hostTab.color !== undefined ? hostTab.color : (existingUnifiedTab?.color ?? null), - sortOrder: tab.createdAt, - createdAt: tab.createdAt, - isPreview: false, - isPinned: - hostTab.isPinned !== undefined - ? hostTab.isPinned === true - : existingUnifiedTab?.isPinned === true - } -} - -function buildEditorUnifiedTab( - file: OpenFile, - tab: ReadyEditorSurface, - hostTabId: string, - existingUnifiedTab: Tab | null, - label: string, - groupId: string, - sortOrder: number, - createdAt: number, - environmentId: string -): Tab { - return { - id: hostTabId, - entityId: file.id, - groupId, - worktreeId: file.worktreeId, - executionHostId: toRuntimeExecutionHostId(environmentId), - contentType: 'editor', - label, - customLabel: null, - color: tab.color !== undefined ? tab.color : (existingUnifiedTab?.color ?? null), - sortOrder, - createdAt, - isPreview: false, - isPinned: - tab.isPinned !== undefined ? tab.isPinned === true : existingUnifiedTab?.isPinned === true - } -} - -function buildMirroredEditorTabs( - snapshot: RuntimeMobileSessionTabsResult, - environmentId: string, - worktreeOpenFileById: ReadonlyMap, - existingTabIndex: WebSessionExistingTabIndex, - hostGroupIdByTabId: ReadonlyMap, - fallbackGroupId: string, - sortOffset: number, - now: number -): MirroredEditorTab[] { - return snapshot.tabs.filter(isReadyEditorTab).map((tab, index) => { - const fileId = localEditorFileId(tab) - const existingFile = worktreeOpenFileById.get(fileId) - const existingUnifiedTab = existingTabIndex.getEditorUnifiedTab(fileId, tab.id) - const sourceFileId = editorSourceFileId(tab) - const groupId = hostGroupIdByTabId.get(tab.id) ?? fallbackGroupId - const file: OpenFile = { - ...existingFile, - id: fileId, - filePath: tab.filePath, - relativePath: tab.relativePath, - worktreeId: snapshot.worktree, - language: tab.language, - isDirty: tab.isDirty, - runtimeEnvironmentId: environmentId, - mode: tab.type === 'markdown' ? tab.mode : 'edit', - markdownPreviewSourceFileId: sourceFileId, - // Why: marks this tab host-owned so a later snapshot that omits it can cull it; locally opened tabs lack this flag and survive. - mirroredFromRuntimeSession: true - } - return { - file, - hostTabId: tab.id, - unifiedTab: buildEditorUnifiedTab( - file, - tab, - tab.id, - existingUnifiedTab, - tab.title.trim() || tab.relativePath || 'File', - groupId, - sortOffset + index, - existingUnifiedTab?.createdAt ?? now + sortOffset + index, - environmentId - ) - } - }) -} - -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 - ) -} - -/** A page of this environment whose guest runs on this desktop, so it survives the host process. */ -function browserWorkspaceHasClientHostedEnvironmentPage( - state: WebSessionTabsSyncState, - workspace: BrowserWorkspace, - environmentId: string -): boolean { - return (state.browserPagesByWorkspace[workspace.id] ?? []).some((page) => { - const handle = state.remoteBrowserPageHandlesByPageId[page.id] - return ( - handle?.environmentId === environmentId && - (handle.placement?.kind === 'client' || - handle.stagedClientHosted === true || - handle.restoredClientHosted === true) - ) - }) -} - -/** - * The host publishes `title || url || 'Browser'` (see the runtime's browser tab projection), so a - * page that has not produced a real title yet arrives as its own url — or as the bare default. - * Neither says anything the local row does not already say better, so while the page still sits at - * the url this client gave it, the local title stands and only a real navigation replaces it. - * - * Scope: the pre-adoption/staged window, every placement the host actually drives, and the mirror - * of a page some other client hosts. Rows whose guest is ours never reach here — see - * resolveMirroredBrowserPageContent. - */ -function resolveMirroredBrowserTitle( - tab: RuntimeMobileSessionBrowserTab, - existingPage: BrowserPage | undefined -): string { - const published = tab.title.trim() - const publishedIsHostFallback = - published === '' || published === tab.url.trim() || published === 'Browser' - if (existingPage && publishedIsHostFallback && tab.url === existingPage.url) { - return existingPage.title - } - return published || 'Browser' -} - -/** - * Whether the guest running this page is one of ours. `placement.kind === 'client'` says only that - * *some* client hosts it: a second desktop, or the web client that installs no page renderer at - * all, mirrors the same row with nothing of its own to see it with. The placement names the lease - * holder, so the comparison is exact, and it holds from the moment the host mints the placement — - * before our guest has attached — which is the window the title would otherwise flicker through. - */ -function clientHostsMirroredBrowserPage(tab: RuntimeMobileSessionBrowserTab): boolean { - if (tab.placement?.kind !== 'client') { - return false - } - const hostClientId = readBrowserClientHostId() - return hostClientId !== null && tab.placement.browserHostClientId === hostClientId -} - -/** - * A page hosted by this client lives in our own guest webview; the host only ever learns its - * content second-hand through a fire-and-forget metadata publish, so its snapshot is a stale echo - * at best and the registry's `'Browser'`/create-time defaults at worst. Once a local row exists it - * is the truth. - * - * The url is part of the payload and not just a symptom: rewinding it to the host's create-time - * value is what breaks the staged-title hold's url-equality arm on the following snapshot. - * - * Every other row keeps taking the host values — a client that hosts no guest has no second - * opinion to offer, and freezing its mirror would strand it on whatever its first snapshot said. - */ -function resolveMirroredBrowserPageContent( - tab: RuntimeMobileSessionBrowserTab, - existingPage: BrowserPage | undefined -): Pick { - if (clientHostsMirroredBrowserPage(tab) && existingPage) { - return { - url: existingPage.url, - title: existingPage.title, - loading: existingPage.loading, - canGoBack: existingPage.canGoBack, - canGoForward: existingPage.canGoForward - } - } - return { - url: tab.url, - title: resolveMirroredBrowserTitle(tab, existingPage), - loading: tab.loading, - canGoBack: tab.canGoBack, - canGoForward: tab.canGoForward - } -} - -function buildMirroredBrowserTabs( - snapshot: RuntimeMobileSessionTabsResult, - environmentId: string, - state: WebSessionTabsSyncState, - hostGroupIdByTabId: ReadonlyMap, - fallbackGroupId: string, - sortOffset: number, - now: number -): MirroredBrowserTab[] { - const renderedGroupIds = collectLayoutGroupIds(state.layoutByWorktree[snapshot.worktree]) - const clientGroupIds = new Set( - (state.groupsByWorktree[snapshot.worktree] ?? []).map((group) => group.id) - ) - return snapshot.tabs.filter(isReadyBrowserTab).map((tab, index) => { - const existing = findBrowserWorkspaceForRemotePage( - state, - snapshot.worktree, - environmentId, - tab.browserPageId - ) - const workspaceId = existing?.workspace.id ?? tab.browserWorkspaceId - const pageId = existing?.page.id ?? tab.browserPageId - const createdAt = existing?.page.createdAt ?? now + sortOffset + index - const recordedClientGroupId = peekWebSessionBrowserPlacementGroup({ - environmentId, - worktreeId: snapshot.worktree, - remotePageId: tab.browserPageId - }) - const hostGroupId = hostGroupIdByTabId.get(tab.id) ?? fallbackGroupId - const existingClientGroupId = - existing?.unifiedTab?.groupId !== hostGroupId ? existing?.unifiedTab?.groupId : undefined - // Why: a staged row was placed by this client, so wherever it sits now is the user's own - // choice — including a split made after the create recorded its intent. Rows the client never - // staged carry no such truth: a pre-response snapshot may have parked them in the host group, - // and there the record is what repairs them. - const preferredClientGroupId = - existing && state.remoteBrowserPageHandlesByPageId[existing.page.id]?.staged === true - ? (existing.unifiedTab?.groupId ?? recordedClientGroupId) - : (recordedClientGroupId ?? existingClientGroupId) - const clientGroupId = - preferredClientGroupId && - clientGroupIds.has(preferredClientGroupId) && - (renderedGroupIds.size === 0 || renderedGroupIds.has(preferredClientGroupId)) - ? preferredClientGroupId - : undefined - const groupId = clientGroupId ?? hostGroupId - const content = resolveMirroredBrowserPageContent(tab, existing?.page) - const nextPage: BrowserPage = { - id: pageId, - workspaceId, - worktreeId: snapshot.worktree, - ...content, - faviconUrl: existing?.page.faviconUrl ?? null, - // Why: a client-hosted page's load failure is observed by the local guest webview and the - // host publishes no loadError for it, so the host snapshot must not clear the local record. - loadError: - (tab.placement?.kind === 'client' ? existing?.page.loadError : tab.loadError) ?? null, - createdAt, - browserRuntimeEnvironmentId: environmentId, - viewportPresetId: existing?.page.viewportPresetId ?? null - } - // Why: reuse hinges on browserPageEqual comparing workspaceId — the removed-workspace - // page-list cleanup gates on page.workspaceId matching this entry's workspace.id. - const page = existing && browserPageEqual(existing.page, nextPage) ? existing.page : nextPage - const workspace: BrowserWorkspace = { - id: workspaceId, - worktreeId: snapshot.worktree, - label: existing?.workspace.label, - sessionProfileId: existing?.workspace.sessionProfileId ?? null, - activePageId: page.id, - pageIds: [page.id], - url: page.url, - title: page.title, - loading: page.loading, - faviconUrl: page.faviconUrl, - canGoBack: page.canGoBack, - canGoForward: page.canGoForward, - loadError: page.loadError, - createdAt - } - return { - workspace, - page, - certificateFailure: tab.certificateFailure ?? null, - remotePageId: tab.browserPageId, - ...(tab.placement ? { placement: tab.placement } : {}), - unifiedTab: buildBrowserUnifiedTab( - workspace, - tab, - existing?.unifiedTab ?? null, - groupId, - environmentId - ), - hostTabId: tab.id, - ...(clientGroupId ? { clientGroupId } : {}) - } - }) -} - -function chooseTargetGroupId( - state: WebSessionTabsSyncState, - snapshot: RuntimeMobileSessionTabsResult -): string { - const groups = state.groupsByWorktree[snapshot.worktree] ?? [] - const layoutGroupIds = collectLayoutGroupIds(state.layoutByWorktree[snapshot.worktree]) - const inRenderedLayout = (groupId: string | null | undefined): boolean => - Boolean(groupId && (layoutGroupIds.size === 0 || layoutGroupIds.has(groupId))) - const preferred = - groups.find((group) => group.id === snapshot.activeGroupId && inRenderedLayout(group.id)) ?? - groups.find( - (group) => - group.id === state.activeGroupIdByWorktree[snapshot.worktree] && inRenderedLayout(group.id) - ) ?? - groups.find((group) => inRenderedLayout(group.id)) - // Why: host snapshots can reference desktop-only group ids; the rendered group is the only safe CSS anchor for mirrored panes. - const firstRenderedLayoutGroupId = layoutGroupIds.values().next().value as string | undefined - return ( - preferred?.id ?? - firstRenderedLayoutGroupId ?? - snapshot.activeGroupId ?? - `${WEB_SESSION_GROUP_PREFIX}${snapshot.worktree}` - ) -} - -function collectLayoutGroupIds(layout: TabGroupLayoutNode | undefined): Set { - const result = new Set() - 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 { - const result = new Map() - 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 -): 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 dropTabGroupLayoutGroups( - layout: TabGroupLayoutNode | null, - excludedGroupIds: ReadonlySet -): TabGroupLayoutNode | null { - if (!layout) { - return null - } - if (layout.type === 'leaf') { - return excludedGroupIds.has(layout.groupId) ? null : layout - } - const first = dropTabGroupLayoutGroups(layout.first, excludedGroupIds) - const second = dropTabGroupLayoutGroups(layout.second, excludedGroupIds) - if (first && second) { - return { ...layout, first, second } - } - return first ?? second -} - -function appendTabGroupLayout( - first: TabGroupLayoutNode | null, - second: TabGroupLayoutNode | null -): TabGroupLayoutNode | null { - if (!first) { - return second - } - if (!second) { - return first - } - // Why: a group already placed by `first` must not gain a second leaf — two - // leaves for one group render the same tab strip in two columns, and each - // later snapshot appends another copy. - const appended = dropTabGroupLayoutGroups(second, collectLayoutGroupIds(first)) - if (!appended) { - return first - } - return { - type: 'split', - direction: 'horizontal', - first, - second: appended - } -} - -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, - 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, - agentTabs -}: { - terminalSurfaces: readonly TerminalSurface[] - terminalTabs: readonly TerminalTab[] - browserTabs: readonly MirroredBrowserTab[] - editorTabs: readonly MirroredEditorTab[] - agentTabs: readonly MirroredAgentTab[] -}): Map { - const hostToLocal = new Map() - const terminalIds = new Set(terminalTabs.map((tab) => tab.id)) - for (const surface of terminalSurfaces) { - const localId = toWebTerminalSurfaceTabId(surface.parentTabId) - if (terminalIds.has(localId)) { - hostToLocal.set(surface.parentTabId, localId) - hostToLocal.set(surface.id, localId) - } - } - for (const entry of browserTabs) { - hostToLocal.set(entry.hostTabId, entry.unifiedTab.id) - hostToLocal.set(entry.unifiedTab.id, entry.unifiedTab.id) - } - for (const entry of editorTabs) { - hostToLocal.set(entry.hostTabId, entry.unifiedTab.id) - } - for (const entry of agentTabs) { - hostToLocal.set(entry.hostTabId, entry.unifiedTab.id) - } - return hostToLocal -} - -function updateHostSessionTabIdMappings(args: { - environmentId: string - worktreeId: string - terminalSurfaces: readonly TerminalSurface[] - terminalTabs: readonly TerminalTab[] - browserTabs: readonly MirroredBrowserTab[] - editorTabs: readonly MirroredEditorTab[] - agentTabs: readonly MirroredAgentTab[] -}): void { - clearHostSessionTabIdMappings(args.environmentId, args.worktreeId) - - const mirroredTerminalIds = new Set(args.terminalTabs.map((tab) => tab.id)) - for (const surface of args.terminalSurfaces) { - const localId = toWebTerminalSurfaceTabId(surface.parentTabId) - if (mirroredTerminalIds.has(localId)) { - setHostSessionTabIdMapping({ ...args, tabId: localId }, surface.parentTabId) - } - } - for (const entry of args.browserTabs) { - setHostSessionTabIdMapping({ ...args, tabId: entry.unifiedTab.id }, entry.hostTabId) - } - for (const entry of args.editorTabs) { - setHostSessionTabIdMapping({ ...args, tabId: entry.unifiedTab.id }, entry.hostTabId) - } - for (const entry of args.agentTabs) { - setHostSessionTabIdMapping({ ...args, tabId: entry.unifiedTab.id }, entry.hostTabId) - } -} - -function retainClientPlacedMirroredTabs(args: { - groups: readonly TabGroup[] - mirroredUnifiedIds: ReadonlySet - validUnifiedTabIds: ReadonlySet - clientGroupIdByLocalTabId: ReadonlyMap - nextActiveUnifiedTabId: string | null -}): TabGroup[] { - return args.groups.map((group) => { - const retainedTabOrder = group.tabOrder.filter( - (tabId) => - args.validUnifiedTabIds.has(tabId) && - (!args.mirroredUnifiedIds.has(tabId) || - args.clientGroupIdByLocalTabId.get(tabId) === group.id) - ) - const placedTabIds = [...args.clientGroupIdByLocalTabId] - .filter( - ([tabId, groupId]) => - groupId === group.id && - args.validUnifiedTabIds.has(tabId) && - !retainedTabOrder.includes(tabId) - ) - .map(([tabId]) => tabId) - const tabOrder = [...retainedTabOrder, ...placedTabIds] - const activeTabId = - args.nextActiveUnifiedTabId && tabOrder.includes(args.nextActiveUnifiedTabId) - ? args.nextActiveUnifiedTabId - : group.activeTabId && tabOrder.includes(group.activeTabId) - ? group.activeTabId - : (tabOrder[0] ?? null) - return { - ...group, - tabOrder, - activeTabId, - recentTabIds: activeTabId - ? pushRecentTabId(sanitizeRecentTabIds(group.recentTabIds, tabOrder), activeTabId) - : [] - } - }) -} - -function buildMirroredHostGroups({ - currentGroups, - hostGroups, - hostToLocalTabId, - mirroredUnifiedIds, - nextActiveUnifiedTabId, - now, - validUnifiedTabIds, - environmentId, - worktreeId, - clientGroupIdByLocalTabId -}: { - currentGroups: readonly TabGroup[] - hostGroups: readonly RuntimeMobileSessionTabGroup[] - hostToLocalTabId: ReadonlyMap - mirroredUnifiedIds: ReadonlySet - nextActiveUnifiedTabId: string | null - now: number - validUnifiedTabIds: ReadonlySet - environmentId: string - worktreeId: string - clientGroupIdByLocalTabId: ReadonlyMap -}): TabGroup[] | null { - const strippedGroups = retainClientPlacedMirroredTabs({ - groups: currentGroups, - mirroredUnifiedIds, - validUnifiedTabIds, - clientGroupIdByLocalTabId, - nextActiveUnifiedTabId - }) - const groupsById = new Map(strippedGroups.map((group) => [group.id, group])) - const orderedGroups: TabGroup[] = [] - const seen = new Set() - - for (const hostGroup of hostGroups) { - const existing = groupsById.get(hostGroup.id) - const localHostOrder = hostGroup.tabOrder - .map((tabId) => hostToLocalTabId.get(tabId)) - .filter( - (tabId): tabId is string => - tabId !== undefined && - validUnifiedTabIds.has(tabId) && - !clientGroupIdByLocalTabId.has(tabId) - ) - const localHostOrderIds = new Set(localHostOrder) - const hostTabOrder = [ - ...(existing?.tabOrder.filter((tabId) => !localHostOrderIds.has(tabId)) ?? []), - ...localHostOrder - ] - // Why: a pending client reorder wins over a stale pre-move host order until the host echoes the move (or membership changes). - const tabOrder = resolveWebSessionReorderedOrder( - { environmentId }, - worktreeId, - hostGroup.id, - hostTabOrder, - now - ) - if (tabOrder.length === 0) { - continue - } - const activeFromHost = - hostGroup.activeTabId !== null ? (hostToLocalTabId.get(hostGroup.activeTabId) ?? null) : null - const activeTabId = - nextActiveUnifiedTabId && tabOrder.includes(nextActiveUnifiedTabId) - ? nextActiveUnifiedTabId - : activeFromHost && tabOrder.includes(activeFromHost) - ? activeFromHost - : existing?.activeTabId && tabOrder.includes(existing.activeTabId) - ? existing.activeTabId - : (tabOrder[0] ?? null) - orderedGroups.push({ - id: hostGroup.id, - worktreeId, - tabOrder, - activeTabId, - recentTabIds: activeTabId - ? pushRecentTabId( - mapHostRecentTabIds(hostGroup.recentTabIds, hostToLocalTabId, tabOrder), - activeTabId - ) - : [] - }) - seen.add(hostGroup.id) - } - - for (const group of strippedGroups) { - if ( - !seen.has(group.id) && - (group.tabOrder.length > 0 || - isWebSessionBrowserPlacementGroupReserved({ worktreeId, groupId: group.id })) - ) { - orderedGroups.push(group) - } - } - - return orderedGroups.length > 0 ? orderedGroups : null -} - -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.workingMode === b.workingMode && - a.prompt === b.prompt && - a.updatedAt === b.updatedAt && - a.stateStartedAt === b.stateStartedAt && - a.agentType === b.agentType && - a.paneKey === b.paneKey && - a.worktreeId === b.worktreeId && - a.tabId === b.tabId && - a.terminalTitle === b.terminalTitle && - a.toolName === b.toolName && - a.toolInput === b.toolInput && - a.interactivePrompt === b.interactivePrompt && - a.lastAssistantMessage === b.lastAssistantMessage && - a.interrupted === b.interrupted && - a.promptInteractionKey === b.promptInteractionKey && - a.restoredUnconfirmed === b.restoredUnconfirmed && - agentProviderSessionsEqual(a.agentType, a.providerSession, b.providerSession) && - sameAgentStateHistory(a.stateHistory, b.stateHistory) - ) -} - -function isAgentStatusFresh( - entry: Pick, - now: number -): boolean { - return entry.restoredUnconfirmed !== true && now - entry.updatedAt <= AGENT_STATUS_STALE_AFTER_MS -} - -function isMirroredCommandCodeTurnBump( - existing: AgentStatusEntry | undefined, - entry: AgentStatusEntry -): boolean { - return ( - existing?.agentType === 'command-code' && - entry.agentType === 'command-code' && - existing.state === 'working' && - entry.state === 'working' && - entry.stateStartedAt > existing.stateStartedAt - ) -} - -function sanitizeRecentTabIds(recent: string[] | undefined, tabOrder: string[]): string[] { - if (!recent || recent.length === 0) { - return [] - } - const valid = new Set(tabOrder) - const seen = new Set() - 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 writableWebSessionTabsRecord( - state: WebSessionTabsSyncState, - recordKey: K, - batchContext?: WebSessionTabsBatchContext -): NonNullable { - const record = (state[recordKey] ?? {}) as NonNullable - if (!batchContext) { - return { ...record } as NonNullable - } - // Why: one batch owns its record copies, so later snapshots can update them without recopying every workspace. - if (batchContext.changedRecords.has(recordKey)) { - return record - } - const next = { ...record } as NonNullable - const mutableState = state as unknown as Record< - WebSessionTabsBatchRecordKey, - Record - > - mutableState[recordKey] = next as Record - batchContext.changedRecords.add(recordKey) - return next -} - -function withWorktreeEntry( - state: WebSessionTabsSyncState, - recordKey: WebSessionTabsBatchRecordKey, - key: string, - value: T | null, - equal: (a: T | undefined, b: T | null) => boolean, - batchContext?: WebSessionTabsBatchContext, - deleteNull = true -): Record { - const record = (state[recordKey] ?? {}) as Record - if (equal(record[key], value)) { - return record - } - const next = writableWebSessionTabsRecord(state, recordKey, batchContext) as Record - if (value === null && deleteNull) { - delete next[key] - } else { - next[key] = value as T - } - 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.aiVaultTitle?.agent === b.aiVaultTitle?.agent && - a.aiVaultTitle?.sessionId === b.aiVaultTitle?.sessionId && - a.aiVaultTitle?.title === b.aiVaultTitle?.title && - a.customTitle === b.customTitle && - a.color === b.color && - a.sortOrder === b.sortOrder && - a.createdAt === b.createdAt && - a.generation === b.generation && - a.shellOverride === b.shellOverride && - a.launchAgent === b.launchAgent && - a.pendingActivationSpawn === b.pendingActivationSpawn - ) -} - -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 optionalRuntimeBrowserPlacementsEqual( - left: RuntimeBrowserPlacement | undefined, - right: RuntimeBrowserPlacement | undefined -): boolean { - return left === undefined - ? right === undefined - : right !== undefined && sameRuntimeBrowserPlacement(left, right) -} - -function browserCertificateFailureEqual( - a: BrowserCertificateFailure | null | undefined, - b: BrowserCertificateFailure | null | undefined -): boolean { - const left = a ?? null - const right = b ?? null - if (left === right) { - return true - } - return Boolean( - left && - right && - left.challengeId === right.challengeId && - left.browserPageId === right.browserPageId && - left.errorCode === right.errorCode && - left.error === right.error && - left.origin === right.origin && - left.displayHost === right.displayHost && - left.canProceed === right.canProceed && - left.observedAt === right.observedAt - ) -} - -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]!)) -} - -/** This worktree's open files — the only scope a snapshot reconciles, so a batch can - * answer from here instead of walking every open file in the app. */ -function webSessionOpenFilesForWorktree( - state: WebSessionTabsSyncState, - worktreeId: string, - batchContext?: WebSessionTabsBatchContext -): readonly OpenFile[] { - if (!batchContext) { - return state.openFiles.filter((file) => file.worktreeId === worktreeId) - } - let index = batchContext.openFilesIndex - if (!index || index.source !== state.openFiles) { - const byWorktree = new Map() - for (const file of state.openFiles) { - const bucket = byWorktree.get(file.worktreeId) ?? [] - bucket.push(file) - byWorktree.set(file.worktreeId, bucket) - } - index = { source: state.openFiles, byWorktree } - batchContext.openFilesIndex = index - } - return index.byWorktree.get(worktreeId) ?? [] -} - -/** Retargets the index at the array a snapshot just produced, re-bucketing only the - * worktree that changed. Rebuilding it wholesale would cost the entire array again on - * every snapshot, which is the cost this index exists to avoid. */ -function advanceWebSessionOpenFilesIndex( - batchContext: WebSessionTabsBatchContext | undefined, - nextOpenFiles: readonly OpenFile[], - worktreeId: string -): void { - const index = batchContext?.openFilesIndex - if (!index || index.source === nextOpenFiles) { - return - } - const bucket: OpenFile[] = [] - for (const file of nextOpenFiles) { - if (file.worktreeId === worktreeId) { - bucket.push(file) - } - } - index.byWorktree.set(worktreeId, bucket) - index.source = nextOpenFiles -} - -/** Mirrors `openFiles.find()` first-wins lookup, which duplicate ids make observable. */ -function firstOpenFileByIdForWorktree(files: readonly OpenFile[]): Map { - const byId = new Map() - for (const file of files) { - if (!byId.has(file.id)) { - byId.set(file.id, file) - } - } - return byId -} - -function tabEqual(a: Tab, b: Tab): boolean { - return ( - a.id === b.id && - a.entityId === b.entityId && - a.groupId === b.groupId && - a.worktreeId === b.worktreeId && - a.executionHostId === b.executionHostId && - a.contentType === b.contentType && - a.agentSessionAgent === b.agentSessionAgent && - a.label === b.label && - // Why: the generated label is the visible tab title; ignoring it let the - // equality bail keep a unified tab that disagreed with its terminal tab. - a.generatedLabel === b.generatedLabel && - a.aiVaultTitle?.agent === b.aiVaultTitle?.agent && - a.aiVaultTitle?.sessionId === b.aiVaultTitle?.sessionId && - a.aiVaultTitle?.title === b.aiVaultTitle?.title && - a.customLabel === b.customLabel && - a.color === b.color && - a.sortOrder === b.sortOrder && - a.createdAt === b.createdAt && - a.isPreview === b.isPreview && - a.isPinned === b.isPinned - ) -} - -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 === 'agent-session') { - return 'agent-session' - } - if (tab.contentType === 'browser' || tab.contentType === 'terminal') { - return tab.contentType - } - return 'editor' -} - -function applyWebSessionTabsSnapshotWithContext( - state: WebSessionTabsSyncState, - rawSnapshot: RuntimeMobileSessionTabsResult, - environmentId: string, - now = Date.now(), - batchContext?: WebSessionTabsBatchContext, - options?: WebSessionTabsSnapshotApplyOptions -): WebSessionTabsSyncState | Partial { - if (suppressE2eWebRuntimeBrowserSnapshot(rawSnapshot)) { - return state - } - const worktreeId = rawSnapshot.worktree - if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { - return state - } - // Why: an in-flight pre-close snapshot can flash a closing tab back, so drop tabs the client is closing until the host confirms removal. - // Why: key close intents by host session tab id (terminal parentTabId else tab.id), not browserPageId, or browser closes never get suppressed. - const snapshotHostTabId = (tab: RuntimeMobileSessionTabsResult['tabs'][number]): string => - tab.type === 'terminal' ? tab.parentTabId : tab.id - reconcileWebSessionCloseIntents( - { environmentId }, - worktreeId, - new Set(rawSnapshot.tabs.map((tab) => snapshotHostTabId(tab))) - ) - const snapshot: RuntimeMobileSessionTabsResult = rawSnapshot.tabs.some((tab) => - isWebSessionCloseIntentPending({ environmentId }, worktreeId, snapshotHostTabId(tab), now) - ) - ? { - ...rawSnapshot, - tabs: rawSnapshot.tabs.filter( - (tab) => - !isWebSessionCloseIntentPending( - { environmentId }, - worktreeId, - snapshotHostTabId(tab), - now - ) - ) - } - : rawSnapshot - // Why: only a caller-recorded create intent may focus its arriving tab; unsolicited server-active must not steal focus (#5435). - const focusIntent = peekWebSessionFocusIntent({ environmentId }, worktreeId) - const focusIntentHostTabId = focusIntent?.hostTabId ?? null - const matchingFocusIntentTab = - focusIntentHostTabId === null - ? null - : focusIntent?.leafId - ? (snapshot.tabs.find( - (tab) => - tab.type === 'terminal' && - tab.leafId === focusIntent.leafId && - (tab.id === focusIntentHostTabId || tab.parentTabId === focusIntentHostTabId) - ) ?? null) - : (snapshot.tabs.find( - (tab) => - tab.id === focusIntentHostTabId || - (tab.type === 'terminal' && tab.parentTabId === focusIntentHostTabId) || - (tab.type === 'browser' && tab.browserPageId === focusIntentHostTabId) - ) ?? null) - const expectedCurrentLocalTabId = focusIntent?.expectedCurrentLocalTabId - const currentVisibleLocalTabId = resolveWebSessionVisibleTabId(state, worktreeId) - const callerFocusIntentTab = - matchingFocusIntentTab && - (expectedCurrentLocalTabId === undefined || - expectedCurrentLocalTabId === currentVisibleLocalTabId) - ? matchingFocusIntentTab - : null - const followIntentTab = - snapshot.navigationIntent === 'follow' - ? (snapshot.tabs.find((tab) => tab.id === snapshot.activeTabId) ?? null) - : null - const navigationIntentTab = callerFocusIntentTab ?? followIntentTab - const honorSnapshotActiveFocus = navigationIntentTab !== null - if (matchingFocusIntentTab) { - clearWebSessionFocusIntent({ environmentId }, worktreeId) - } - const currentTerminalTabs = state.tabsByWorktree[worktreeId] ?? [] - const existingTerminalById = new Map(currentTerminalTabs.map((tab) => [tab.id, tab])) - const reconcilesNonAgentTabs = options?.contentScope !== 'agent-session' - const terminalSurfaceTabs = reconcilesNonAgentTabs - ? snapshot.tabs.filter(isTerminalSurfaceTab) - : [] - const readyTerminalTabs = terminalSurfaceTabs.filter(isReadyTerminalTab) - const nextRemotePtyIds = new Set( - readyTerminalTabs.map((tab) => toRemoteRuntimePtyId(tab.terminal, environmentId)) - ) - const nextMirroredTerminalIds = new Set( - terminalSurfaceTabs.map((tab) => toWebTerminalSurfaceTabId(tab.parentTabId)) - ) - const nextHostTerminalTabIds = new Set(terminalSurfaceTabs.map((tab) => tab.parentTabId)) - const provisionalHandoffHostTabIds = new Map() - for (const tab of currentTerminalTabs) { - if (isMirroredTerminalSurfaceId(tab.id)) { - continue - } - if (nextHostTerminalTabIds.has(tab.id)) { - provisionalHandoffHostTabIds.set(tab.id, tab.id) - continue - } - const handoff = { - environmentId, - worktreeId, - provisionalTabId: tab.id - } - const hostTabId = resolveWebAgentSessionHandoff(handoff) - if ( - hostTabId !== null && - (nextHostTerminalTabIds.has(hostTabId) || - isWebAgentSessionHandoffPostCreateSnapshotConfirmed(handoff)) - ) { - provisionalHandoffHostTabIds.set(tab.id, hostTabId) - } - } - const exactProvisionalHandoffs = new Set(provisionalHandoffHostTabIds.keys()) - const retainedTerminalTabs = reconcilesNonAgentTabs - ? currentTerminalTabs.filter( - (tab) => - !shouldReplaceTerminalTab( - tab, - environmentId, - nextRemotePtyIds, - nextMirroredTerminalIds, - exactProvisionalHandoffs - ) - ) - : currentTerminalTabs - const mirroredTerminalTabs = buildMirroredTerminalTabs( - snapshot, - environmentId, - existingTerminalById, - state.terminalLayoutsByTabId, - retainedTerminalTabs.length, - now, - callerFocusIntentTab?.type === 'terminal' - ? { - parentTabId: callerFocusIntentTab.parentTabId, - leafId: callerFocusIntentTab.leafId - } - : undefined, - options?.terminalPtyMode - ) - const mirroredTerminalTabEntries = mirroredTerminalTabs.map((entry) => entry.tab) - const retainedTerminalIds = new Set(retainedTerminalTabs.map((tab) => tab.id)) - const nextTerminalTabs = - retainedTerminalTabs.length + mirroredTerminalTabEntries.length > 0 - ? [...retainedTerminalTabs, ...mirroredTerminalTabEntries] - : null - const mirroredTerminalIds = new Set(mirroredTerminalTabEntries.map((tab) => tab.id)) - const removedTerminalIds = new Set( - currentTerminalTabs.filter((tab) => !retainedTerminalIds.has(tab.id)).map((tab) => tab.id) - ) - const removedTerminalResourceIds = [...removedTerminalIds].filter( - (tabId) => !mirroredTerminalIds.has(tabId) - ) - for (const provisionalTabId of exactProvisionalHandoffs) { - clearWebAgentSessionHandoff({ environmentId, worktreeId, provisionalTabId }) - } - - const targetGroupId = chooseTargetGroupId(state, snapshot) - const hostGroupIdByTabId = buildHostGroupIdByTabId(snapshot.tabGroups) - const currentUnifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] - const existingTabIndex = buildWebSessionExistingTabIndex({ unifiedTabs: currentUnifiedTabs }) - const readyBrowserTabs = reconcilesNonAgentTabs ? snapshot.tabs.filter(isReadyBrowserTab) : [] - const nextRemoteBrowserPageIds = new Set(readyBrowserTabs.map((tab) => tab.browserPageId)) - const mirroredBrowserTabs = buildMirroredBrowserTabs( - snapshot, - environmentId, - state, - hostGroupIdByTabId, - targetGroupId, - mirroredTerminalTabEntries.length, - now - ) - const mirroredBrowserWorkspaceIds = new Set( - mirroredBrowserTabs.map((entry) => entry.workspace.id) - ) - const currentBrowserTabs = state.browserTabsByWorktree[worktreeId] ?? [] - const removedBrowserWorkspaceIds = new Set( - (reconcilesNonAgentTabs ? currentBrowserTabs : []) - .filter((tab) => { - if (mirroredBrowserWorkspaceIds.has(tab.id)) { - return true - } - if (!browserWorkspaceHasRemoteEnvironmentPage(state, tab, environmentId)) { - return false - } - // Why: a staged tab holds a handle before its create RPC answers, and a restored tab holds - // one rebuilt from disk before the relaunched host has republished the page. Neither - // handle is evidence the host ever saw this snapshot's worktree, so absence proves - // nothing — culling here would erase an optimistic tab mid-create or a restored tab a - // recovering host is still about to hand back. - if ( - (state.browserPagesByWorkspace[tab.id] ?? []).some((page) => { - const handle = state.remoteBrowserPageHandlesByPageId[page.id] - return handle?.staged === true || handle?.restoredFromSession === true - }) - ) { - return false - } - // Why: a runtime with nothing published for this worktree, or one that has restarted and not - // yet taken its client-hosted pages back, answers with a frame that looks exactly like - // "everything was closed". A page this desktop is still hosting outlives the runtime - // process, so its own guest is the better evidence — hold the row and let adoption publish - // it, rather than deleting a tab that is still rendering. The unreconciled flag is - // host-bounded; the unpublished-worktree frame is not, and its hold lasts until the runtime - // publishes that worktree at all. - if ( - !hostSnapshotAffirmsClientHostedPages(snapshot) && - browserWorkspaceHasClientHostedEnvironmentPage(state, tab, environmentId) - ) { - return false - } - return !(state.browserPagesByWorkspace[tab.id] ?? []).some((page) => { - const handle = state.remoteBrowserPageHandlesByPageId[page.id] - return ( - handle?.environmentId === environmentId && - nextRemoteBrowserPageIds.has(handle.remotePageId) - ) - }) - }) - .map((tab) => tab.id) - ) - const retainedBrowserTabs = currentBrowserTabs.filter( - (tab) => !removedBrowserWorkspaceIds.has(tab.id) - ) - const nextBrowserTabs = - retainedBrowserTabs.length + mirroredBrowserTabs.length > 0 - ? [...retainedBrowserTabs, ...mirroredBrowserTabs.map((entry) => entry.workspace)] - : null - const readyEditorTabs = reconcilesNonAgentTabs ? snapshot.tabs.filter(isReadyEditorTab) : [] - const worktreeOpenFiles = webSessionOpenFilesForWorktree(state, worktreeId, batchContext) - const mirroredEditorTabs = buildMirroredEditorTabs( - snapshot, - environmentId, - firstOpenFileByIdForWorktree(worktreeOpenFiles), - existingTabIndex, - hostGroupIdByTabId, - targetGroupId, - mirroredTerminalTabEntries.length + mirroredBrowserTabs.length, - now - ) - const mirroredAgentTabs = buildMirroredAgentTabs( - snapshot, - hostGroupIdByTabId, - targetGroupId, - mirroredTerminalTabEntries.length + mirroredBrowserTabs.length + mirroredEditorTabs.length, - currentUnifiedTabs, - now - ) - const mirroredEditorFileIds = new Set(mirroredEditorTabs.map((entry) => entry.file.id)) - const mirroredEditorHostTabIds = new Set(mirroredEditorTabs.map((entry) => entry.hostTabId)) - const removedEditorFileIds = new Set( - (reconcilesNonAgentTabs ? worktreeOpenFiles : []) - .filter( - (file) => - file.runtimeEnvironmentId === environmentId && - (file.mode === 'edit' || file.mode === 'markdown-preview') && - // Why: only cull host-mirrored tabs; locally opened files have no host counterpart, so their omission isn't a close signal. - file.mirroredFromRuntimeSession === true && - !mirroredEditorFileIds.has(file.id) - ) - .map((file) => file.id) - ) - const isReplacedOpenFile = (file: OpenFile): boolean => - file.runtimeEnvironmentId === environmentId && - (removedEditorFileIds.has(file.id) || mirroredEditorFileIds.has(file.id)) - const replacedOpenFileCount = worktreeOpenFiles.filter(isReplacedOpenFile).length - // Why: both consumers below ask only about this worktree, so the surviving ids answer - // them in worktree scope instead of walking every open file in the app. - const nextWorktreeOpenFileIds = new Set( - worktreeOpenFiles.filter((file) => !isReplacedOpenFile(file)).map((file) => file.id) - ) - for (const fileId of mirroredEditorFileIds) { - nextWorktreeOpenFileIds.add(fileId) - } - const mirroredOpenFiles = mirroredEditorTabs.map((entry) => entry.file) - const nextOpenFiles = (() => { - // Why: with nothing to drop or mirror, rebuilding reproduces the array exactly, so - // skip the global rebuild the equality check below would have thrown away anyway. - if (replacedOpenFileCount === 0 && mirroredOpenFiles.length === 0) { - return state.openFiles - } - const retained = state.openFiles.filter( - (file) => - !( - file.worktreeId === worktreeId && - file.runtimeEnvironmentId === environmentId && - (removedEditorFileIds.has(file.id) || mirroredEditorFileIds.has(file.id)) - ) - ) - const next = [...retained, ...mirroredOpenFiles] - return sameOpenFiles(state.openFiles, next) ? state.openFiles : next - })() - advanceWebSessionOpenFilesIndex(batchContext, nextOpenFiles, worktreeId) - const retainedUnifiedTabs = currentUnifiedTabs.filter((tab) => { - if (tab.contentType === 'agent-session') { - return false - } - if (tab.contentType === 'browser') { - return ( - !removedBrowserWorkspaceIds.has(tab.entityId) && - !mirroredBrowserWorkspaceIds.has(tab.entityId) - ) - } - if (tab.contentType === 'editor') { - return ( - !removedEditorFileIds.has(tab.entityId) && - !mirroredEditorFileIds.has(tab.entityId) && - !mirroredEditorHostTabIds.has(tab.id) - ) - } - if (tab.contentType !== 'terminal') { - return true - } - if (removedTerminalIds.has(tab.entityId) || removedTerminalIds.has(tab.id)) { - return false - } - return !mirroredTerminalIds.has(tab.entityId) && !mirroredTerminalIds.has(tab.id) - }) - const existingViewModeByTabId = new Map( - currentUnifiedTabs - .filter((tab) => tab.contentType === 'terminal' && tab.viewMode) - .map((tab) => [tab.id, tab.viewMode] as const) - ) - const mirroredTerminalUnifiedTabs = mirroredTerminalTabs.map((entry) => - buildTerminalUnifiedTab( - entry.tab, - hostGroupIdByTabId.get(entry.hostTabId) ?? targetGroupId, - environmentId, - entry.tab.viewMode ?? existingViewModeByTabId.get(entry.tab.id) - ) - ) - const mirroredBrowserUnifiedTabs = mirroredBrowserTabs.map((entry) => entry.unifiedTab) - const mirroredEditorUnifiedTabs = mirroredEditorTabs.map((entry) => entry.unifiedTab) - const mirroredAgentUnifiedTabs = mirroredAgentTabs.map((entry) => entry.unifiedTab) - const mirroredUnifiedTabs = [ - ...mirroredTerminalUnifiedTabs, - ...mirroredBrowserUnifiedTabs, - ...mirroredEditorUnifiedTabs, - ...mirroredAgentUnifiedTabs - ] - const nextUnifiedTabs = - retainedUnifiedTabs.length + mirroredUnifiedTabs.length > 0 - ? [...retainedUnifiedTabs, ...mirroredUnifiedTabs] - : null - const validUnifiedTabIds = new Set(nextUnifiedTabs?.map((tab) => tab.id) ?? []) - const activeHostTerminalId = - terminalSurfaceTabs.find((tab) => tab.id === snapshot.activeTabId)?.id ?? - terminalSurfaceTabs.find((tab) => tab.isActive)?.id ?? - null - const activeHostTerminalParentId = - terminalSurfaceTabs.find((tab) => tab.id === activeHostTerminalId)?.parentTabId ?? - terminalSurfaceTabs.find((tab) => tab.isActive)?.parentTabId ?? - null - const activeMirroredTerminalId = activeHostTerminalId - ? toWebTerminalSurfaceTabId(activeHostTerminalParentId ?? activeHostTerminalId) - : null - const activeHostBrowser = - readyBrowserTabs.find((tab) => tab.id === snapshot.activeTabId) ?? - readyBrowserTabs.find((tab) => tab.isActive) ?? - null - const activeMirroredBrowser = activeHostBrowser - ? (mirroredBrowserTabs.find( - (entry) => entry.remotePageId === activeHostBrowser.browserPageId - ) ?? null) - : null - const activeMirroredBrowserTabId = activeMirroredBrowser?.unifiedTab.id ?? null - const activeMirroredBrowserWorkspaceId = activeMirroredBrowser?.workspace.id ?? null - const activeHostEditor = - readyEditorTabs.find((tab) => tab.id === snapshot.activeTabId) ?? - readyEditorTabs.find((tab) => tab.isActive) ?? - null - const activeMirroredEditor = activeHostEditor - ? (mirroredEditorTabs.find((entry) => entry.hostTabId === activeHostEditor.id) ?? null) - : null - const activeMirroredEditorFileId = activeMirroredEditor?.file.id ?? null - const activeMirroredEditorTabId = activeMirroredEditor?.unifiedTab.id ?? null - const activeHostAgent = - snapshot.tabs - .filter(isAgentSessionTab) - .find((tab) => tab.id === snapshot.activeTabId || tab.isActive) ?? null - const activeMirroredAgentTabId = activeHostAgent - ? (mirroredAgentTabs.find((entry) => entry.hostTabId === activeHostAgent.id)?.unifiedTab.id ?? - null) - : null - const intentMirroredTerminalId = - navigationIntentTab?.type === 'terminal' - ? toWebTerminalSurfaceTabId(navigationIntentTab.parentTabId) - : null - const intentMirroredBrowser = - navigationIntentTab?.type === 'browser' - ? (mirroredBrowserTabs.find( - (entry) => - entry.hostTabId === navigationIntentTab.id || - entry.remotePageId === navigationIntentTab.browserPageId - ) ?? null) - : null - const intentMirroredEditor = - navigationIntentTab?.type === 'markdown' || navigationIntentTab?.type === 'file' - ? (mirroredEditorTabs.find((entry) => entry.hostTabId === navigationIntentTab.id) ?? null) - : null - const intentMirroredAgent = - navigationIntentTab?.type === 'agent-session' - ? (mirroredAgentTabs.find((entry) => entry.hostTabId === navigationIntentTab.id) ?? null) - : null - const currentActiveTerminalStillExists = - state.activeTabIdByWorktree[worktreeId] && - (nextTerminalTabs ?? []).some((tab) => tab.id === state.activeTabIdByWorktree[worktreeId]) - ? state.activeTabIdByWorktree[worktreeId] - : null - // Why: caller intent targets the requested tab even when an older host leaves its own active tab unchanged. - const intentTerminalId = - honorSnapshotActiveFocus && navigationIntentTab?.type === 'terminal' - ? intentMirroredTerminalId - : null - const nextActiveTerminalId = - intentTerminalId ?? - currentActiveTerminalStillExists ?? - (snapshot.activeTabType === 'terminal' - ? (activeMirroredTerminalId ?? mirroredTerminalTabEntries[0]?.id) - : mirroredTerminalTabEntries[0]?.id) ?? - null - const currentActiveBrowserStillExists = - state.activeBrowserTabIdByWorktree[worktreeId] && - (nextBrowserTabs ?? []).some((tab) => tab.id === state.activeBrowserTabIdByWorktree[worktreeId]) - ? state.activeBrowserTabIdByWorktree[worktreeId] - : null - const intentBrowserWorkspaceId = - honorSnapshotActiveFocus && navigationIntentTab?.type === 'browser' - ? (intentMirroredBrowser?.workspace.id ?? null) - : null - const nextActiveBrowserWorkspaceId = - intentBrowserWorkspaceId ?? - currentActiveBrowserStillExists ?? - (snapshot.activeTabType === 'browser' - ? (activeMirroredBrowserWorkspaceId ?? mirroredBrowserTabs[0]?.workspace.id) - : mirroredBrowserTabs[0]?.workspace.id) ?? - null - const activeEditorFileIdForWorktree = state.activeFileIdByWorktree[worktreeId] - const currentActiveEditorStillExists = - activeEditorFileIdForWorktree && nextWorktreeOpenFileIds.has(activeEditorFileIdForWorktree) - ? activeEditorFileIdForWorktree - : null - const intentEditorFileId = honorSnapshotActiveFocus - ? (intentMirroredEditor?.file.id ?? null) - : null - const nextActiveEditorFileId = - intentEditorFileId ?? - currentActiveEditorStillExists ?? - (snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file' - ? (activeMirroredEditorFileId ?? mirroredEditorTabs[0]?.file.id) - : mirroredEditorTabs[0]?.file.id) ?? - null - const currentVisibleUnifiedTabId = resolveWebSessionVisibleTabId( - state, - worktreeId, - nextUnifiedTabs ?? [] - ) - const currentVisibleStructuredTabId = - currentVisibleUnifiedTabId && - nextUnifiedTabs?.find( - (tab) => tab.id === currentVisibleUnifiedTabId && tab.contentType === 'agent-session' - ) - ? currentVisibleUnifiedTabId - : null - const activeGroupId = state.activeGroupIdByWorktree[worktreeId] - // Why: Open Preview to the Side can activate an empty reserved group before the host - // browser lands. A snapshot that still has the host terminal active must not treat - // that emptiness as a terminal focus change. - const reservedEmptyPreviewFallbackTabId = - currentVisibleUnifiedTabId == null && - activeGroupId != null && - isWebSessionBrowserPlacementGroupReserved({ worktreeId, groupId: activeGroupId }) - ? resolveWebSessionSiblingVisibleTabId(state, worktreeId, nextUnifiedTabs ?? []) - : null - // Why: a client-initiated activation also drives the visible unified tab, overriding the sticky current-visible tab. - const intentUnifiedTabId = honorSnapshotActiveFocus - ? navigationIntentTab?.type === 'browser' - ? (intentMirroredBrowser?.unifiedTab.id ?? null) - : navigationIntentTab?.type === 'terminal' - ? intentTerminalId - : navigationIntentTab?.type === 'agent-session' - ? (intentMirroredAgent?.unifiedTab.id ?? null) - : navigationIntentTab?.type === 'markdown' || navigationIntentTab?.type === 'file' - ? (intentMirroredEditor?.unifiedTab.id ?? null) - : null - : null - const nextActiveUnifiedTabId = - intentUnifiedTabId ?? - currentVisibleUnifiedTabId ?? - reservedEmptyPreviewFallbackTabId ?? - (snapshot.activeTabType === 'agent-session' - ? (activeMirroredAgentTabId ?? mirroredAgentUnifiedTabs[0]?.id ?? nextActiveTerminalId) - : snapshot.activeTabType === 'browser' - ? (activeMirroredBrowserTabId ?? - mirroredBrowserTabs[0]?.unifiedTab.id ?? - state.activeTabIdByWorktree[worktreeId] ?? - nextActiveTerminalId) - : snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file' - ? (activeMirroredEditorTabId ?? - mirroredEditorTabs[0]?.unifiedTab.id ?? - state.activeTabIdByWorktree[worktreeId] ?? - nextActiveTerminalId) - : nextActiveTerminalId) - const mirroredUnifiedIds = new Set(mirroredUnifiedTabs.map((tab) => tab.id)) - const hostToLocalTabId = buildHostToLocalTabIdMap({ - terminalSurfaces: terminalSurfaceTabs, - terminalTabs: mirroredTerminalTabEntries, - browserTabs: mirroredBrowserTabs, - editorTabs: mirroredEditorTabs, - agentTabs: mirroredAgentTabs - }) - updateHostSessionTabIdMappings({ - environmentId, - worktreeId, - terminalSurfaces: terminalSurfaceTabs, - terminalTabs: mirroredTerminalTabEntries, - browserTabs: mirroredBrowserTabs, - editorTabs: mirroredEditorTabs, - agentTabs: mirroredAgentTabs - }) - - const currentGroups = state.groupsByWorktree[worktreeId] ?? [] - const clientGroupIdByLocalTabId = new Map( - mirroredBrowserTabs.flatMap((entry) => - entry.clientGroupId ? [[entry.unifiedTab.id, entry.clientGroupId]] : [] - ) - ) - // Why: once this worktree has client groups, placement is client-owned — snapshots may only - // append never-seen tabs, drop vanished ones, and honor explicit focus intent. Host order, - // host actives, and host layout apply only on first adoption (no client groups yet). - const clientOwnedPlacement = (() => { - // Why: a preserveLocalLayout owner keeps the local layout authoritative, so placement - // is client-owned even before any local group record exists — first adoption on an - // empty worktree must repair a rendered-leaf-without-record or materialize a rendered - // group instead of publishing the tab into a group no local leaf will ever show. - if (!nextUnifiedTabs || (currentGroups.length === 0 && !options?.preserveLocalLayout)) { - return null - } - // Why: an entity-identical replacement (provisional terminal → mirrored surface, local - // editor → host editor tab) is a rename — its position and focus must carry over. - const rekeyedTabIds = new Map() - for (const [provisionalTabId, hostTabId] of provisionalHandoffHostTabIds) { - const mirroredId = toWebTerminalSurfaceTabId(hostTabId) - if (mirroredId !== provisionalTabId) { - rekeyedTabIds.set(provisionalTabId, mirroredId) - } - } - for (const entry of mirroredEditorTabs) { - const existing = existingTabIndex.getEditorUnifiedTab(entry.file.id, entry.hostTabId) - if (existing && existing.id !== entry.unifiedTab.id) { - rekeyedTabIds.set(existing.id, entry.unifiedTab.id) - } - } - const knownGroupTabIds = new Set( - currentGroups.flatMap((group) => - group.tabOrder.map((tabId) => rekeyedTabIds.get(tabId) ?? tabId) - ) - ) - // Why: a pending record is this client's own create intent — authoritative even when the - // provisional tab was provisionally adopted elsewhere or the target group record lags its leaf. - // The entry's own clientGroupId comes first, so a group the user moved the row into after the - // create was recorded wins over the group the create asked for. - const placementMoves = mirroredBrowserTabs.flatMap((entry) => { - const recordedGroupId = peekWebSessionBrowserPlacementGroup({ - environmentId, - worktreeId, - remotePageId: entry.remotePageId - }) - if (!recordedGroupId) { - return [] - } - return [{ tabId: entry.unifiedTab.id, groupId: entry.clientGroupId ?? recordedGroupId }] - }) - for (const parentTabId of new Set(terminalSurfaceTabs.map((tab) => tab.parentTabId))) { - const recordedGroupId = peekWebSessionTerminalPlacementGroup({ - environmentId, - worktreeId, - hostTabId: parentTabId - }) - if (recordedGroupId) { - placementMoves.push({ - tabId: toWebTerminalSurfaceTabId(parentTabId), - groupId: recordedGroupId - }) - } - } - const adoptedTabs = mirroredUnifiedTabs - .filter((tab) => !knownGroupTabIds.has(tab.id)) - .map((tab) => ({ - tabId: tab.id, - groupId: clientGroupIdByLocalTabId.get(tab.id) ?? tab.groupId - })) - return reconcileClientOwnedTabPlacement({ - currentGroups, - worktreeId, - validUnifiedTabIds, - adoptedTabs, - placementMoves, - rekeyedTabIds, - intentTabId: honorSnapshotActiveFocus ? (intentUnifiedTabId ?? null) : null, - reservedEmptyGroupFallbackTabId: reservedEmptyPreviewFallbackTabId, - currentActiveGroupId: state.activeGroupIdByWorktree[worktreeId] ?? null, - currentLayout: state.layoutByWorktree[worktreeId] ?? null, - isGroupReserved: (groupId) => - isWebSessionBrowserPlacementGroupReserved({ worktreeId, groupId }) - }) - })() - const nextGroups = (() => { - if (clientOwnedPlacement) { - return clientOwnedPlacement.groups - } - if (!nextUnifiedTabs || nextUnifiedTabs.length === 0) { - return null - } - if (snapshot.tabGroups && snapshot.tabGroups.length > 0) { - return buildMirroredHostGroups({ - currentGroups, - hostGroups: snapshot.tabGroups, - hostToLocalTabId, - mirroredUnifiedIds, - nextActiveUnifiedTabId, - now, - validUnifiedTabIds, - environmentId, - worktreeId, - clientGroupIdByLocalTabId - }) - } - const strippedGroups = retainClientPlacedMirroredTabs({ - groups: currentGroups, - mirroredUnifiedIds, - validUnifiedTabIds, - clientGroupIdByLocalTabId, - nextActiveUnifiedTabId - }) - const target = strippedGroups.find((group) => group.id === targetGroupId) ?? { - id: targetGroupId, - worktreeId, - activeTabId: null, - tabOrder: [], - recentTabIds: [] - } - const targetOrder = [ - ...target.tabOrder.filter((tabId) => validUnifiedTabIds.has(tabId)), - ...mirroredUnifiedTabs - .filter((tab) => !clientGroupIdByLocalTabId.has(tab.id)) - .map((tab) => tab.id) - ] - const targetActiveTabId = - nextActiveUnifiedTabId && targetOrder.includes(nextActiveUnifiedTabId) - ? nextActiveUnifiedTabId - : target.activeTabId && targetOrder.includes(target.activeTabId) - ? target.activeTabId - : (targetOrder[0] ?? null) - const updatedTarget: TabGroup = { - ...target, - worktreeId, - tabOrder: targetOrder, - activeTabId: targetActiveTabId, - recentTabIds: targetActiveTabId - ? pushRecentTabId(sanitizeRecentTabIds(target.recentTabIds, targetOrder), targetActiveTabId) - : [] - } - const merged = strippedGroups.some((group) => group.id === targetGroupId) - ? strippedGroups.map((group) => (group.id === targetGroupId ? updatedTarget : group)) - : [...strippedGroups, updatedTarget] - return merged.filter( - (group) => - group.id === targetGroupId || - group.tabOrder.length > 0 || - isWebSessionBrowserPlacementGroupReserved({ - worktreeId, - groupId: group.id - }) - ) - })() - - const nextTabBarOrder = (() => { - const current = state.tabBarOrderByWorktree[worktreeId] ?? [] - const validTabBarIds = new Set([ - ...retainedUnifiedTabs.map((tab) => tab.id), - ...mirroredUnifiedTabs.map((tab) => tab.id) - ]) - const hostTabBarOrder = - snapshot.tabGroups?.flatMap((group) => - group.tabOrder - .map((tabId) => hostToLocalTabId.get(tabId)) - .filter((tabId): tabId is string => tabId !== undefined && validTabBarIds.has(tabId)) - ) ?? [] - const next: string[] = [] - const seen = new Set() - const push = (tabId: string): void => { - if (validTabBarIds.has(tabId) && !seen.has(tabId)) { - seen.add(tabId) - next.push(tabId) - } - } - // Why: snapshots can arrive after the client staged local browser tabs, so preserve visible order and only append new host tabs. - for (const tabId of current) { - push(tabId) - } - const hostOrMirroredOrder = - hostTabBarOrder.length > 0 ? hostTabBarOrder : mirroredUnifiedTabs.map((tab) => tab.id) - for (const tabId of hostOrMirroredOrder) { - push(tabId) - } - return next - })() - - let nextPtyIdsByTabId = state.ptyIdsByTabId - for (const removedId of removedTerminalResourceIds) { - if (nextPtyIdsByTabId[removedId]) { - nextPtyIdsByTabId = - nextPtyIdsByTabId === state.ptyIdsByTabId - ? writableWebSessionTabsRecord(state, 'ptyIdsByTabId', batchContext) - : nextPtyIdsByTabId - delete nextPtyIdsByTabId[removedId] - } - } - for (const { tab, ptyIds } of mirroredTerminalTabs) { - if (ptyIds.length === 0) { - if (nextPtyIdsByTabId[tab.id]) { - nextPtyIdsByTabId = - nextPtyIdsByTabId === state.ptyIdsByTabId - ? writableWebSessionTabsRecord(state, 'ptyIdsByTabId', batchContext) - : nextPtyIdsByTabId - delete nextPtyIdsByTabId[tab.id] - } - continue - } - const current = nextPtyIdsByTabId[tab.id] ?? [] - if (!sameStringArray(current, ptyIds)) { - nextPtyIdsByTabId = - nextPtyIdsByTabId === state.ptyIdsByTabId - ? writableWebSessionTabsRecord(state, 'ptyIdsByTabId', batchContext) - : nextPtyIdsByTabId - nextPtyIdsByTabId[tab.id] = ptyIds - } - } - - let nextTerminalLayoutsByTabId = state.terminalLayoutsByTabId - for (const removedId of removedTerminalResourceIds) { - if (nextTerminalLayoutsByTabId[removedId]) { - nextTerminalLayoutsByTabId = - nextTerminalLayoutsByTabId === state.terminalLayoutsByTabId - ? writableWebSessionTabsRecord(state, 'terminalLayoutsByTabId', batchContext) - : nextTerminalLayoutsByTabId - delete nextTerminalLayoutsByTabId[removedId] - } - } - for (const { tab, layout } of mirroredTerminalTabs) { - if (!terminalLayoutEqual(nextTerminalLayoutsByTabId[tab.id], layout)) { - nextTerminalLayoutsByTabId = - nextTerminalLayoutsByTabId === state.terminalLayoutsByTabId - ? writableWebSessionTabsRecord(state, 'terminalLayoutsByTabId', batchContext) - : nextTerminalLayoutsByTabId - nextTerminalLayoutsByTabId[tab.id] = layout - } - } - - let nextUnreadTerminalTabs = state.unreadTerminalTabs - for (const removedId of removedTerminalIds) { - if (nextUnreadTerminalTabs[removedId]) { - nextUnreadTerminalTabs = - nextUnreadTerminalTabs === state.unreadTerminalTabs - ? writableWebSessionTabsRecord(state, 'unreadTerminalTabs', batchContext) - : nextUnreadTerminalTabs - delete nextUnreadTerminalTabs[removedId] - } - } - - const pendingStartupByTabId = state.pendingStartupByTabId ?? {} - let nextPendingStartupByTabId = pendingStartupByTabId - const automaticAgentResumeClaimsByTabId = state.automaticAgentResumeClaimsByTabId ?? {} - let nextAutomaticAgentResumeClaimsByTabId = automaticAgentResumeClaimsByTabId - for (const removedId of exactProvisionalHandoffs) { - if (nextPendingStartupByTabId[removedId]) { - nextPendingStartupByTabId = - nextPendingStartupByTabId === pendingStartupByTabId - ? writableWebSessionTabsRecord(state, 'pendingStartupByTabId', batchContext) - : nextPendingStartupByTabId - delete nextPendingStartupByTabId[removedId] - } - if (nextAutomaticAgentResumeClaimsByTabId[removedId]) { - nextAutomaticAgentResumeClaimsByTabId = - nextAutomaticAgentResumeClaimsByTabId === automaticAgentResumeClaimsByTabId - ? writableWebSessionTabsRecord(state, 'automaticAgentResumeClaimsByTabId', batchContext) - : nextAutomaticAgentResumeClaimsByTabId - delete nextAutomaticAgentResumeClaimsByTabId[removedId] - } - } - - let nextBrowserPagesByWorkspace = state.browserPagesByWorkspace - let nextRemoteBrowserPageHandlesByPageId = state.remoteBrowserPageHandlesByPageId - let nextBrowserCertificateFailuresByPageId = state.browserCertificateFailuresByPageId - if (removedBrowserWorkspaceIds.size > 0) { - const nextBrowserWorkspaceIds = new Set(nextBrowserTabs?.map((tab) => tab.id) ?? []) - const nextBrowserPageIds = new Set(mirroredBrowserTabs.map((entry) => entry.page.id)) - for (const workspace of retainedBrowserTabs) { - for (const page of state.browserPagesByWorkspace[workspace.id] ?? []) { - nextBrowserPageIds.add(page.id) - } - } - for (const removedWorkspaceId of removedBrowserWorkspaceIds) { - const pages = nextBrowserPagesByWorkspace[removedWorkspaceId] ?? [] - if ( - !nextBrowserWorkspaceIds.has(removedWorkspaceId) && - nextBrowserPagesByWorkspace[removedWorkspaceId] - ) { - nextBrowserPagesByWorkspace = - nextBrowserPagesByWorkspace === state.browserPagesByWorkspace - ? writableWebSessionTabsRecord(state, 'browserPagesByWorkspace', batchContext) - : nextBrowserPagesByWorkspace - delete nextBrowserPagesByWorkspace[removedWorkspaceId] - } - for (const page of pages) { - if (nextBrowserPageIds.has(page.id)) { - continue - } - if (nextBrowserCertificateFailuresByPageId[page.id]) { - nextBrowserCertificateFailuresByPageId = - nextBrowserCertificateFailuresByPageId === state.browserCertificateFailuresByPageId - ? writableWebSessionTabsRecord( - state, - 'browserCertificateFailuresByPageId', - batchContext - ) - : nextBrowserCertificateFailuresByPageId - delete nextBrowserCertificateFailuresByPageId[page.id] - } - if (nextRemoteBrowserPageHandlesByPageId[page.id]) { - nextRemoteBrowserPageHandlesByPageId = - nextRemoteBrowserPageHandlesByPageId === state.remoteBrowserPageHandlesByPageId - ? writableWebSessionTabsRecord( - state, - 'remoteBrowserPageHandlesByPageId', - batchContext - ) - : nextRemoteBrowserPageHandlesByPageId - delete nextRemoteBrowserPageHandlesByPageId[page.id] - } - } - } - } - for (const { page, certificateFailure, remotePageId, placement } of mirroredBrowserTabs) { - const current = nextBrowserPagesByWorkspace[page.workspaceId] ?? [] - if (!sameBrowserPages(current, [page])) { - nextBrowserPagesByWorkspace = - nextBrowserPagesByWorkspace === state.browserPagesByWorkspace - ? writableWebSessionTabsRecord(state, 'browserPagesByWorkspace', batchContext) - : nextBrowserPagesByWorkspace - nextBrowserPagesByWorkspace[page.workspaceId] = [page] - } - const currentHandle = nextRemoteBrowserPageHandlesByPageId[page.id] - if ( - currentHandle?.environmentId !== environmentId || - currentHandle.remotePageId !== remotePageId || - // Why: this snapshot is the host publishing the page, which is exactly what clears a - // staged handle — without this the optimistic flag would survive every later snapshot. - currentHandle.staged === true || - // Why separately: a host that puts a restored page back on the server publishes no - // placement, so the handle is otherwise identical to the seed and the restored markers - // would never be spent — leaving the row cull-proof and pinned to the client-hosted pane. - currentHandle.restoredFromSession === true || - !optionalRuntimeBrowserPlacementsEqual(currentHandle.placement, placement) - ) { - nextRemoteBrowserPageHandlesByPageId = - nextRemoteBrowserPageHandlesByPageId === state.remoteBrowserPageHandlesByPageId - ? writableWebSessionTabsRecord(state, 'remoteBrowserPageHandlesByPageId', batchContext) - : nextRemoteBrowserPageHandlesByPageId - nextRemoteBrowserPageHandlesByPageId[page.id] = { - environmentId, - remotePageId, - ...(placement ? { placement } : {}) - } - } - // Why here and not on the staged flag alone: the flag is cleared by this very block, so every - // later snapshot in the create's materialization wait would see an un-spent record and move the - // tab back to the group the create asked for. Both peeks (the group precedence and the - // placementMoves repair) run earlier in this same pass by design: moving the mark ahead of them - // would spend the intent before the pass that adopts the page can place it. - markWebSessionBrowserPlacementAdopted({ environmentId, worktreeId, remotePageId }) - // Why: a client-hosted page's certificate failure is raised by the local guest webview and is - // structurally absent from what the host publishes, so host snapshots must not own that record - // — reconciling here would delete the local one on the next metadata update. - if ( - placement?.kind !== 'client' && - !browserCertificateFailureEqual( - nextBrowserCertificateFailuresByPageId[page.id], - certificateFailure - ) - ) { - nextBrowserCertificateFailuresByPageId = - nextBrowserCertificateFailuresByPageId === state.browserCertificateFailuresByPageId - ? writableWebSessionTabsRecord(state, 'browserCertificateFailuresByPageId', batchContext) - : nextBrowserCertificateFailuresByPageId - if (certificateFailure) { - nextBrowserCertificateFailuresByPageId[page.id] = certificateFailure - } else { - delete nextBrowserCertificateFailuresByPageId[page.id] - } - } - } - - const nextTabsByWorktree = withWorktreeEntry( - state, - 'tabsByWorktree', - worktreeId, - nextTerminalTabs, - sameTerminalTabs, - batchContext - ) - const nextBrowserTabsByWorktree = withWorktreeEntry( - state, - 'browserTabsByWorktree', - worktreeId, - nextBrowserTabs, - sameBrowserTabs, - batchContext - ) - // Why: under client-owned placement the reconciled groups are the membership truth; - // the unified tabs' groupId field must agree or TabBar filters them out of their strip. - const placedUnifiedTabs = (() => { - if (!clientOwnedPlacement?.groups || !nextUnifiedTabs) { - return nextUnifiedTabs - } - const groupIdByTabId = new Map( - clientOwnedPlacement.groups.flatMap((group) => - group.tabOrder.map((tabId) => [tabId, group.id] as const) - ) - ) - let changed = false - const placed = nextUnifiedTabs.map((tab) => { - const groupId = groupIdByTabId.get(tab.id) - if (!groupId || groupId === tab.groupId) { - return tab - } - changed = true - return { ...tab, groupId } - }) - return changed ? placed : nextUnifiedTabs - })() - const nextUnifiedTabsByWorktree = withWorktreeEntry( - state, - 'unifiedTabsByWorktree', - worktreeId, - placedUnifiedTabs, - sameUnifiedTabs, - batchContext - ) - const nextGroupsByWorktree = withWorktreeEntry( - state, - 'groupsByWorktree', - worktreeId, - nextGroups, - sameGroups, - batchContext - ) - const nextActiveGroupId = clientOwnedPlacement - ? clientOwnedPlacement.activeGroupId - : // Why: status/title snapshots carry the host's last active tab; a client that already switched panes keeps 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 - ? withWorktreeEntry( - state, - 'activeGroupIdByWorktree', - worktreeId, - nextActiveGroupId ?? targetGroupId, - (current, next) => current === next, - batchContext - ) - : state.activeGroupIdByWorktree - const nextLayoutByWorktree = (() => { - if (!nextGroups) { - return state.layoutByWorktree - } - // Why: client-owned placement derives its layout from the local one (pruned, plus - // repair leaves for surviving groups the layout lost), so a preserveLocalLayout owner - // still applies it — the option only rejects host-authored layout below. - if (clientOwnedPlacement) { - const clientLayout = - clientOwnedPlacement.layout ?? - (nextActiveGroupId ? { type: 'leaf' as const, groupId: nextActiveGroupId } : null) - if (!clientLayout || tabGroupLayoutEqual(state.layoutByWorktree[worktreeId], clientLayout)) { - return state.layoutByWorktree - } - return withWorktreeEntry( - state, - 'layoutByWorktree', - worktreeId, - clientLayout, - (current, next) => current === next, - batchContext - ) - } - if (options?.preserveLocalLayout) { - return state.layoutByWorktree - } - const validGroupIds = new Set(nextGroups.map((group) => group.id)) - const hostLayout = pruneTabGroupLayout(snapshot.tabGroupLayout, validGroupIds) - const defaultLeafLayout = { type: 'leaf' as const, groupId: nextActiveGroupId ?? targetGroupId } - const hostLayoutGroupIds = collectLayoutGroupIds(hostLayout ?? undefined) - const hostGroupIds = new Set(snapshot.tabGroups?.map((group) => group.id) ?? []) - const extraGroupIds = new Set( - nextGroups - .map((group) => group.id) - .filter((groupId) => - hostLayout - ? !hostLayoutGroupIds.has(groupId) - : snapshot.tabGroups && snapshot.tabGroups.length > 0 - ? !hostGroupIds.has(groupId) - : false - ) - ) - const localExtraLayout = pruneTabGroupLayout(state.layoutByWorktree[worktreeId], extraGroupIds) - const hostBaseLayout = - hostLayout ?? (snapshot.tabGroups && snapshot.tabGroups.length > 0 ? defaultLeafLayout : null) - const fallbackLayout = - appendTabGroupLayout(hostBaseLayout, localExtraLayout) ?? - (snapshot.tabGroups && snapshot.tabGroups.length > 0 - ? defaultLeafLayout - : state.layoutByWorktree[worktreeId] - ? null - : defaultLeafLayout) - if (!fallbackLayout) { - return state.layoutByWorktree - } - if (tabGroupLayoutEqual(state.layoutByWorktree[worktreeId], fallbackLayout)) { - return state.layoutByWorktree - } - return withWorktreeEntry( - state, - 'layoutByWorktree', - worktreeId, - fallbackLayout, - (current, next) => current === next, - batchContext - ) - })() - const nextTabBarOrderByWorktree = withWorktreeEntry( - state, - 'tabBarOrderByWorktree', - worktreeId, - nextTabBarOrder.length > 0 ? nextTabBarOrder : null, - (a, b) => sameStringArray(a ?? [], b ?? []), - batchContext - ) - const nextActiveTabIdByWorktree = - (state.activeTabIdByWorktree[worktreeId] ?? null) !== - (intentMirroredAgent?.unifiedTab.id ?? currentVisibleStructuredTabId ?? nextActiveTerminalId) - ? withWorktreeEntry( - state, - 'activeTabIdByWorktree', - worktreeId, - intentMirroredAgent?.unifiedTab.id ?? - currentVisibleStructuredTabId ?? - nextActiveTerminalId, - (current, next) => (current ?? null) === next, - batchContext, - false - ) - : state.activeTabIdByWorktree - const nextActiveBrowserTabIdByWorktree = - (state.activeBrowserTabIdByWorktree[worktreeId] ?? null) !== nextActiveBrowserWorkspaceId - ? withWorktreeEntry( - state, - 'activeBrowserTabIdByWorktree', - worktreeId, - nextActiveBrowserWorkspaceId, - (current, next) => (current ?? null) === next, - batchContext, - false - ) - : state.activeBrowserTabIdByWorktree - const nextActiveFileIdByWorktree = - (state.activeFileIdByWorktree[worktreeId] ?? null) !== nextActiveEditorFileId - ? withWorktreeEntry( - state, - 'activeFileIdByWorktree', - worktreeId, - nextActiveEditorFileId, - (current, next) => (current ?? null) === next, - batchContext, - false - ) - : state.activeFileIdByWorktree - const isActiveWorktree = state.activeWorktreeId === worktreeId - const focusIntentVisibleTabType = - navigationIntentTab?.type === 'agent-session' && intentMirroredAgent - ? ('agent-session' as const) - : navigationIntentTab?.type === 'browser' && intentBrowserWorkspaceId - ? ('browser' as const) - : navigationIntentTab?.type === 'terminal' && intentTerminalId - ? ('terminal' as const) - : intentEditorFileId - ? ('editor' as const) - : null - const snapshotVisibleTabType = - snapshot.activeTabType === 'agent-session' && activeMirroredAgentTabId - ? ('agent-session' as const) - : snapshot.activeTabType === 'browser' && nextActiveBrowserWorkspaceId - ? ('browser' as const) - : snapshot.activeTabType === 'terminal' && nextActiveTerminalId - ? ('terminal' as const) - : (snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file') && - nextActiveEditorFileId - ? ('editor' as const) - : null - const currentVisibleTabType = - state.activeTabTypeByWorktree[worktreeId] ?? (isActiveWorktree ? state.activeTabType : null) - const currentVisibleTabTypeStillValid = - currentVisibleStructuredTabId !== null - ? ('agent-session' as const) - : currentVisibleTabType === 'agent-session' && - currentVisibleUnifiedTabId && - nextUnifiedTabs?.some( - (tab) => tab.id === currentVisibleUnifiedTabId && tab.contentType === 'agent-session' - ) - ? ('agent-session' as const) - : currentVisibleTabType === 'browser' && currentActiveBrowserStillExists - ? ('browser' as const) - : currentVisibleTabType === 'editor' && currentActiveEditorStillExists - ? ('editor' as const) - : currentVisibleTabType === 'terminal' && currentActiveTerminalStillExists - ? ('terminal' as const) - : null - const activeUnifiedTab = - nextActiveUnifiedTabId && nextUnifiedTabs - ? (nextUnifiedTabs.find((tab) => tab.id === nextActiveUnifiedTabId) ?? null) - : null - const fallbackVisibleTabType = - activeUnifiedTab !== null - ? toVisibleTabType(activeUnifiedTab) - : nextActiveTerminalId - ? ('terminal' as const) - : nextActiveBrowserWorkspaceId - ? ('browser' as const) - : nextActiveEditorFileId - ? ('editor' as const) - : ('terminal' as const) - // Why: don't keep pointing shortcuts at a removed browser/editor; a client-initiated activation lets the snapshot's type switch the visible pane. - const nextVisibleTabType = honorSnapshotActiveFocus - ? (focusIntentVisibleTabType ?? - currentVisibleTabTypeStillValid ?? - snapshotVisibleTabType ?? - fallbackVisibleTabType) - : (currentVisibleTabTypeStillValid ?? snapshotVisibleTabType ?? fallbackVisibleTabType) - const currentActiveTerminalStillValid = - state.activeTabId && (nextTerminalTabs ?? []).some((tab) => tab.id === state.activeTabId) - ? state.activeTabId - : null - const currentActiveEditorStillValid = - state.activeFileId && nextWorktreeOpenFileIds.has(state.activeFileId) - ? state.activeFileId - : null - const nextActiveTabId = isActiveWorktree - ? (intentMirroredAgent?.unifiedTab.id ?? - (snapshot.activeTabType === 'terminal' - ? nextActiveTerminalId - : (currentActiveTerminalStillValid ?? nextActiveTerminalId))) - : state.activeTabId - const nextActiveBrowserTabId = isActiveWorktree - ? nextActiveBrowserWorkspaceId - : state.activeBrowserTabId - const nextActiveFileId = isActiveWorktree - ? snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file' - ? nextActiveEditorFileId - : (currentActiveEditorStillValid ?? nextActiveEditorFileId) - : state.activeFileId - const nextActiveTabType = isActiveWorktree ? nextVisibleTabType : state.activeTabType - const nextActiveTabTypeByWorktree = - state.activeTabTypeByWorktree[worktreeId] !== nextVisibleTabType - ? withWorktreeEntry( - state, - 'activeTabTypeByWorktree', - worktreeId, - nextVisibleTabType, - (current, next) => current === next, - batchContext - ) - : state.activeTabTypeByWorktree - const agentStatusPatch = buildMirroredAgentStatusPatch( - state, - currentTerminalTabs, - terminalSurfaceTabs, - mirroredTerminalTabs, - now, - batchContext - ) - // Why: only a host snapshot that omits a tab is a retraction; a tombstone clears the mirror - // for every environment at once, and sweeping there erases rows a live sibling still owns. - const retractedTabSweepPatch = isWebSessionTabsWorktreeRemovalFrame(snapshot) - ? null - : buildRetractedMirroredTabSweepPatch( - state, - worktreeId, - nextTabsByWorktree, - agentStatusPatch, - removedTerminalResourceIds, - batchContext - ) - // Why: mirrored ids are stable, so a published-again id proves the tab is not closed — - // a lingering closed-tab marker would blackhole its byte-derived status for the whole - // session (host-restart subset frames, cross-host collision replays). The close-intent - // filter already holds genuinely closing tabs out of the snapshot. - const remirroredClosedTabLiftPatch = buildRemirroredClosedTabMarkerLiftPatch( - retractedTabSweepPatch?.recentlyClosedAgentStatusTabIds ?? - state.recentlyClosedAgentStatusTabIds, - mirroredTerminalIds - ) - - const patch: Partial = { - ...agentStatusPatch, - ...retractedTabSweepPatch, - ...remirroredClosedTabLiftPatch, - ...(nextOpenFiles !== state.openFiles ? { openFiles: nextOpenFiles } : {}), - ...(nextTabsByWorktree !== state.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}), - ...(nextBrowserTabsByWorktree !== state.browserTabsByWorktree - ? { browserTabsByWorktree: nextBrowserTabsByWorktree } - : {}), - ...(nextUnifiedTabsByWorktree !== state.unifiedTabsByWorktree - ? { unifiedTabsByWorktree: nextUnifiedTabsByWorktree } - : {}), - ...(nextGroupsByWorktree !== state.groupsByWorktree - ? { groupsByWorktree: nextGroupsByWorktree } - : {}), - ...(nextActiveGroupIdByWorktree !== state.activeGroupIdByWorktree - ? { activeGroupIdByWorktree: nextActiveGroupIdByWorktree } - : {}), - ...(nextLayoutByWorktree !== state.layoutByWorktree - ? { layoutByWorktree: nextLayoutByWorktree } - : {}), - ...(nextTabBarOrderByWorktree !== state.tabBarOrderByWorktree - ? { tabBarOrderByWorktree: nextTabBarOrderByWorktree } - : {}), - ...(nextPtyIdsByTabId !== state.ptyIdsByTabId ? { ptyIdsByTabId: nextPtyIdsByTabId } : {}), - ...(nextTerminalLayoutsByTabId !== state.terminalLayoutsByTabId - ? { terminalLayoutsByTabId: nextTerminalLayoutsByTabId } - : {}), - ...(nextUnreadTerminalTabs !== state.unreadTerminalTabs - ? { unreadTerminalTabs: nextUnreadTerminalTabs } - : {}), - ...(nextPendingStartupByTabId !== pendingStartupByTabId - ? { pendingStartupByTabId: nextPendingStartupByTabId } - : {}), - ...(nextAutomaticAgentResumeClaimsByTabId !== automaticAgentResumeClaimsByTabId - ? { automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId } - : {}), - ...(nextBrowserPagesByWorkspace !== state.browserPagesByWorkspace - ? { browserPagesByWorkspace: nextBrowserPagesByWorkspace } - : {}), - ...(nextRemoteBrowserPageHandlesByPageId !== state.remoteBrowserPageHandlesByPageId - ? { remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId } - : {}), - ...(nextBrowserCertificateFailuresByPageId !== state.browserCertificateFailuresByPageId - ? { browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId } - : {}), - ...(nextActiveTabIdByWorktree !== state.activeTabIdByWorktree - ? { activeTabIdByWorktree: nextActiveTabIdByWorktree } - : {}), - ...(nextActiveBrowserTabIdByWorktree !== state.activeBrowserTabIdByWorktree - ? { activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree } - : {}), - ...(nextActiveFileIdByWorktree !== state.activeFileIdByWorktree - ? { activeFileIdByWorktree: nextActiveFileIdByWorktree } - : {}), - ...(nextActiveTabId !== state.activeTabId ? { activeTabId: nextActiveTabId } : {}), - ...(nextActiveBrowserTabId !== state.activeBrowserTabId - ? { activeBrowserTabId: nextActiveBrowserTabId } - : {}), - ...(nextActiveFileId !== state.activeFileId ? { activeFileId: nextActiveFileId } : {}), - ...(nextActiveTabType !== state.activeTabType ? { activeTabType: nextActiveTabType } : {}), - ...(nextActiveTabTypeByWorktree !== state.activeTabTypeByWorktree - ? { activeTabTypeByWorktree: nextActiveTabTypeByWorktree } - : {}) - } - - return Object.keys(patch).length === 0 ? state : patch -} - -export function applyWebSessionTabsSnapshot( - state: WebSessionTabsSyncState, - rawSnapshot: RuntimeMobileSessionTabsResult, - environmentId: string, - now = Date.now(), - options?: WebSessionTabsSnapshotApplyOptions -): WebSessionTabsSyncState | Partial { - return applyWebSessionTabsSnapshotWithContext( - state, - rawSnapshot, - environmentId, - now, - undefined, - options - ) -} - -export function applyWebSessionTabsSnapshots( - state: WebSessionTabsSyncState, - snapshots: readonly RuntimeMobileSessionTabsResult[], - environmentId: string, - now = Date.now() -): WebSessionTabsSyncState | Partial { - const nextState = { ...state } - const batchContext: WebSessionTabsBatchContext = { - agentPaneKeysByTabId: null, - changedRecords: new Set(), - openFilesIndex: null - } - let mergedPatch: Partial = {} - for (const snapshot of snapshots) { - const patch = applyWebSessionTabsSnapshotWithContext( - nextState, - snapshot, - environmentId, - now, - batchContext - ) - if (patch === nextState) { - continue - } - mergedPatch = { ...mergedPatch, ...patch } - Object.assign(nextState, patch) - } - const mutableMergedPatch = mergedPatch as Record - const mutableNextState = nextState as unknown as Record - for (const recordKey of batchContext.changedRecords) { - mutableMergedPatch[recordKey] = mutableNextState[recordKey] - } - return Object.keys(mergedPatch).length === 0 ? state : mergedPatch -} - -export function applyFreshWebSessionTabsSnapshot( - state: WebSessionTabsSyncState, - snapshot: RuntimeMobileSessionTabsResult, - environmentId: string, - now = Date.now() -): WebSessionTabsSyncState | Partial { - 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 { - const freshSnapshots = snapshots.filter((snapshot) => - shouldApplyWebSessionTabsSnapshot(snapshot, environmentId) - ) - return freshSnapshots.length === 0 - ? state - : applyWebSessionTabsSnapshots(state, freshSnapshots, environmentId, now) -} - -type WebSessionTabsSnapshotOperation = { - environmentId: string - snapshot: RuntimeMobileSessionTabsResult -} - -type DecidedWebSessionTabsSnapshotOperation = WebSessionTabsSnapshotOperation & { - decision: WebSessionTabsSnapshotDecision -} - -/** Why: the settle must name the decision the apply ran on, so each operation - * is decided once here instead of again inside the store updater. */ -function decideWebSessionTabsSnapshotOperations( - operations: readonly WebSessionTabsSnapshotOperation[] -): DecidedWebSessionTabsSnapshotOperation[] { - return operations.map((operation) => ({ - ...operation, - decision: decideWebSessionTabsSnapshot(operation.snapshot, operation.environmentId) - })) -} - -function applyWebSessionTabsSnapshotOperations( - state: WebSessionTabsSyncState, - operations: readonly DecidedWebSessionTabsSnapshotOperation[] -): WebSessionTabsSyncState | Partial { - let nextState = state - let mergedPatch: Partial = {} - for (const { environmentId, snapshot, decision } of operations) { - if (!decision.apply) { - continue - } - const patch = applyWebSessionTabsSnapshot(nextState, snapshot, environmentId) - if (patch === nextState) { - continue - } - mergedPatch = { ...mergedPatch, ...patch } - nextState = { ...nextState, ...patch } - } - return Object.keys(mergedPatch).length === 0 ? state : mergedPatch -} - -/** Proof that a session-tabs store patch committed. Invoke it after the - * frame's finishRecovery to settle the mirror latch for its verdicts. */ -export type HostSessionMirrorSettle = () => void - -/** A worktree the patch carried, named with the decision its frame's fate came - * from. Requiring the decision is the point: a settle cannot be written for a - * pair whose frame was never decided, so no site can overstate its evidence. */ -export type HostSessionMirrorPatchFrame = { - environmentId: string - worktreeId: string - decision: WebSessionTabsSnapshotDecision - expectedEnvironmentConnectionGeneration?: number - expectedEnvironmentPairingRevision?: number - expectedTrackingGeneration?: number -} - -/** - * The host verdicts a store patch carries. Every frame it touched is listed; - * only the ones whose decision carries `settlesHostMirror` settle, so a frame - * the patch wrote no bytes for settles ONLY when the store already holds an - * equal/newer accepted view of it. `fullInventory` upgrades a completely - * applied inventory to an environment-wide verdict: absence from it is itself - * the host answering, but a partial one speaks only for the worktrees whose - * frames reached the store — the rest wait for the next clean inventory rather - * than read as retracted. - */ -export type HostSessionMirrorPatchVerdict = { - frames: readonly HostSessionMirrorPatchFrame[] - fullInventory?: { - environmentId: string - publishedSnapshotCount: number - authoritative?: boolean - expectedEnvironmentConnectionGeneration?: number - expectedEnvironmentPairingRevision?: number - expectedTrackingGeneration?: number - } -} - -/** - * An inventory that published nothing is the one shape carrying no host - * evidence at all: `settles.length === publishedSnapshotCount` is `0 === 0`, so - * a live host answering `[]` before its renderer's first publish used to be - * upgraded into an environment-wide "the host has spoken" — draining parked - * resumes into forking a second agent onto a PTY the host still runs. - * - * The distinguisher has to be host readiness, not list emptiness: a host with - * genuinely zero terminals must still settle or its panes park forever. Only - * `none` settles; `live` and `unverifiable` leave waiters for the next - * inventory or per-worktree frame. - */ -type HostSessionMirrorSettleFence = { - environmentId: string - connectionGeneration: number - pairingRevision?: number - trackingGeneration: number -} - -function captureHostSessionMirrorSettleFence( - environmentId: string, - expected: { - connectionGeneration?: number - pairingRevision?: number - trackingGeneration?: number - } = {} -): HostSessionMirrorSettleFence { - return { - environmentId, - connectionGeneration: - expected.connectionGeneration ?? getRuntimeEnvironmentConnectionGeneration(environmentId), - pairingRevision: expected.pairingRevision ?? getRuntimeEnvironmentRevision(environmentId), - trackingGeneration: - expected.trackingGeneration ?? getWebSessionTabsTrackingGeneration(environmentId) - } -} - -function hostSessionMirrorSettleFenceIsCurrent(fence: HostSessionMirrorSettleFence): boolean { - return ( - getRuntimeEnvironmentConnectionGeneration(fence.environmentId) === fence.connectionGeneration && - getRuntimeEnvironmentRevision(fence.environmentId) === fence.pairingRevision && - getWebSessionTabsTrackingGeneration(fence.environmentId) === fence.trackingGeneration - ) -} - -function settleEmptyHostInventoryOnlyIfHostHasNoTerminals( - fence: HostSessionMirrorSettleFence -): void { - void probeHostLiveTerminals( - fence.environmentId, - undefined, - fence.connectionGeneration, - fence.pairingRevision - ).then((verdict) => { - // Why: the probe is a round trip, and a reconnect in between would make its - // answer speak for a connection whose PTYs nobody listed. - if (verdict === 'none' && hostSessionMirrorSettleFenceIsCurrent(fence)) { - markHostSessionMirrorHydrated(fence.environmentId) - } - }) -} - -function createHostSessionMirrorSettle( - verdict: HostSessionMirrorPatchVerdict -): HostSessionMirrorSettle { - const fenceByEnvironment = new Map() - for (const frame of verdict.frames) { - fenceByEnvironment.set( - frame.environmentId, - captureHostSessionMirrorSettleFence(frame.environmentId, { - connectionGeneration: frame.expectedEnvironmentConnectionGeneration, - pairingRevision: frame.expectedEnvironmentPairingRevision, - trackingGeneration: frame.expectedTrackingGeneration - }) - ) - } - if (verdict.fullInventory) { - fenceByEnvironment.set( - verdict.fullInventory.environmentId, - captureHostSessionMirrorSettleFence(verdict.fullInventory.environmentId, { - connectionGeneration: verdict.fullInventory.expectedEnvironmentConnectionGeneration, - pairingRevision: verdict.fullInventory.expectedEnvironmentPairingRevision, - trackingGeneration: verdict.fullInventory.expectedTrackingGeneration - }) - ) - } - return () => { - const { frames, fullInventory } = verdict - const settles = frames.filter(({ decision }) => decision.settlesHostMirror) - if (fullInventory && settles.length === fullInventory.publishedSnapshotCount) { - const fence = fenceByEnvironment.get(fullInventory.environmentId) - if (!fence || !hostSessionMirrorSettleFenceIsCurrent(fence)) { - return - } - if (fullInventory.publishedSnapshotCount === 0) { - if (fullInventory.authoritative) { - markHostSessionMirrorHydrated(fullInventory.environmentId) - } else { - settleEmptyHostInventoryOnlyIfHostHasNoTerminals(fence) - } - return - } - markHostSessionMirrorHydrated(fullInventory.environmentId) - return - } - for (const { environmentId, worktreeId } of settles) { - const fence = fenceByEnvironment.get(environmentId) - if (fence && hostSessionMirrorSettleFenceIsCurrent(fence)) { - markHostSessionMirrorWorktreeHydrated(environmentId, worktreeId) - } - } - } -} - -/** The settle a frame earns when its own patch wrote nothing — or null when it - * earns none. Only a decision carrying `settlesHostMirror` may stand in for a - * patch: it is backed by the equal/newer view already accepted into the store, - * so the host HAS answered for this worktree. Taking the decision as the - * argument is the point — a new rejection branch cannot inherit a settle by - * defaulting, and a transport failure is `unverifiable` and never gets here. */ -function hostSessionMirrorSettleForPatchlessFrame( - decision: WebSessionTabsSnapshotDecision, - environmentId: string, - worktreeId: string, - expected: { - connectionGeneration?: number - pairingRevision?: number - trackingGeneration?: number - } = {} -): HostSessionMirrorSettle | null { - if (!decision.settlesHostMirror) { - return null - } - const fence = captureHostSessionMirrorSettleFence(environmentId, expected) - return () => { - if (hostSessionMirrorSettleFenceIsCurrent(fence)) { - markHostSessionMirrorWorktreeHydrated(environmentId, worktreeId) - } - } -} - -export function applyWebSessionTabsStorePatch( - buildPatch: (state: AppState) => WebSessionTabsSyncState | Partial, - hostMirrorVerdict: HostSessionMirrorPatchVerdict, - agentStatusSnapshots?: RuntimeMobileSessionTabsResult | readonly RuntimeMobileSessionTabsResult[], - allowCompletionNotification = false -): HostSessionMirrorSettle { - let mirroredAgentStatusChanged = false - // Why: zustand commits before notifying subscribers, so a producer that ran - // to completion has landed even when a subscriber throws afterwards. - let patchCommitted = false - const acceptedNotificationStatuses: { - paneKey: string - worktreeId: string - seedOnly?: true - payload: ReturnType & { - stateStartedAt: number - localStateStartedAt?: number - } - }[] = [] - const runStorePatch = ( - state: AppState - ): WebSessionTabsSyncState | Partial => { - const patch = buildPatch(state) - mirroredAgentStatusChanged = patch !== state && Object.hasOwn(patch, 'agentStatusByPaneKey') - if (agentStatusSnapshots) { - const nextAgentStatuses = patch.agentStatusByPaneKey ?? state.agentStatusByPaneKey - const snapshots = Array.isArray(agentStatusSnapshots) - ? agentStatusSnapshots - : [agentStatusSnapshots] - for (const snapshot of snapshots) { - for (const surface of snapshot.tabs) { - if (surface.type !== 'terminal') { - continue - } - const remapped = remapHostAgentStatus(surface) - const accepted = remapped ? nextAgentStatuses[remapped.paneKey] : undefined - const turnCompletedAt = remapped - ? normalizeTurnCompletedAtField(surface.turnCompletedAt, remapped.state) - : undefined - if (remapped?.state === 'done' && turnCompletedAt === undefined) { - hostWorkingClientBoundaryByPaneKey.delete(remapped.paneKey) - } - // Why: client OSC owns display state, but only the host hook stream carries background-turn stamps. - const clientOwnedNotification = Boolean( - remapped && - isClientAuthoritativeAgentStatusPane(remapped.paneKey) && - (remapped.state === 'working' || turnCompletedAt !== undefined) - ) - if ( - !remapped || - (!clientOwnedNotification && - (!accepted || - accepted === state.agentStatusByPaneKey[remapped.paneKey] || - !agentStatusEntryEqual(accepted, remapped))) - ) { - continue - } - const notificationStatus = clientOwnedNotification ? remapped : accepted - if (!notificationStatus) { - continue - } - const currentClientStateStartedAt = clientOwnedNotification - ? state.agentStatusByPaneKey[notificationStatus.paneKey]?.stateStartedAt - : undefined - let localStateStartedAt = currentClientStateStartedAt - if ( - clientOwnedNotification && - notificationStatus.state === 'working' && - currentClientStateStartedAt !== undefined - ) { - if (turnCompletedAt === undefined) { - const retainedBoundary = hostWorkingClientBoundaryByPaneKey.get( - notificationStatus.paneKey - ) - if ( - !retainedBoundary || - retainedBoundary.hostStateStartedAt !== notificationStatus.stateStartedAt || - retainedBoundary.hostPrompt !== notificationStatus.prompt || - retainedBoundary.stamped - ) { - hostWorkingClientBoundaryByPaneKey.delete(notificationStatus.paneKey) - hostWorkingClientBoundaryByPaneKey.set(notificationStatus.paneKey, { - hostStateStartedAt: notificationStatus.stateStartedAt, - hostPrompt: notificationStatus.prompt, - clientStateStartedAt: currentClientStateStartedAt, - stamped: false - }) - if (hostWorkingClientBoundaryByPaneKey.size > HOST_WORKING_CLIENT_BOUNDARY_LIMIT) { - const oldestPaneKey = hostWorkingClientBoundaryByPaneKey.keys().next().value - if (oldestPaneKey !== undefined) { - hostWorkingClientBoundaryByPaneKey.delete(oldestPaneKey) - } - } - } - } else { - const retainedBoundary = hostWorkingClientBoundaryByPaneKey.get( - notificationStatus.paneKey - ) - if ( - retainedBoundary?.hostStateStartedAt === notificationStatus.stateStartedAt && - retainedBoundary.hostPrompt === notificationStatus.prompt - ) { - localStateStartedAt = retainedBoundary.clientStateStartedAt - retainedBoundary.stamped = true - } - } - } - if (!allowCompletionNotification && notificationStatus.state !== 'working') { - continue - } - acceptedNotificationStatuses.push({ - paneKey: notificationStatus.paneKey, - worktreeId: notificationStatus.worktreeId ?? snapshot.worktree, - ...(!allowCompletionNotification ? { seedOnly: true as const } : {}), - payload: { - ...pickParsedAgentStatusPayload({ - ...notificationStatus, - ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}) - }), - stateStartedAt: notificationStatus.stateStartedAt, - ...(clientOwnedNotification - ? { - localStateStartedAt - } - : {}) - } - }) - } - } - } - patchCommitted = true - return patch - } - try { - useAppStore.setState(runStorePatch) - } catch (error) { - if (!patchCommitted) { - throw error - } - console.warn('[web-session-tabs-sync] a store subscriber failed after the patch landed:', error) - } - // Why: the receipt exists exactly from the commit on, so no caller can settle - // evidence the store does not hold, and none can lose one it does. - const settleHostMirror = createHostSessionMirrorSettle(hostMirrorVerdict) - try { - // Why: paired-web snapshots bypass setAgentStatus, so arm the stale-boundary timer explicitly like local hook events do. - if (mirroredAgentStatusChanged) { - useAppStore.getState().scheduleAgentStatusFreshness() - } - for (const status of acceptedNotificationStatuses) { - observeAgentHookCompletionForNotification(status) - } - } catch (error) { - // Why: notification bookkeeping cannot un-land the patch, so it must not - // destroy the settle receipt of evidence already in the store. - console.warn('[web-session-tabs-sync] post-patch bookkeeping failed:', error) - } - return settleHostMirror -} - -function loadInitialWebSessionTabs( - environmentId: string, - expectedEnvironmentConnectionGeneration: number, - expectedEnvironmentPairingRevision: number | undefined, - expectedTrackingGeneration: number, - isCurrent: () => boolean -): void { - // Why: only a conclusion that reached the store may settle the mirror, so - // this stays null on every failure exit below. - let settleHydration: (() => void) | null = null - void window.api.runtimeEnvironments - .call({ - selector: environmentId, - method: 'session.tabs.listAll', - params: {}, - timeoutMs: 15_000, - expectedEnvironmentPairingRevision - }) - .then(async (response: RuntimeRpcResponse) => { - if ( - !isCurrent() || - getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision - ) { - return - } - if (response.ok === false) { - console.warn('[web-session-tabs-sync] initial listAll failed:', response.error.message) - return - } - const result = response.result - if (!isSessionTabsListAllResult(result)) { - console.warn('[web-session-tabs-sync] initial listAll returned an invalid payload') - return - } - const receivedFrames = result.snapshots.map((snapshot) => - recordReceivedWebSessionTabsSnapshot(environmentId, snapshot) - ) - const finishRecoveries = result.snapshots.map((snapshot, index) => - beginWebSessionTabsSnapshotRecovery( - environmentId, - snapshot.worktree, - receivedFrames[index]! - ) - ) - try { - const recovered = await Promise.all( - result.snapshots.map((snapshot) => - recoverWebSessionTerminalOrphansBeforeApply( - useAppStore.getState(), - snapshot, - environmentId - ) - ) - ) - if ( - !isCurrent() || - getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision - ) { - return - } - const applicable = recovered.filter( - (snapshot, index): snapshot is RuntimeMobileSessionTabsResult => - snapshot !== null && - shouldApplyRecoveredWebSessionTabsSnapshot( - environmentId, - snapshot, - receivedFrames[index]! - ) - ) - const decisions = applicable.map((snapshot) => - decideWebSessionTabsSnapshot(snapshot, environmentId) - ) - const freshSnapshots = applicable.filter( - (_snapshot, position) => decisions[position]!.apply - ) - settleHydration = applyWebSessionTabsStorePatch( - (state) => applyWebSessionTabsSnapshots(state, freshSnapshots, environmentId), - { - frames: applicable.map((snapshot, position) => ({ - environmentId, - worktreeId: snapshot.worktree, - decision: decisions[position]!, - expectedEnvironmentConnectionGeneration, - expectedEnvironmentPairingRevision, - expectedTrackingGeneration - })), - fullInventory: { - environmentId, - authoritative: result.authoritative === true, - expectedEnvironmentConnectionGeneration, - expectedEnvironmentPairingRevision, - expectedTrackingGeneration, - // Why: a workspace the mirror never writes is not part of the - // inventory the environment-wide verdict has to account for. - publishedSnapshotCount: result.snapshots.filter((snapshot) => - isHostMirroredWorktree(snapshot.worktree) - ).length - } - }, - applicable - ) - } finally { - for (const finishRecovery of finishRecoveries) { - finishRecovery() - } - } - }) - .catch((error) => { - if (isCurrent()) { - console.warn( - '[web-session-tabs-sync] failed to load initial session tabs:', - error instanceof Error ? error.message : String(error) - ) - } - }) - .finally(() => { - // Why: a rejected or timed-out inventory is `unverifiable`, never proof a - // host-owned PTY exited, and this latch releases into replaying a resume. - // Parked work waits for the next inventory instead. - if ( - isCurrent() && - getRuntimeEnvironmentRevision(environmentId) === expectedEnvironmentPairingRevision - ) { - settleHydration?.() - } - }) -} - -export function useWebSessionTabsSync(): void { - const recordVisibilityResumeSnapshotRef = useRef< - (environmentId: string, snapshot: RuntimeMobileSessionTabsResult, receivedFrame: number) => void - >(() => {}) - const recordVisibilityResumeSnapshotReceiptRef = useRef< - (environmentId: string, snapshot: RuntimeMobileSessionTabsResult, receivedFrame: number) => void - >(() => {}) - const shouldApplyVisibilityResumeSnapshotRef = useRef< - ( - environmentId: string, - snapshot: RuntimeMobileSessionTabsResult, - receivedFrame: number - ) => boolean - >(() => true) - const visibilityResumeOmissionsByKeyRef = useRef(new Map()) - const mirroredSessionTabsOwnerRevisionByEnvironmentRef = useRef( - new Map() - ) - const activeRuntimeEnvironmentIdRef = useRef(null) - const activeRuntimeWorktreeKeyRef = useRef(null) - const activeWorktreeId = useAppStore((state) => state.activeWorktreeId) - const runtimeSessionMirrorEnvironmentKey = useRuntimeSessionMirrorEnvironmentKey() - const activeWorktreeRuntimeEnvironmentId = useAppStore((state) => - getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId) - ) - const activeWorktreeRuntimeId = useAppStore((state) => { - const environmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId) - return environmentId - ? (state.runtimeStatusByEnvironmentId.get(environmentId)?.status?.runtimeId ?? null) - : null - }) - const activeWorktreeRuntimeConnectionGeneration = useAppStore((state) => { - const environmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId) - return environmentId - ? (state.runtimeStatusByEnvironmentId.get(environmentId)?.connectionGeneration ?? 0) - : 0 - }) - const activeWorktreeRuntimePairingRevision = useAppStore((state) => { - const environmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId) - const environment = state.runtimeEnvironments.find( - (candidate) => candidate.id === environmentId - ) - return environment ? (environment.pairingRevision ?? environment.createdAt) : undefined - }) - const workspaceSessionReady = useAppStore((state) => state.workspaceSessionReady) - // Why: only resume callbacks read these refs, and resumes fire from visibilitychange or - // stale-visibility recovery — outside React — so committing during layout leaves no window - // where a resume could read the previous worktree's key. - useLayoutEffect(() => { - activeRuntimeEnvironmentIdRef.current = activeWorktreeRuntimeEnvironmentId?.trim() || null - activeRuntimeWorktreeKeyRef.current = - activeWorktreeRuntimeEnvironmentId && activeWorktreeId - ? sessionTabsFreshnessKey(activeWorktreeRuntimeEnvironmentId, activeWorktreeId) - : null - }, [activeWorktreeId, activeWorktreeRuntimeEnvironmentId]) - - useEffect( - () => () => { - for (const environmentId of mirroredSessionTabsOwnerRevisionByEnvironmentRef.current.keys()) { - clearWebSessionTabsTrackingForEnvironment(environmentId) - } - mirroredSessionTabsOwnerRevisionByEnvironmentRef.current.clear() - visibilityResumeOmissionsByKeyRef.current.clear() - }, - [] - ) - - useEffect(() => { - const environments = runtimeSessionMirrorEnvironmentKey - ? runtimeSessionMirrorEnvironmentKey - .split('\u0000') - .map((entry) => { - const [environmentId = '', , rawConnectionGeneration = '0', rawRevision = ''] = - entry.split('\u0001') - return { - environmentId, - expectedEnvironmentConnectionGeneration: Number(rawConnectionGeneration), - expectedEnvironmentPairingRevision: - rawRevision === '' ? undefined : Number(rawRevision) - } - }) - .filter(({ environmentId }) => environmentId.trim()) - : [] - const mirroredEnvironmentOwnerRevisions = new Map( - (workspaceSessionReady ? environments : []).map( - ({ environmentId, expectedEnvironmentPairingRevision }) => - [environmentId, expectedEnvironmentPairingRevision] as const - ) - ) - const previousOwnerRevisions = mirroredSessionTabsOwnerRevisionByEnvironmentRef.current - // Why: same-owner tracking must survive effect restarts to reconcile removals missed while hidden. - for (const [environmentId, previousRevision] of previousOwnerRevisions) { - if ( - !mirroredEnvironmentOwnerRevisions.has(environmentId) || - mirroredEnvironmentOwnerRevisions.get(environmentId) !== previousRevision - ) { - clearWebSessionTabsTrackingForEnvironment(environmentId) - } - } - mirroredSessionTabsOwnerRevisionByEnvironmentRef.current = mirroredEnvironmentOwnerRevisions - for (const [key, omission] of visibilityResumeOmissionsByKeyRef.current) { - const previousRevision = previousOwnerRevisions.get(omission.environmentId) - if ( - !mirroredEnvironmentOwnerRevisions.has(omission.environmentId) || - (previousOwnerRevisions.has(omission.environmentId) && - mirroredEnvironmentOwnerRevisions.get(omission.environmentId) !== previousRevision) - ) { - visibilityResumeOmissionsByKeyRef.current.delete(key) - } - } - // Why: mirror all paired runtimes' sessions, not just the selected worktree, so background worktrees don't look asleep (selectedness isn't liveness). - // Why: applying the host snapshot before startup hydration writes browser-local session state clobbers it and leaves the sidebar stale. - if (!workspaceSessionReady || environments.length === 0) { - return - } - - type VisibilityResumeEnvironment = { - trackedWorktrees: readonly TrackedWebSessionTabsWorktree[] - inventoryReceived: boolean - latestInventoryReceivedFrame: number - pendingMissingWorktrees: Set - expectedEnvironmentConnectionGeneration: number - expectedEnvironmentPairingRevision?: number - expectedTrackingGeneration: number - } - type VisibilityResumeMissing = { - environmentId: string - inventoryReceivedFrame: number - trackedWorktree: TrackedWebSessionTabsWorktree - snapshot: RuntimeMobileSessionTabsRemovedResult - } - type VisibilityResumeBatch = { - visibilityGeneration: number - environments: Map - pendingInventoryCount: number - pendingMissingByWorktree: Map> - deferredRepairWorktrees: Set - trackedWorktreeIds: ReadonlySet - reapplyableSnapshotsByKey: Map< - string, - { snapshot: RuntimeMobileSessionTabsResult; receivedFrame: number } - > - } - - const subscriptionSpecs: WindowVisibilitySubscriptionSpec[] = [] - const environmentIdBySubscriptionSpec: string[] = [] - let visibilityResumeBatch: VisibilityResumeBatch | null = null - const visibilityResumeOmissionsByKey = visibilityResumeOmissionsByKeyRef.current - - const recordVisibilityResumeSnapshotReceipt = ( - environmentId: string, - snapshot: RuntimeMobileSessionTabsResult, - receivedFrame: number - ): void => { - const omission = visibilityResumeOmissionsByKey.get( - sessionTabsFreshnessKey(environmentId, snapshot.worktree) - ) - if ( - omission && - receivedFrame > omission.inventoryReceivedFrame && - ((snapshot as { removed?: unknown }).removed === true || - advancesSessionTabsFreshness(snapshot, omission.baseline)) - ) { - omission.superseded = true - if (visibilityResumeBatch?.pendingMissingByWorktree.has(snapshot.worktree)) { - reconcileVisibilityResumeWorktrees([snapshot.worktree]) - } - } - } - - const shouldApplyVisibilityResumeSnapshot = ( - environmentId: string, - snapshot: RuntimeMobileSessionTabsResult, - receivedFrame: number - ): boolean => { - const omission = visibilityResumeOmissionsByKey.get( - sessionTabsFreshnessKey(environmentId, snapshot.worktree) - ) - if (!omission) { - return true - } - if (receivedFrame < omission.inventoryReceivedFrame) { - return false - } - return ( - (snapshot as { removed?: unknown }).removed === true || - advancesSessionTabsFreshness(snapshot, omission.baseline) - ) - } - recordVisibilityResumeSnapshotReceiptRef.current = recordVisibilityResumeSnapshotReceipt - shouldApplyVisibilityResumeSnapshotRef.current = shouldApplyVisibilityResumeSnapshot - - const isVisibilityResumeMissingCurrent = (missing: VisibilityResumeMissing): boolean => { - const omission = visibilityResumeOmissionsByKey.get( - sessionTabsFreshnessKey(missing.environmentId, missing.snapshot.worktree) - ) - return ( - omission?.inventoryReceivedFrame === missing.inventoryReceivedFrame && !omission.superseded - ) - } - - const getVisibilityResumeSnapshot = ( - batch: VisibilityResumeBatch, - environmentId: string, - worktreeId: string - ): RuntimeMobileSessionTabsResult | null => { - const key = sessionTabsFreshnessKey(environmentId, worktreeId) - const entry = batch.reapplyableSnapshotsByKey.get(key) - const freshness = latestSessionTabsSnapshotByWorktree.get(key) - if ( - !entry || - freshness?.publicationEpoch !== entry.snapshot.publicationEpoch || - freshness.snapshotVersion !== entry.snapshot.snapshotVersion || - !shouldApplyRecoveredWebSessionTabsSnapshot( - environmentId, - entry.snapshot, - entry.receivedFrame - ) - ) { - return null - } - return entry.snapshot - } - - const finishVisibilityResumeBatchIfIdle = (batch: VisibilityResumeBatch): void => { - if ( - batch.pendingInventoryCount === 0 && - batch.pendingMissingByWorktree.size === 0 && - visibilityResumeBatch === batch - ) { - visibilityResumeBatch = null - } - } - - // Why: omission tombstones touch shared terminal ids, so apply them only before replaying every surviving host. - function reconcileVisibilityResumeWorktrees(worktreeIds: Iterable): void { - const batch = visibilityResumeBatch - if (!batch) { - return - } - const operations: WebSessionTabsSnapshotOperation[] = [] - for (const worktreeId of new Set(worktreeIds)) { - const pendingMissing = batch.pendingMissingByWorktree.get(worktreeId) - if (!pendingMissing) { - batch.deferredRepairWorktrees.delete(worktreeId) - continue - } - for (const [environmentId, missing] of pendingMissing) { - if (isVisibilityResumeMissingCurrent(missing)) { - continue - } - pendingMissing.delete(environmentId) - batch.environments.get(environmentId)?.pendingMissingWorktrees.delete(worktreeId) - } - if (pendingMissing.size === 0) { - batch.pendingMissingByWorktree.delete(worktreeId) - batch.deferredRepairWorktrees.delete(worktreeId) - continue - } - const missingEnvironmentIds = new Set(pendingMissing.keys()) - const survivingSnapshots: { - environmentId: string - snapshot: RuntimeMobileSessionTabsResult - }[] = [] - let canRepairSharedState = true - for (const environmentId of sessionTabsEnvironmentsByWorktree.get(worktreeId) ?? []) { - if (missingEnvironmentIds.has(environmentId)) { - continue - } - const snapshot = getVisibilityResumeSnapshot(batch, environmentId, worktreeId) - if (!snapshot) { - canRepairSharedState = false - break - } - survivingSnapshots.push({ environmentId, snapshot }) - } - if (!canRepairSharedState) { - batch.deferredRepairWorktrees.add(worktreeId) - continue - } - for (const missing of pendingMissing.values()) { - operations.push({ environmentId: missing.environmentId, snapshot: missing.snapshot }) - } - for (const { environmentId, snapshot } of survivingSnapshots) { - acceptReplayedWebSessionTabsSnapshot(environmentId, worktreeId) - operations.push({ environmentId, snapshot }) - } - for (const environmentId of pendingMissing.keys()) { - batch.environments.get(environmentId)?.pendingMissingWorktrees.delete(worktreeId) - } - batch.pendingMissingByWorktree.delete(worktreeId) - batch.deferredRepairWorktrees.delete(worktreeId) - } - if (operations.length > 0) { - const decided = decideWebSessionTabsSnapshotOperations(operations) - const settleMirror = applyWebSessionTabsStorePatch( - (state) => applyWebSessionTabsSnapshotOperations(state, decided), - { - frames: decided.map(({ environmentId, snapshot, decision }) => ({ - environmentId, - worktreeId: snapshot.worktree, - decision, - expectedEnvironmentConnectionGeneration: - batch.environments.get(environmentId)?.expectedEnvironmentConnectionGeneration, - expectedEnvironmentPairingRevision: - batch.environments.get(environmentId)?.expectedEnvironmentPairingRevision, - expectedTrackingGeneration: - batch.environments.get(environmentId)?.expectedTrackingGeneration - })) - }, - operations.map(({ snapshot }) => snapshot) - ) - // Why: every operation is post-recovery or inventory-absence evidence - // with no finishRecovery of its own pending, so settle now — a deferred - // tombstone otherwise leaves its worktree parked on a healthy host. - settleMirror() - } - finishVisibilityResumeBatchIfIdle(batch) - } - - const recordVisibilityResumeSnapshot = ( - environmentId: string, - snapshot: RuntimeMobileSessionTabsResult, - receivedFrame: number - ): void => { - const batch = visibilityResumeBatch - if (!batch || !batch.trackedWorktreeIds.has(snapshot.worktree)) { - return - } - const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) - const existingIsCurrent = getVisibilityResumeSnapshot(batch, environmentId, snapshot.worktree) - const freshness = latestSessionTabsSnapshotByWorktree.get(key) - const repairsCrossHostCollision = - (sessionTabsEnvironmentsByWorktree.get(snapshot.worktree)?.size ?? 0) > 1 || - batch.deferredRepairWorktrees.has(snapshot.worktree) - if ( - (snapshot as { removed?: unknown }).removed === true || - snapshot.tabs.length === 0 || - !repairsCrossHostCollision || - freshness?.publicationEpoch !== snapshot.publicationEpoch || - freshness.snapshotVersion !== snapshot.snapshotVersion || - !shouldApplyRecoveredWebSessionTabsSnapshot(environmentId, snapshot, receivedFrame) - ) { - if (!existingIsCurrent) { - batch.reapplyableSnapshotsByKey.delete(key) - } - } else { - batch.reapplyableSnapshotsByKey.set(key, { snapshot, receivedFrame }) - } - if (batch.pendingMissingByWorktree.has(snapshot.worktree)) { - reconcileVisibilityResumeWorktrees([snapshot.worktree]) - } - } - - const recordVisibilityResumeInventory = ( - environmentId: string, - visibilityGeneration: number, - inventoryReceivedFrame: number, - missingWorktrees: readonly VisibilityResumeMissing[] - ): void => { - if ( - visibilityGeneration === 0 || - visibilityResumeBatch?.visibilityGeneration !== visibilityGeneration - ) { - return - } - const environment = visibilityResumeBatch.environments.get(environmentId) - if (!environment || environment.latestInventoryReceivedFrame !== inventoryReceivedFrame) { - return - } - const batch = visibilityResumeBatch - const affectedWorktrees = new Set(environment.pendingMissingWorktrees) - for (const worktreeId of environment.pendingMissingWorktrees) { - const pendingMissing = batch.pendingMissingByWorktree.get(worktreeId) - pendingMissing?.delete(environmentId) - if (pendingMissing?.size === 0) { - batch.pendingMissingByWorktree.delete(worktreeId) - } - } - environment.pendingMissingWorktrees.clear() - for (const missing of missingWorktrees) { - const worktreeId = missing.snapshot.worktree - const pendingMissing = batch.pendingMissingByWorktree.get(worktreeId) ?? new Map() - pendingMissing.set(environmentId, missing) - batch.pendingMissingByWorktree.set(worktreeId, pendingMissing) - environment.pendingMissingWorktrees.add(worktreeId) - affectedWorktrees.add(worktreeId) - } - if (!environment.inventoryReceived) { - environment.inventoryReceived = true - batch.pendingInventoryCount -= 1 - } - reconcileVisibilityResumeWorktrees(affectedWorktrees) - } - recordVisibilityResumeSnapshotRef.current = recordVisibilityResumeSnapshot - - const recordVisibilityResumeInventoryReceipt = ( - environmentId: string, - visibilityGeneration: number, - inventoryReceivedFrame: number, - snapshots: readonly RuntimeMobileSessionTabsResult[] - ): VisibilityResumeMissing[] => { - for (const snapshot of snapshots) { - visibilityResumeOmissionsByKey.delete( - sessionTabsFreshnessKey(environmentId, snapshot.worktree) - ) - } - if (visibilityResumeBatch?.visibilityGeneration !== visibilityGeneration) { - return [] - } - const environment = visibilityResumeBatch.environments.get(environmentId) - if (!environment) { - return [] - } - environment.latestInventoryReceivedFrame = Math.max( - environment.latestInventoryReceivedFrame, - inventoryReceivedFrame - ) - if (environment.latestInventoryReceivedFrame !== inventoryReceivedFrame) { - return [] - } - const publishedWorktrees = new Set(snapshots.map((snapshot) => snapshot.worktree)) - return buildMissingWebSessionTabsRemovals( - environmentId, - environment.trackedWorktrees, - publishedWorktrees - ).map((missing) => { - const key = sessionTabsFreshnessKey(environmentId, missing.snapshot.worktree) - visibilityResumeOmissionsByKey.set(key, { - baseline: missing.trackedWorktree.freshness, - environmentId, - inventoryReceivedFrame, - superseded: false, - visibilityGeneration - }) - recordReceivedWebSessionTabsRemoval( - environmentId, - missing.snapshot.worktree, - inventoryReceivedFrame - ) - return { environmentId, inventoryReceivedFrame, ...missing } - }) - } - - const beginVisibilityResume = ({ - visibilityGeneration, - restartingSpecIndexes - }: { - visibilityGeneration: number - restartingSpecIndexes: readonly number[] - }): void => { - const activeRuntimeWorktreeKey = activeRuntimeWorktreeKeyRef.current - for (const [key, omission] of visibilityResumeOmissionsByKey) { - // Why: the active worktree's scoped stream resumes before its host inventory, so its fence outlives exactly one resume - never more. - if ( - key !== activeRuntimeWorktreeKey || - omission.visibilityGeneration < visibilityGeneration - 1 - ) { - visibilityResumeOmissionsByKey.delete(key) - } - } - const resumedEnvironments = new Map() - const trackedWorktreeIds = new Set() - for (const index of restartingSpecIndexes) { - const environmentId = environmentIdBySubscriptionSpec[index] - if (environmentId) { - const trackedWorktrees = getTrackedWebSessionTabsWorktrees(environmentId) - if (trackedWorktrees.length === 0) { - continue - } - for (const { worktree } of trackedWorktrees) { - trackedWorktreeIds.add(worktree) - } - resumedEnvironments.set(environmentId, { - trackedWorktrees, - inventoryReceived: false, - latestInventoryReceivedFrame: 0, - pendingMissingWorktrees: new Set(), - expectedEnvironmentConnectionGeneration: - environments.find((environment) => environment.environmentId === environmentId) - ?.expectedEnvironmentConnectionGeneration ?? - getRuntimeEnvironmentConnectionGeneration(environmentId), - expectedEnvironmentPairingRevision: environments.find( - (environment) => environment.environmentId === environmentId - )?.expectedEnvironmentPairingRevision, - expectedTrackingGeneration: getWebSessionTabsTrackingGeneration(environmentId) - }) - } - } - visibilityResumeBatch = - resumedEnvironments.size > 0 - ? { - visibilityGeneration, - environments: resumedEnvironments, - pendingInventoryCount: resumedEnvironments.size, - pendingMissingByWorktree: new Map(), - deferredRepairWorktrees: new Set(), - trackedWorktreeIds, - reapplyableSnapshotsByKey: new Map() - } - : null - } - - // Why: the stream's initial snapshot can land after first render, so a one-shot fetch makes initial parity deterministic. - for (const { - environmentId, - expectedEnvironmentConnectionGeneration, - expectedEnvironmentPairingRevision - } of environments) { - if ( - !shouldSyncAllRuntimeSessionTabs({ - activeRuntimeEnvironmentId: environmentId, - workspaceSessionReady - }) - ) { - continue - } - let requestedInitialLoad = false - const expectedTrackingGeneration = getWebSessionTabsTrackingGeneration(environmentId) - environmentIdBySubscriptionSpec.push(environmentId) - subscriptionSpecs.push({ - subscribe: (isCurrent, { visibilityGeneration }) => { - const isVisibilityRestart = visibilityGeneration > 0 - let awaitingVisibilityResumeInventory = isVisibilityRestart - if (!requestedInitialLoad) { - requestedInitialLoad = true - loadInitialWebSessionTabs( - environmentId, - expectedEnvironmentConnectionGeneration, - expectedEnvironmentPairingRevision, - expectedTrackingGeneration, - isCurrent - ) - } - return window.api.runtimeEnvironments.subscribe( - { - selector: environmentId, - method: 'session.tabs.subscribeAll', - params: {}, - timeoutMs: 15_000, - expectedEnvironmentPairingRevision - }, - { - onResponse: (response: RuntimeRpcResponse) => { - if ( - !isCurrent() || - getRuntimeEnvironmentRevision(environmentId) !== - expectedEnvironmentPairingRevision - ) { - 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') { - const skipUnchangedResumeWork = awaitingVisibilityResumeInventory && !replayed - awaitingVisibilityResumeInventory = false - // Why: an unchanged epoch/version proves the host published nothing while parked, so there is no missed frame to recover or replay. - const unchangedVisibilityResumeSnapshots = event.snapshots.map((snapshot) => { - const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) - const freshness = latestSessionTabsSnapshotByWorktree.get(key) - return Boolean( - skipUnchangedResumeWork && - // Why: an armed replay is an outstanding repair request, so that worktree must be rebuilt even when the host looks unchanged. - !replayableSessionTabsSnapshotByWorktree.has(key) && - freshness?.publicationEpoch === snapshot.publicationEpoch && - freshness.snapshotVersion === snapshot.snapshotVersion - ) - }) - const receivedFrames = event.snapshots.map((snapshot) => { - const receivedFrame = recordReceivedWebSessionTabsSnapshot( - environmentId, - snapshot - ) - recordVisibilityResumeSnapshotReceipt(environmentId, snapshot, receivedFrame) - return receivedFrame - }) - const inventoryReceivedFrame = recordReceivedWebSessionTabsInventory() - const missingWorktrees = recordVisibilityResumeInventoryReceipt( - environmentId, - visibilityGeneration, - inventoryReceivedFrame, - event.snapshots - ) - const finishRecoveries = event.snapshots.map((snapshot, index) => - unchangedVisibilityResumeSnapshots[index] - ? null - : beginWebSessionTabsSnapshotRecovery( - environmentId, - snapshot.worktree, - receivedFrames[index]! - ) - ) - let settleHydration: (() => void) | null = null - void Promise.all( - event.snapshots.map((snapshot, index) => - unchangedVisibilityResumeSnapshots[index] - ? Promise.resolve(snapshot) - : recoverWebSessionTerminalOrphansBeforeApply( - useAppStore.getState(), - snapshot, - environmentId - ) - ) - ) - .then((recovered) => { - if (isCurrent()) { - const applicable = recovered.flatMap((snapshot, index) => - snapshot !== null && - shouldApplyRecoveredWebSessionTabsSnapshot( - environmentId, - snapshot, - receivedFrames[index]! - ) && - shouldApplyVisibilityResumeSnapshot( - environmentId, - snapshot, - receivedFrames[index]! - ) - ? [{ index, snapshot }] - : [] - ) - if (isVisibilityRestart || replayed) { - for (const { index, snapshot } of applicable) { - if (unchangedVisibilityResumeSnapshots[index]) { - continue - } - acceptReplayedWebSessionTabsSnapshot(environmentId, snapshot.worktree) - } - } - // Why: an unchanged resume snapshot is never re-decided - // — the store already holds the view it would rewrite. - const decisions = applicable.map(({ index, snapshot }) => - unchangedVisibilityResumeSnapshots[index] - ? WEB_SESSION_TABS_FRAME_OUTRANKED - : decideWebSessionTabsSnapshot(snapshot, environmentId) - ) - const freshSnapshots = applicable.flatMap(({ snapshot }, position) => - decisions[position]!.apply ? [snapshot] : [] - ) - settleHydration = applyWebSessionTabsStorePatch( - (state) => - applyWebSessionTabsSnapshots(state, freshSnapshots, environmentId), - { - frames: applicable.map(({ snapshot }, position) => ({ - environmentId, - worktreeId: snapshot.worktree, - decision: decisions[position]!, - expectedEnvironmentConnectionGeneration, - expectedEnvironmentPairingRevision, - expectedTrackingGeneration - })), - fullInventory: { - environmentId, - authoritative: event.authoritative === true, - expectedEnvironmentConnectionGeneration, - expectedEnvironmentPairingRevision, - expectedTrackingGeneration, - // Why: a workspace the mirror never writes is not - // part of the inventory this verdict accounts for. - publishedSnapshotCount: event.snapshots.filter((snapshot) => - isHostMirroredWorktree(snapshot.worktree) - ).length - } - }, - freshSnapshots - ) - const freshSnapshotSet = new Set(freshSnapshots) - for (const { index, snapshot } of applicable) { - if (unchangedVisibilityResumeSnapshots[index]) { - queueAcceptedWebSessionTerminalSnapshot(snapshot, environmentId) - } - if ( - unchangedVisibilityResumeSnapshots[index] || - freshSnapshotSet.has(snapshot) - ) { - recordVisibilityResumeSnapshot( - environmentId, - snapshot, - receivedFrames[index]! - ) - } - } - recordVisibilityResumeInventory( - environmentId, - visibilityGeneration, - inventoryReceivedFrame, - missingWorktrees - ) - } - }) - .catch((error) => { - if (isCurrent()) { - console.warn('[web-session-tabs-sync] snapshot recovery failed:', error) - } - }) - .finally(() => { - for (const finishRecovery of finishRecoveries) { - finishRecovery?.() - } - // Why: an inventory speaks only once its patch is in the - // store — settling earlier, or on snapshots recovery - // discarded, drains parked work against state nobody wrote. - if (isCurrent()) { - settleHydration?.() - } - }) - return - } - if (event.type !== 'snapshot' && event.type !== 'updated') { - // Why: silence carries no frame, and a stream that stopped - // talking has not reported a single PTY dead. - return - } - const receivedFrame = recordReceivedWebSessionTabsSnapshot(environmentId, event) - recordVisibilityResumeSnapshotReceipt(environmentId, event, receivedFrame) - const finishRecovery = beginWebSessionTabsSnapshotRecovery( - environmentId, - event.worktree, - receivedFrame - ) - let settleHydration: HostSessionMirrorSettle | null = null - void recoverWebSessionTerminalOrphansBeforeApply( - useAppStore.getState(), - event, - environmentId - ) - .then((recovered) => { - if ( - isCurrent() && - recovered && - shouldApplyRecoveredWebSessionTabsSnapshot( - environmentId, - recovered, - receivedFrame - ) && - shouldApplyVisibilityResumeSnapshot(environmentId, recovered, receivedFrame) - ) { - if (replayed) { - acceptReplayedWebSessionTabsSnapshot(environmentId, recovered.worktree) - } - const decision = decideWebSessionTabsSnapshot(recovered, environmentId) - if (decision.apply) { - settleHydration = applyWebSessionTabsStorePatch( - (state) => applyWebSessionTabsSnapshot(state, recovered, environmentId), - { - frames: [ - { - environmentId, - worktreeId: recovered.worktree, - decision, - expectedEnvironmentConnectionGeneration, - expectedEnvironmentPairingRevision, - expectedTrackingGeneration - } - ] - }, - recovered, - event.type === 'updated' && !replayed - ) - recordVisibilityResumeSnapshot(environmentId, recovered, receivedFrame) - } else { - settleHydration = hostSessionMirrorSettleForPatchlessFrame( - decision, - environmentId, - recovered.worktree, - { - connectionGeneration: expectedEnvironmentConnectionGeneration, - pairingRevision: expectedEnvironmentPairingRevision, - trackingGeneration: expectedTrackingGeneration - } - ) - } - } - }) - .catch((error) => { - if (isCurrent()) { - console.warn('[web-session-tabs-sync] snapshot recovery failed:', error) - } - }) - .finally(() => { - finishRecovery() - // Why: this frame speaks for its own worktree only, and only - // through the receipt of what actually landed — a discarded - // frame leaves the pane's PTY as unaccounted as before. - if (isCurrent()) { - settleHydration?.() - } - }) - }, - onError: (error) => { - if (isCurrent()) { - console.warn('[web-session-tabs-sync] global subscription error:', error.message) - } - } - } - ) - }, - onSubscribeError: (error) => { - console.warn( - '[web-session-tabs-sync] failed to subscribe globally:', - error instanceof Error ? error.message : String(error) - ) - }, - onUnsubscribeError: (error) => { - console.warn('[web-session-tabs-sync] failed to unsubscribe globally:', error) - } - }) - } - // Why: pace full-host inventories while the scoped active-worktree mirror resumes immediately. - const disposeSubscriptions = installWindowVisibilitySubscriptionParking(subscriptionSpecs, { - getVisibilityResumePriority: (index) => - environmentIdBySubscriptionSpec[index] === activeRuntimeEnvironmentIdRef.current ? 0 : 1, - visibilityResumeStaggerMs: WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS, - onVisibilityResume: beginVisibilityResume - }) - - return () => { - recordVisibilityResumeSnapshotRef.current = () => {} - recordVisibilityResumeSnapshotReceiptRef.current = () => {} - shouldApplyVisibilityResumeSnapshotRef.current = () => true - disposeSubscriptions() - for (const { environmentId, expectedEnvironmentPairingRevision } of environments) { - const owner = { - environmentId, - pairingRevision: expectedEnvironmentPairingRevision - } - clearWebSessionCloseIntentsForOwner(owner) - clearWebSessionFocusIntentsForOwner(owner) - clearWebSessionReorderIntentsForOwner(owner) - } - } - }, [runtimeSessionMirrorEnvironmentKey, workspaceSessionReady]) - - useEffect(() => { - const environmentId = activeWorktreeRuntimeEnvironmentId?.trim() - const expectedEnvironmentPairingRevision = activeWorktreeRuntimePairingRevision - if ( - !shouldSyncRuntimeSessionTabs({ - activeWorktreeId, - activeWorktreeRuntimeEnvironmentId, - workspaceSessionReady - }) || - !environmentId || - !activeWorktreeId - ) { - return - } - const expectedTrackingGeneration = getWebSessionTabsTrackingGeneration(environmentId) - - let requestedInitialTerminal = false - let requestedRespawnAfterWake = false - /** Resolves the settle receipt of the evidence this frame put (or already - * had) in the store, or null when the frame was discarded undecided. */ - const applyActiveSnapshot = async ( - event: RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }, - response: RuntimeRpcResponse, - isCurrent: () => boolean, - receivedFrame: number, - expectedTrackingGeneration: number - ): Promise => { - const recovered = await recoverWebSessionTerminalOrphansBeforeApply( - useAppStore.getState(), - event, - environmentId - ) - if ( - !isCurrent() || - !recovered || - !shouldApplyRecoveredWebSessionTabsSnapshot(environmentId, recovered, receivedFrame) || - !shouldApplyVisibilityResumeSnapshotRef.current(environmentId, recovered, receivedFrame) - ) { - return null - } - if (event.type === 'snapshot' || isRuntimeSubscriptionReplayResponse(response)) { - // Why: the parallel global stream can consume an earlier replay allowance before this authoritative snapshot lands. - acceptReplayedWebSessionTabsSnapshot(environmentId, recovered.worktree) - } - const recoveredEvent: SessionTabsStreamEvent = { ...recovered, type: event.type } - const decision = decideWebSessionTabsSnapshot(recovered, environmentId) - const fresh = decision.apply - 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: recoveredEvent, - activeWorktreeId, - requestedInitialTerminal, - snapshotIsFresh: fresh, - localTerminalCount - }) - const shouldRespawnAfterWake = shouldRespawnWebRuntimeTerminalAfterWake({ - event: recoveredEvent, - activeWorktreeId, - requestedRespawnAfterWake, - snapshotIsFresh: fresh, - localTerminalCount, - hasLiveLocalPty, - skipWakeRespawn: shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId) - }) - // Why: a rejected frame settles only on the accepted view that outranked it. - let settleMirror: HostSessionMirrorSettle | null = fresh - ? null - : hostSessionMirrorSettleForPatchlessFrame(decision, environmentId, recovered.worktree, { - connectionGeneration: activeWorktreeRuntimeConnectionGeneration, - pairingRevision: expectedEnvironmentPairingRevision, - trackingGeneration: expectedTrackingGeneration - }) - try { - if (fresh) { - const replayed = isRuntimeSubscriptionReplayResponse(response) - settleMirror = applyWebSessionTabsStorePatch( - (state) => applyWebSessionTabsSnapshot(state, recovered, environmentId), - { - frames: [ - { - environmentId, - worktreeId: recovered.worktree, - decision, - expectedEnvironmentConnectionGeneration: - activeWorktreeRuntimeConnectionGeneration, - expectedEnvironmentPairingRevision, - expectedTrackingGeneration - } - ] - }, - recovered, - event.type === 'updated' && !replayed - ) - recordVisibilityResumeSnapshotRef.current(environmentId, recovered, receivedFrame) - } - if (isCurrent() && shouldBootstrapInitialTerminal) { - requestedInitialTerminal = true - await createWebRuntimeSessionTerminal({ - worktreeId: activeWorktreeId, - environmentId, - activate: true - }) - } else if ( - isCurrent() && - shouldRespawnAfterWake && - beginWebRuntimeWakeTerminalRespawn(activeWorktreeId) - ) { - requestedRespawnAfterWake = true - await createWebRuntimeSessionTerminal({ - worktreeId: activeWorktreeId, - environmentId, - activate: true, - selectWorktree: false - }).finally(() => endWebRuntimeWakeTerminalRespawn(activeWorktreeId)) - } - } catch (error) { - // Why: the spawn is a side effect of the frame, not part of it — failing - // after the patch landed cannot un-land it, and the host is still healthy. - if (isCurrent()) { - console.warn('[web-session-tabs-sync] snapshot follow-up failed:', error) - } - } - return settleMirror - } - const disposeSubscription = installWindowVisibilitySubscriptionParking([ - { - subscribe: (isCurrent) => - window.api.runtimeEnvironments.subscribe( - { - selector: environmentId, - method: 'session.tabs.subscribe', - params: { worktree: toRuntimeWorktreeSelector(activeWorktreeId) }, - timeoutMs: 15_000, - expectedEnvironmentPairingRevision - }, - { - onResponse: (response: RuntimeRpcResponse) => { - if ( - !isCurrent() || - getRuntimeEnvironmentRevision(environmentId) !== - expectedEnvironmentPairingRevision - ) { - 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') { - // No frame to apply, and a dead stream is not a verdict about - // this worktree — let alone about the whole environment. - return - } - const receivedFrame = recordReceivedWebSessionTabsSnapshot(environmentId, event) - recordVisibilityResumeSnapshotReceiptRef.current( - environmentId, - event, - receivedFrame - ) - const finishRecovery = beginWebSessionTabsSnapshotRecovery( - environmentId, - event.worktree, - receivedFrame - ) - void applyActiveSnapshot( - event, - response, - isCurrent, - receivedFrame, - expectedTrackingGeneration - ) - .catch((error) => { - if (isCurrent()) { - console.warn( - '[web-session-tabs-sync] active snapshot recovery failed:', - error - ) - } - return null - }) - .then((settleMirror) => { - finishRecovery() - // Why: the active-worktree mirror is authoritative for this - // worktree alone, and only through its landed receipt. - if (isCurrent()) { - settleMirror?.() - } - }) - }, - onError: (error) => { - if (isCurrent()) { - console.warn('[web-session-tabs-sync] subscription error:', error.message) - } - } - } - ), - onSubscribeError: (error) => { - console.warn( - '[web-session-tabs-sync] failed to subscribe:', - error instanceof Error ? error.message : String(error) - ) - }, - onUnsubscribeError: (error) => { - console.warn('[web-session-tabs-sync] failed to unsubscribe:', error) - } - } - ]) - - return disposeSubscription - }, [ - activeWorktreeId, - activeWorktreeRuntimeEnvironmentId, - activeWorktreeRuntimeConnectionGeneration, - activeWorktreeRuntimeId, - activeWorktreeRuntimePairingRevision, - workspaceSessionReady - ]) -} +/** Public boundary for the web runtime session-tab mirror. */ +export { + applyFreshWebSessionTabsSnapshot, + applyFreshWebSessionTabsSnapshots, + applyWebSessionTabsSnapshot, + applyWebSessionTabsSnapshots, + applyWebSessionTabsSnapshotOperations, + decideWebSessionTabsSnapshotOperations +} from './web-session-tabs-sync/snapshot-api' +export type { + DecidedWebSessionTabsSnapshotOperation, + WebSessionTabsSnapshotOperation +} from './web-session-tabs-sync/snapshot-api' + +export { + acceptReplayedWebSessionTabsSnapshot, + clearWebSessionTabsTrackingForEnvironment, + getLastKnownHostTerminalTabCount, + getLatestWebSessionTabsPublicationEpoch, + getWebSessionTabsTrackingGeneration, + resetWebSessionTabsSnapshotFreshnessForTests, + _getWebSessionTabsRecoveryTrackingCountsForTest, + _getWebSessionTabsTrackingCountsForTest +} from './web-session-tabs-sync/tracking-lifecycle' +export { resolveHostSessionTabIdForWebSessionTab } from './web-session-tabs-sync/tracking-mappings' +export { + decideWebSessionTabsSnapshot, + shouldApplyWebSessionTabsSnapshot, + shouldBootstrapInitialWebRuntimeTerminal, + shouldRespawnWebRuntimeTerminalAfterWake, + shouldSyncAllRuntimeSessionTabs, + shouldSyncRuntimeSessionTabs, + WEB_SESSION_TABS_FRAME_OUTRANKED +} from './web-session-tabs-sync/tracking-decisions' +export type { WebSessionTabsSnapshotDecision } from './web-session-tabs-sync/tracking-decisions' + +export { applyWebSessionTabsStorePatch } from './web-session-tabs-sync/store-patch' +export type { + HostSessionMirrorPatchFrame, + HostSessionMirrorPatchVerdict, + HostSessionMirrorSettle +} from './web-session-tabs-sync/mirror-settle' +export { + hostSessionMirrorSettleForPatchlessFrame, + createHostSessionMirrorSettle +} from './web-session-tabs-sync/mirror-settle' + +export { WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS } from './web-session-tabs-sync/state' +export type { + WebSessionTabsSnapshotApplyOptions, + WebSessionTabsSyncState, + SessionTabsStreamEvent +} from './web-session-tabs-sync/state' + +export { useWebSessionTabsSync } from './web-session-tabs-sync/use-web-session-tabs-sync' diff --git a/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts b/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts new file mode 100644 index 00000000000..870c3f6a57f --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts @@ -0,0 +1,250 @@ +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { isRuntimeSubscriptionReplayResponse } from '../../../../shared/runtime-subscription-replay' +import { useAppStore } from '../../store' +import { getRuntimeEnvironmentRevision } from '../runtime-environment-revision' +import { recoverWebSessionTerminalOrphansBeforeApply } from '../web-session-terminal-orphan-recovery' +import { installWindowVisibilitySubscriptionParking } from '../window-visibility-subscription-parking' +import { + beginWebSessionTabsSnapshotRecovery, + recordReceivedWebSessionTabsSnapshot, + shouldApplyRecoveredWebSessionTabsSnapshot +} from './tracking' +import { + decideWebSessionTabsSnapshot, + shouldBootstrapInitialWebRuntimeTerminal, + shouldRespawnWebRuntimeTerminalAfterWake, + shouldSyncRuntimeSessionTabs +} from './tracking-decisions' +import { + acceptReplayedWebSessionTabsSnapshot, + getWebSessionTabsTrackingGeneration +} from './tracking-lifecycle' +import { applyWebSessionTabsSnapshot } from './snapshot-api' +import { applyWebSessionTabsStorePatch } from './store-patch' +import { + hostSessionMirrorSettleForPatchlessFrame, + type HostSessionMirrorSettle +} from './mirror-settle' +import { + beginWebRuntimeWakeTerminalRespawn, + endWebRuntimeWakeTerminalRespawn, + shouldSkipWebRuntimeWakeTerminalRespawn +} from '../web-runtime-wake-terminal-respawn' +import { createWebRuntimeSessionTerminal } from '../web-runtime-session' +import { toRuntimeWorktreeSelector } from '../runtime-worktree-selector' +import type { SessionTabsStreamEvent } from './state' + +type Ref = { current: T } + +export type ActiveSubscriptionArgs = { + activeWorktreeId: string | null + activeWorktreeRuntimeEnvironmentId: string | null | undefined + activeWorktreeRuntimeConnectionGeneration: number + activeWorktreeRuntimePairingRevision: number | undefined + workspaceSessionReady: boolean + visibilitySnapshotReceipt: Ref< + (environmentId: string, snapshot: RuntimeMobileSessionTabsResult, receivedFrame: number) => void + > + visibilitySnapshotApply: Ref< + ( + environmentId: string, + snapshot: RuntimeMobileSessionTabsResult, + receivedFrame: number + ) => boolean + > +} + +/** Install the selected-worktree stream, which resumes immediately on visibility changes. */ +export function installActiveSessionTabsSubscription({ + activeWorktreeId, + activeWorktreeRuntimeEnvironmentId, + activeWorktreeRuntimeConnectionGeneration, + activeWorktreeRuntimePairingRevision, + workspaceSessionReady, + visibilitySnapshotReceipt, + visibilitySnapshotApply +}: ActiveSubscriptionArgs): (() => void) | undefined { + const environmentId = activeWorktreeRuntimeEnvironmentId?.trim() + if ( + !shouldSyncRuntimeSessionTabs({ + activeWorktreeId, + activeWorktreeRuntimeEnvironmentId, + workspaceSessionReady + }) || + !environmentId || + !activeWorktreeId + ) { + return undefined + } + const expectedTrackingGeneration = getWebSessionTabsTrackingGeneration(environmentId) + let requestedInitialTerminal = false + let requestedRespawnAfterWake = false + + const applyActiveSnapshot = async ( + event: RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }, + response: RuntimeRpcResponse, + isCurrent: () => boolean, + receivedFrame: number + ): Promise => { + const recovered = await recoverWebSessionTerminalOrphansBeforeApply( + useAppStore.getState(), + event, + environmentId + ) + if ( + !isCurrent() || + !recovered || + !shouldApplyRecoveredWebSessionTabsSnapshot(environmentId, recovered, receivedFrame) || + !visibilitySnapshotApply.current(environmentId, recovered, receivedFrame) + ) { + return null + } + if (event.type === 'snapshot' || isRuntimeSubscriptionReplayResponse(response)) { + acceptReplayedWebSessionTabsSnapshot(environmentId, recovered.worktree) + } + const recoveredEvent: SessionTabsStreamEvent = { ...recovered, type: event.type } + const decision = decideWebSessionTabsSnapshot(recovered, environmentId) + const syncState = useAppStore.getState() + const localTabs = syncState.tabsByWorktree[activeWorktreeId] ?? [] + const localTerminalCount = localTabs.length + const hasLiveLocalPty = localTabs.some( + (tab) => (syncState.ptyIdsByTabId[tab.id] ?? []).length > 0 + ) + const bootstrap = shouldBootstrapInitialWebRuntimeTerminal({ + event: recoveredEvent, + activeWorktreeId, + requestedInitialTerminal, + snapshotIsFresh: decision.apply, + localTerminalCount + }) + const respawn = shouldRespawnWebRuntimeTerminalAfterWake({ + event: recoveredEvent, + activeWorktreeId, + requestedRespawnAfterWake, + snapshotIsFresh: decision.apply, + localTerminalCount, + hasLiveLocalPty, + skipWakeRespawn: shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId) + }) + let settle: HostSessionMirrorSettle | null = decision.apply + ? null + : hostSessionMirrorSettleForPatchlessFrame(decision, environmentId, recovered.worktree, { + connectionGeneration: activeWorktreeRuntimeConnectionGeneration, + pairingRevision: activeWorktreeRuntimePairingRevision, + trackingGeneration: expectedTrackingGeneration + }) + if (decision.apply) { + const replayed = isRuntimeSubscriptionReplayResponse(response) + settle = applyWebSessionTabsStorePatch( + (state) => applyWebSessionTabsSnapshot(state, recovered, environmentId), + { + frames: [ + { + environmentId, + worktreeId: recovered.worktree, + decision, + expectedEnvironmentConnectionGeneration: activeWorktreeRuntimeConnectionGeneration, + expectedEnvironmentPairingRevision: activeWorktreeRuntimePairingRevision, + expectedTrackingGeneration + } + ] + }, + recovered, + event.type === 'updated' && !replayed + ) + visibilitySnapshotReceipt.current(environmentId, recovered, receivedFrame) + } + try { + if (isCurrent() && bootstrap) { + requestedInitialTerminal = true + await createWebRuntimeSessionTerminal({ + worktreeId: activeWorktreeId, + environmentId, + activate: true + }) + } else if (isCurrent() && respawn && beginWebRuntimeWakeTerminalRespawn(activeWorktreeId)) { + requestedRespawnAfterWake = true + await createWebRuntimeSessionTerminal({ + worktreeId: activeWorktreeId, + environmentId, + activate: true, + selectWorktree: false + }).finally(() => endWebRuntimeWakeTerminalRespawn(activeWorktreeId)) + } + } catch (error) { + if (isCurrent()) { + console.warn('[web-session-tabs-sync] snapshot follow-up failed:', error) + } + } + return settle + } + + return installWindowVisibilitySubscriptionParking([ + { + subscribe: (isCurrent) => + window.api.runtimeEnvironments.subscribe( + { + selector: environmentId, + method: 'session.tabs.subscribe', + params: { worktree: toRuntimeWorktreeSelector(activeWorktreeId) }, + timeoutMs: 15_000, + expectedEnvironmentPairingRevision: activeWorktreeRuntimePairingRevision + }, + { + onResponse: (response) => { + if ( + !isCurrent() || + getRuntimeEnvironmentRevision(environmentId) !== + activeWorktreeRuntimePairingRevision + ) { + return + } + if (response.ok === false) { + console.warn('[web-session-tabs-sync] subscription failed:', response.error.message) + return + } + const event = response.result as SessionTabsStreamEvent + if (event.type !== 'snapshot' && event.type !== 'updated') { + return + } + const frame = recordReceivedWebSessionTabsSnapshot(environmentId, event) + visibilitySnapshotReceipt.current(environmentId, event, frame) + const finish = beginWebSessionTabsSnapshotRecovery( + environmentId, + event.worktree, + frame + ) + void applyActiveSnapshot(event, response, isCurrent, frame) + .catch((error) => { + if (isCurrent()) { + console.warn('[web-session-tabs-sync] active snapshot recovery failed:', error) + } + return null + }) + .then((settle) => { + finish() + if (isCurrent()) { + settle?.() + } + }) + }, + onError: (error) => { + if (isCurrent()) { + console.warn('[web-session-tabs-sync] subscription error:', error.message) + } + } + } + ), + onSubscribeError: (error) => { + console.warn( + '[web-session-tabs-sync] failed to subscribe:', + error instanceof Error ? error.message : String(error) + ) + }, + onUnsubscribeError: (error) => { + console.warn('[web-session-tabs-sync] failed to unsubscribe:', error) + } + } + ]) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts new file mode 100644 index 00000000000..dac050d2278 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts @@ -0,0 +1,191 @@ +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time' +import { normalizeCompatibleAgentStatusEntryForOwner } from '../../../../shared/agent-title-owner' +import { isWebTerminalSurfaceTabId, toWebTerminalSurfaceTabId } from '../web-runtime-session' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { + MirroredTerminalTab, + TerminalSurface, + WebSessionTabsBatchContext, + WebSessionTabsSyncState +} from './state' +import { + isClientOwnedAgentStatus, + isFencedClientAgentStatus, + hostAgentStatusPiercesClientAuthority, + isMirroredAgentPaneKeyForTabs, + batchAgentPaneKeysForTabs, + remapHostAgentStatus, + updateBatchAgentPaneKey +} from './agent-status-primitives' +import { + agentStatusEntryEqual, + isAgentStatusFresh, + isMirroredCommandCodeTurnBump, + writableWebSessionTabsRecord +} from './state-equality-core' + +export function buildMirroredAgentStatusPatch( + state: WebSessionTabsSyncState, + currentTerminalTabs: readonly TerminalTab[], + terminalSurfaceTabs: readonly TerminalSurface[], + mirroredTerminalTabs: readonly MirroredTerminalTab[], + now: number, + batchContext?: WebSessionTabsBatchContext +): Pick | null { + const mirroredTabIds = new Set() + for (const tab of currentTerminalTabs) { + if (isWebTerminalSurfaceTabId(tab.id)) { + mirroredTabIds.add(tab.id) + } + } + for (const surface of terminalSurfaceTabs) { + mirroredTabIds.add(toWebTerminalSurfaceTabId(surface.parentTabId)) + } + + if (mirroredTabIds.size === 0) { + return null + } + + let retainedSurfaceByHostTabAndPrunedLeafId: + | Map> + | undefined + for (const entry of mirroredTerminalTabs) { + if (entry.retainedSurfaceByPrunedLeafId) { + retainedSurfaceByHostTabAndPrunedLeafId ??= new Map() + retainedSurfaceByHostTabAndPrunedLeafId.set( + entry.hostTabId, + entry.retainedSurfaceByPrunedLeafId + ) + } + } + const nextByPaneKey = new Map() + for (const surface of terminalSurfaceTabs) { + const retainedSurface = retainedSurfaceByHostTabAndPrunedLeafId + ?.get(surface.parentTabId) + ?.get(surface.leafId) + const entry = remapHostAgentStatus(surface, retainedSurface) + if (!entry) { + continue + } + const existing = nextByPaneKey.get(entry.paneKey) ?? state.agentStatusByPaneKey[entry.paneKey] + // Why: keep fresher OSC state while taking remapped ownership metadata from the authoritative host snapshot. + const hostIdentityPredatesCurrentTurn = + existing !== undefined && + entry.state === 'done' && + existing.state !== 'done' && + existing.stateStartedAt > entry.stateStartedAt + // Why: cross-machine wall clocks are not comparable, so the host frame could + // outrank live client status forever; a proven client writer keeps its own + // state (still adopting the host's identity fields below) unless the host + // carries a state class the client's bytes can never see. + const clientOwnsEntry = + isFencedClientAgentStatus(entry.paneKey, existing, now) && + !hostAgentStatusPiercesClientAuthority(entry) + const nextEntry = + existing && (clientOwnsEntry || existing.updatedAt > entry.updatedAt) + ? { + ...normalizeCompatibleAgentStatusEntryForOwner(existing, entry.agentType), + ...(clientOwnsEntry && existing.state === 'working' && entry.state === 'working' + ? { workingMode: entry.workingMode } + : {}), + paneKey: entry.paneKey, + worktreeId: entry.worktreeId ?? existing.worktreeId, + tabId: entry.tabId, + providerSession: + existing.providerSession ?? + (hostIdentityPredatesCurrentTurn ? undefined : entry.providerSession), + // Why: hook-only content the byte pipeline can never see, and every OSC + // write blanks it, so a fenced pane's message line stayed empty forever + // (#12906). Host-first unlike providerSession: only the host can mint one. + lastAssistantMessage: + (hostIdentityPredatesCurrentTurn ? undefined : entry.lastAssistantMessage) ?? + existing.lastAssistantMessage + } + : entry + nextByPaneKey.set(entry.paneKey, nextEntry) + } + + let nextAgentStatusByPaneKey = state.agentStatusByPaneKey + let changed = false + let aggregateRelevantChange = false + let sortRelevantChange = false + + for (const paneKey of batchAgentPaneKeysForTabs(state, mirroredTabIds, batchContext)) { + if (!isMirroredAgentPaneKeyForTabs(paneKey, mirroredTabIds)) { + continue + } + if (nextByPaneKey.has(paneKey)) { + continue + } + // Why: the host surface carrying no status is not proof the agent stopped — + // hook-only hosts publish nothing for OSC-driven panes. Keep a live entry + // this renderer owns; it decays through the normal freshness boundary. + // Ownership, not freshness, is the gate here: with no competing host value + // there is nothing to arbitrate, and a client asleep past the stale + // boundary would otherwise erase every pane it owns on the first snapshot + // after wake (STA-3107) instead of decaying it like a local pane. + if (isClientOwnedAgentStatus(paneKey, state.agentStatusByPaneKey[paneKey])) { + continue + } + if (nextAgentStatusByPaneKey === state.agentStatusByPaneKey) { + nextAgentStatusByPaneKey = writableWebSessionTabsRecord( + state, + 'agentStatusByPaneKey', + batchContext + ) + } + delete nextAgentStatusByPaneKey[paneKey] + updateBatchAgentPaneKey(paneKey, false, batchContext) + changed = true + aggregateRelevantChange = true + sortRelevantChange = true + } + + for (const [paneKey, entry] of nextByPaneKey) { + const existing = nextAgentStatusByPaneKey[paneKey] + if (agentStatusEntryEqual(existing, entry)) { + continue + } + if (nextAgentStatusByPaneKey === state.agentStatusByPaneKey) { + nextAgentStatusByPaneKey = writableWebSessionTabsRecord( + state, + 'agentStatusByPaneKey', + batchContext + ) + } + nextAgentStatusByPaneKey[paneKey] = entry + updateBatchAgentPaneKey(paneKey, true, batchContext) + changed = true + const entryAttributionChanged = + existing?.worktreeId !== entry.worktreeId || existing?.tabId !== entry.tabId + const entryFreshnessChanged = + !!existing && isAgentStatusFresh(existing, now) !== isAgentStatusFresh(entry, now) + const doneAttentionChanged = + existing?.state === 'done' && + entry.state === 'done' && + agentEntryCompletionAt(existing) !== agentEntryCompletionAt(entry) + const workingModeChanged = existing?.workingMode !== entry.workingMode + const entrySortRelevantChange = + !existing || + existing.state !== entry.state || + !isAgentStatusFresh(existing, now) || + entryFreshnessChanged || + entryAttributionChanged || + doneAttentionChanged || + isMirroredCommandCodeTurnBump(existing, entry) + aggregateRelevantChange = + aggregateRelevantChange || entrySortRelevantChange || workingModeChanged + sortRelevantChange = sortRelevantChange || entrySortRelevantChange + } + + if (!changed) { + return null + } + + return { + agentStatusByPaneKey: nextAgentStatusByPaneKey, + agentStatusEpoch: aggregateRelevantChange ? state.agentStatusEpoch + 1 : state.agentStatusEpoch, + sortEpoch: sortRelevantChange ? state.sortEpoch + 1 : state.sortEpoch + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts new file mode 100644 index 00000000000..30cb1ac7632 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts @@ -0,0 +1,198 @@ +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { parsePaneKey, makePaneKey, isTerminalLeafId } from '../../../../shared/stable-pane-id' +import { isClientAuthoritativeAgentStatusPane } from '@/components/terminal-pane/renderer-owned-agent-status-registry' +import { normalizeCompatibleAgentStatusEntryForOwner } from '../../../../shared/agent-title-owner' +import { resolvePaneAgentOwnerRecord } from '../../../../shared/pane-agent-owner' +import { toWebTerminalSurfaceTabId } from '../web-runtime-session' +import type { TerminalSurface, WebSessionTabsBatchContext, WebSessionTabsSyncState } from './state' +import type { RetiredTerminalTabSweepState } from '../../store/slices/retired-terminal-tab-state-sweep' +import { buildRetiredTerminalTabStateSweepPatch } from '../../store/slices/retired-terminal-tab-state-sweep' +import { isMirroredTerminalSurfaceId } from './terminal-surfaces' +import { isAgentStatusFresh } from './state-equality-core' + +export function toMirroredPaneKey( + surface: TerminalSurface, + leafId = surface.leafId +): string | null { + if (!isTerminalLeafId(leafId)) { + return null + } + return makePaneKey(toWebTerminalSurfaceTabId(surface.parentTabId), leafId) +} + +/** Normalises and mirrors agent status updates from the host payload, preserving ownership metadata. */ +export function remapHostAgentStatus( + surface: TerminalSurface, + retainedSurface?: TerminalSurface +): AgentStatusEntry | null { + if (!surface.agentStatus) { + return null + } + const paneKey = toMirroredPaneKey(surface, retainedSurface?.leafId) + if (!paneKey) { + return null + } + const ownerRecord = resolvePaneAgentOwnerRecord({ + launchAgent: retainedSurface?.launchAgent ?? surface.launchAgent, + hookAgent: surface.agentStatus.agentType + }) + return { + ...normalizeCompatibleAgentStatusEntryForOwner(surface.agentStatus, ownerRecord?.agent, { + ownerIsLaunch: ownerRecord?.ownerIsLaunch === true + }), + paneKey, + tabId: toWebTerminalSurfaceTabId(surface.parentTabId) + } +} + +export function isMirroredAgentPaneKeyForTabs( + paneKey: string, + tabIds: ReadonlySet +): boolean { + const parsed = parsePaneKey(paneKey) + return parsed !== null && tabIds.has(parsed.tabId) +} + +/** Host states the client's byte pipeline cannot observe: permission blocks and + * interactive question cards reach the host over its HTTP agent hook, never + * through PTY bytes, so they must pierce the client-authority fence. */ +export function hostAgentStatusPiercesClientAuthority(entry: AgentStatusEntry): boolean { + return entry.state === 'blocked' || entry.interactivePrompt != null +} + +/** True while this renderer's own byte-derived status owns the pane: it claimed + * the pane at transport creation and wrote status from bytes. The claim is + * released on pane teardown, which is how the host takes the pane back. */ +export function isClientOwnedAgentStatus( + paneKey: string, + existing: AgentStatusEntry | undefined +): existing is AgentStatusEntry { + return existing !== undefined && isClientAuthoritativeAgentStatusPane(paneKey) +} + +/** Owned AND still fresh — the arbitration rule for a pane the host also has an + * opinion about: an OSC-silent dead agent hands that contest back to the host. */ +export function isFencedClientAgentStatus( + paneKey: string, + existing: AgentStatusEntry | undefined, + now: number +): existing is AgentStatusEntry { + return isClientOwnedAgentStatus(paneKey, existing) && isAgentStatusFresh(existing, now) +} + +export function batchAgentPaneKeysForTabs( + state: WebSessionTabsSyncState, + tabIds: ReadonlySet, + batchContext?: WebSessionTabsBatchContext +): string[] { + if (!batchContext) { + return Object.keys(state.agentStatusByPaneKey) + } + if (!batchContext.agentPaneKeysByTabId) { + batchContext.agentPaneKeysByTabId = new Map() + for (const paneKey of Object.keys(state.agentStatusByPaneKey)) { + const tabId = parsePaneKey(paneKey)?.tabId + if (!tabId) { + continue + } + const paneKeys = batchContext.agentPaneKeysByTabId.get(tabId) ?? new Set() + paneKeys.add(paneKey) + batchContext.agentPaneKeysByTabId.set(tabId, paneKeys) + } + } + return [...tabIds].flatMap((tabId) => [...(batchContext.agentPaneKeysByTabId?.get(tabId) ?? [])]) +} + +export function updateBatchAgentPaneKey( + paneKey: string, + present: boolean, + batchContext?: WebSessionTabsBatchContext +): void { + const tabId = parsePaneKey(paneKey)?.tabId + const index = batchContext?.agentPaneKeysByTabId + if (!tabId || !index) { + return + } + if (present) { + const paneKeys = index.get(tabId) ?? new Set() + paneKeys.add(paneKey) + index.set(tabId, paneKeys) + return + } + const paneKeys = index.get(tabId) + paneKeys?.delete(paneKey) + if (paneKeys?.size === 0) { + index.delete(tabId) + } +} + +// Why: the closed-tab marker has no TTL, and setAgentStatus hard-drops writes for a +// marked tab id — for a stable mirrored id that returns, that is a permanent silent +// blackhole unless presence in a snapshot lifts it. +export function buildRemirroredClosedTabMarkerLiftPatch( + recentlyClosedAgentStatusTabIds: WebSessionTabsSyncState['recentlyClosedAgentStatusTabIds'], + mirroredTerminalIds: ReadonlySet +): Partial | null { + let next: WebSessionTabsSyncState['recentlyClosedAgentStatusTabIds'] | null = null + for (const tabId of mirroredTerminalIds) { + if (tabId in (recentlyClosedAgentStatusTabIds ?? {})) { + next ??= { ...recentlyClosedAgentStatusTabIds } + delete next[tabId] + } + } + return next ? { recentlyClosedAgentStatusTabIds: next } : null +} + +/** + * A host retraction owes the retracted tab closeTab's renderer-state sweep: without it, + * client-owned rows (STA-3107-exempt in the mirror's delete loop) and retention promotions + * outlive the tab forever (STA-4593). + */ +export function buildRetractedMirroredTabSweepPatch( + state: WebSessionTabsSyncState, + worktreeId: string, + nextTabsByWorktree: WebSessionTabsSyncState['tabsByWorktree'], + agentStatusPatch: Pick< + WebSessionTabsSyncState, + 'agentStatusByPaneKey' | 'agentStatusEpoch' | 'sortEpoch' + > | null, + removedTerminalResourceIds: readonly string[], + batchContext?: WebSessionTabsBatchContext +): Partial | null { + // Why: only a mirrored id the host stopped publishing is a retraction — a local or provisional + // tab in this list is being renamed into its mirror, and a rename must keep its rows. + const retractedTabIds = removedTerminalResourceIds.filter(isMirroredTerminalSurfaceId) + if (retractedTabIds.length === 0) { + return null + } + const sweepState: RetiredTerminalTabSweepState = { + acknowledgedAgentsByPaneKey: state.acknowledgedAgentsByPaneKey ?? {}, + agentLaunchConfigByPaneKey: state.agentLaunchConfigByPaneKey ?? {}, + agentStatusByPaneKey: agentStatusPatch?.agentStatusByPaneKey ?? state.agentStatusByPaneKey, + agentStatusEpoch: agentStatusPatch?.agentStatusEpoch ?? state.agentStatusEpoch, + migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId ?? {}, + paneForegroundAgentByPaneKey: state.paneForegroundAgentByPaneKey ?? {}, + recentlyClosedAgentStatusTabIds: state.recentlyClosedAgentStatusTabIds ?? {}, + recentlyRetiredAgentStatusPaneKeys: state.recentlyRetiredAgentStatusPaneKeys ?? {}, + retainedAgentsByPaneKey: state.retainedAgentsByPaneKey ?? {}, + retentionSuppressedPaneKeys: state.retentionSuppressedPaneKeys ?? {}, + sortEpoch: agentStatusPatch?.sortEpoch ?? state.sortEpoch, + // Why: the drop's completed-orphan rule reads "keyed under a tab this worktree no longer has", + // so it must see the post-removal tab list, not the one the snapshot replaced. + tabsByWorktree: nextTabsByWorktree + } + const sweep = buildRetiredTerminalTabStateSweepPatch(sweepState, retractedTabIds, worktreeId) + if (!sweep?.agentStatusByPaneKey || !batchContext) { + return sweep ?? null + } + // Why: the batch republishes its own record copy at the end, which would undo the sweep. + const mutableState = state as unknown as Record + mutableState.agentStatusByPaneKey = sweep.agentStatusByPaneKey + batchContext.changedRecords.add('agentStatusByPaneKey') + for (const paneKey of Object.keys(sweepState.agentStatusByPaneKey)) { + if (!(paneKey in sweep.agentStatusByPaneKey)) { + updateBatchAgentPaneKey(paneKey, false, batchContext) + } + } + return sweep +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-active-state.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-active-state.ts new file mode 100644 index 00000000000..68c06e05c9d --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-active-state.ts @@ -0,0 +1,288 @@ +import type { applyWorktreeRecordUpdates } from './apply-worktree-records' +import { withWorktreeEntry, sameStringArray } from './state-equality-core' +import { toVisibleTabType } from './state-equality-files' +import { + collectLayoutGroupIds, + appendTabGroupLayout, + pruneTabGroupLayout, + tabGroupLayoutEqual +} from './layout-helpers' + +type ActiveStateContext = ReturnType + +/** Resolve active ids, visible type, layout, and tab-bar order after reconciliation. */ +export function applyActiveStateUpdates(context: ActiveStateContext) { + const { + state, + snapshot, + options, + worktreeId, + targetGroupId, + nextGroups, + clientOwnedPlacement, + nextUnifiedTabs, + nextTerminalTabs, + nextWorktreeOpenFileIds, + nextActiveUnifiedTabId, + nextTabBarOrder, + navigationIntentTab, + honorSnapshotActiveFocus, + intentMirroredAgent, + activeMirroredAgentTabId, + currentVisibleUnifiedTabId, + currentVisibleStructuredTabId, + currentActiveTerminalStillExists, + currentActiveBrowserStillExists, + currentActiveEditorStillExists, + intentEditorFileId, + nextActiveTerminalId, + nextActiveBrowserWorkspaceId, + nextActiveEditorFileId, + intentTerminalId, + intentBrowserWorkspaceId + } = context + + const nextActiveGroupId = clientOwnedPlacement + ? clientOwnedPlacement.activeGroupId + : (nextGroups?.find((group) => group.activeTabId === nextActiveUnifiedTabId)?.id ?? + nextGroups?.find((group) => group.id === snapshot.activeGroupId)?.id ?? + nextGroups?.[0]?.id ?? + null) + const nextActiveGroupIdByWorktree = + nextGroups && state.activeGroupIdByWorktree[worktreeId] !== nextActiveGroupId + ? withWorktreeEntry( + state, + 'activeGroupIdByWorktree', + worktreeId, + nextActiveGroupId ?? targetGroupId, + (current, next) => current === next, + context.batchContext + ) + : state.activeGroupIdByWorktree + + const nextLayoutByWorktree = (() => { + if (!nextGroups) { + return state.layoutByWorktree + } + if (clientOwnedPlacement) { + const clientLayout = + clientOwnedPlacement.layout ?? + (nextActiveGroupId ? { type: 'leaf' as const, groupId: nextActiveGroupId } : null) + if (!clientLayout || tabGroupLayoutEqual(state.layoutByWorktree[worktreeId], clientLayout)) { + return state.layoutByWorktree + } + return withWorktreeEntry( + state, + 'layoutByWorktree', + worktreeId, + clientLayout, + (current, next) => current === next, + context.batchContext + ) + } + if (options?.preserveLocalLayout) { + return state.layoutByWorktree + } + const validGroupIds = new Set(nextGroups.map((group) => group.id)) + const hostLayout = pruneTabGroupLayout(snapshot.tabGroupLayout, validGroupIds) + const defaultLeafLayout = { type: 'leaf' as const, groupId: nextActiveGroupId ?? targetGroupId } + const hostLayoutGroupIds = collectLayoutGroupIds(hostLayout ?? undefined) + const hostGroupIds = new Set(snapshot.tabGroups?.map((group) => group.id) ?? []) + const extraGroupIds = new Set( + nextGroups + .map((group) => group.id) + .filter((groupId) => + hostLayout + ? !hostLayoutGroupIds.has(groupId) + : snapshot.tabGroups && snapshot.tabGroups.length > 0 + ? !hostGroupIds.has(groupId) + : false + ) + ) + const localExtraLayout = pruneTabGroupLayout(state.layoutByWorktree[worktreeId], extraGroupIds) + const hostBaseLayout = + hostLayout ?? (snapshot.tabGroups && snapshot.tabGroups.length > 0 ? defaultLeafLayout : null) + const fallbackLayout = + appendTabGroupLayout(hostBaseLayout, localExtraLayout) ?? + (snapshot.tabGroups && snapshot.tabGroups.length > 0 + ? defaultLeafLayout + : state.layoutByWorktree[worktreeId] + ? null + : defaultLeafLayout) + if ( + !fallbackLayout || + tabGroupLayoutEqual(state.layoutByWorktree[worktreeId], fallbackLayout) + ) { + return state.layoutByWorktree + } + return withWorktreeEntry( + state, + 'layoutByWorktree', + worktreeId, + fallbackLayout, + (current, next) => current === next, + context.batchContext + ) + })() + + const nextTabBarOrderByWorktree = withWorktreeEntry( + state, + 'tabBarOrderByWorktree', + worktreeId, + nextTabBarOrder.length > 0 ? nextTabBarOrder : null, + (a, b) => sameStringArray(a ?? [], b ?? []), + context.batchContext + ) + const nextActiveTabIdByWorktree = + (state.activeTabIdByWorktree[worktreeId] ?? null) !== + (intentMirroredAgent?.unifiedTab.id ?? currentVisibleStructuredTabId ?? nextActiveTerminalId) + ? withWorktreeEntry( + state, + 'activeTabIdByWorktree', + worktreeId, + intentMirroredAgent?.unifiedTab.id ?? + currentVisibleStructuredTabId ?? + nextActiveTerminalId, + (current, next) => (current ?? null) === next, + context.batchContext, + false + ) + : state.activeTabIdByWorktree + const nextActiveBrowserTabIdByWorktree = + (state.activeBrowserTabIdByWorktree[worktreeId] ?? null) !== nextActiveBrowserWorkspaceId + ? withWorktreeEntry( + state, + 'activeBrowserTabIdByWorktree', + worktreeId, + nextActiveBrowserWorkspaceId, + (current, next) => (current ?? null) === next, + context.batchContext, + false + ) + : state.activeBrowserTabIdByWorktree + const nextActiveFileIdByWorktree = + (state.activeFileIdByWorktree[worktreeId] ?? null) !== nextActiveEditorFileId + ? withWorktreeEntry( + state, + 'activeFileIdByWorktree', + worktreeId, + nextActiveEditorFileId, + (current, next) => (current ?? null) === next, + context.batchContext, + false + ) + : state.activeFileIdByWorktree + + const isActiveWorktree = state.activeWorktreeId === worktreeId + const focusIntentVisibleTabType = + navigationIntentTab?.type === 'agent-session' && intentMirroredAgent + ? ('agent-session' as const) + : navigationIntentTab?.type === 'browser' && intentBrowserWorkspaceId + ? ('browser' as const) + : navigationIntentTab?.type === 'terminal' && intentTerminalId + ? ('terminal' as const) + : intentEditorFileId + ? ('editor' as const) + : null + const snapshotVisibleTabType = + snapshot.activeTabType === 'agent-session' && activeMirroredAgentTabId + ? ('agent-session' as const) + : snapshot.activeTabType === 'browser' && nextActiveBrowserWorkspaceId + ? ('browser' as const) + : snapshot.activeTabType === 'terminal' && nextActiveTerminalId + ? ('terminal' as const) + : (snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file') && + nextActiveEditorFileId + ? ('editor' as const) + : null + const currentVisibleTabType = + state.activeTabTypeByWorktree[worktreeId] ?? (isActiveWorktree ? state.activeTabType : null) + const currentVisibleTabTypeStillValid = + currentVisibleStructuredTabId !== null + ? ('agent-session' as const) + : currentVisibleTabType === 'agent-session' && + currentVisibleUnifiedTabId && + nextUnifiedTabs?.some( + (tab) => tab.id === currentVisibleUnifiedTabId && tab.contentType === 'agent-session' + ) + ? ('agent-session' as const) + : currentVisibleTabType === 'browser' && currentActiveBrowserStillExists + ? ('browser' as const) + : currentVisibleTabType === 'editor' && currentActiveEditorStillExists + ? ('editor' as const) + : currentVisibleTabType === 'terminal' && currentActiveTerminalStillExists + ? ('terminal' as const) + : null + const activeUnifiedTab = + nextActiveUnifiedTabId && nextUnifiedTabs + ? (nextUnifiedTabs.find((tab) => tab.id === nextActiveUnifiedTabId) ?? null) + : null + const fallbackVisibleTabType = + activeUnifiedTab !== null + ? toVisibleTabType(activeUnifiedTab) + : nextActiveTerminalId + ? ('terminal' as const) + : nextActiveBrowserWorkspaceId + ? ('browser' as const) + : nextActiveEditorFileId + ? ('editor' as const) + : ('terminal' as const) + const nextVisibleTabType = honorSnapshotActiveFocus + ? (focusIntentVisibleTabType ?? + currentVisibleTabTypeStillValid ?? + snapshotVisibleTabType ?? + fallbackVisibleTabType) + : (currentVisibleTabTypeStillValid ?? snapshotVisibleTabType ?? fallbackVisibleTabType) + + const currentActiveTerminalStillValid = + state.activeTabId && (nextTerminalTabs ?? []).some((tab) => tab.id === state.activeTabId) + ? state.activeTabId + : null + const currentActiveEditorStillValid = + state.activeFileId && nextWorktreeOpenFileIds.has(state.activeFileId) + ? state.activeFileId + : null + const nextActiveTabId = isActiveWorktree + ? (intentMirroredAgent?.unifiedTab.id ?? + (snapshot.activeTabType === 'terminal' + ? nextActiveTerminalId + : (currentActiveTerminalStillValid ?? nextActiveTerminalId))) + : state.activeTabId + const nextActiveBrowserTabId = isActiveWorktree + ? nextActiveBrowserWorkspaceId + : state.activeBrowserTabId + const nextActiveFileId = isActiveWorktree + ? snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file' + ? nextActiveEditorFileId + : (currentActiveEditorStillValid ?? nextActiveEditorFileId) + : state.activeFileId + const nextActiveTabType = isActiveWorktree ? nextVisibleTabType : state.activeTabType + const nextActiveTabTypeByWorktree = + state.activeTabTypeByWorktree[worktreeId] !== nextVisibleTabType + ? withWorktreeEntry( + state, + 'activeTabTypeByWorktree', + worktreeId, + nextVisibleTabType, + (current, next) => current === next, + context.batchContext + ) + : state.activeTabTypeByWorktree + + return { + ...context, + nextActiveGroupId, + nextActiveGroupIdByWorktree, + nextLayoutByWorktree, + nextTabBarOrderByWorktree, + nextActiveTabIdByWorktree, + nextActiveBrowserTabIdByWorktree, + nextActiveFileIdByWorktree, + nextVisibleTabType, + nextActiveTabId, + nextActiveBrowserTabId, + nextActiveFileId, + nextActiveTabType, + nextActiveTabTypeByWorktree + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-browser-records.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-browser-records.ts new file mode 100644 index 00000000000..56fe3285913 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-browser-records.ts @@ -0,0 +1,134 @@ +import type { applyTerminalRecordUpdates } from './apply-terminal-records' +import { + browserCertificateFailureEqual, + optionalRuntimeBrowserPlacementsEqual, + sameBrowserPages +} from './state-equality-tabs' +import { writableWebSessionTabsRecord } from './state-equality-core' +import { markWebSessionBrowserPlacementAdopted } from '../web-session-browser-placement' + +type BrowserRecordContext = ReturnType + +/** Reconcile browser page records, remote handles, and certificate failures. */ +export function applyBrowserRecordUpdates(context: BrowserRecordContext) { + const { + state, + batchContext, + environmentId, + worktreeId, + mirroredBrowserTabs, + removedBrowserWorkspaceIds, + retainedBrowserTabs, + nextBrowserTabs + } = context + + let nextBrowserPagesByWorkspace = state.browserPagesByWorkspace + let nextRemoteBrowserPageHandlesByPageId = state.remoteBrowserPageHandlesByPageId + let nextBrowserCertificateFailuresByPageId = state.browserCertificateFailuresByPageId + + if (removedBrowserWorkspaceIds.size > 0) { + const nextBrowserWorkspaceIds = new Set(nextBrowserTabs?.map((tab) => tab.id) ?? []) + const nextBrowserPageIds = new Set(mirroredBrowserTabs.map((entry) => entry.page.id)) + for (const workspace of retainedBrowserTabs) { + for (const page of state.browserPagesByWorkspace[workspace.id] ?? []) { + nextBrowserPageIds.add(page.id) + } + } + for (const removedWorkspaceId of removedBrowserWorkspaceIds) { + const pages = nextBrowserPagesByWorkspace[removedWorkspaceId] ?? [] + if ( + !nextBrowserWorkspaceIds.has(removedWorkspaceId) && + nextBrowserPagesByWorkspace[removedWorkspaceId] + ) { + nextBrowserPagesByWorkspace = + nextBrowserPagesByWorkspace === state.browserPagesByWorkspace + ? writableWebSessionTabsRecord(state, 'browserPagesByWorkspace', batchContext) + : nextBrowserPagesByWorkspace + delete nextBrowserPagesByWorkspace[removedWorkspaceId] + } + for (const page of pages) { + if (nextBrowserPageIds.has(page.id)) { + continue + } + if (nextBrowserCertificateFailuresByPageId[page.id]) { + nextBrowserCertificateFailuresByPageId = + nextBrowserCertificateFailuresByPageId === state.browserCertificateFailuresByPageId + ? writableWebSessionTabsRecord( + state, + 'browserCertificateFailuresByPageId', + batchContext + ) + : nextBrowserCertificateFailuresByPageId + delete nextBrowserCertificateFailuresByPageId[page.id] + } + if (nextRemoteBrowserPageHandlesByPageId[page.id]) { + nextRemoteBrowserPageHandlesByPageId = + nextRemoteBrowserPageHandlesByPageId === state.remoteBrowserPageHandlesByPageId + ? writableWebSessionTabsRecord( + state, + 'remoteBrowserPageHandlesByPageId', + batchContext + ) + : nextRemoteBrowserPageHandlesByPageId + delete nextRemoteBrowserPageHandlesByPageId[page.id] + } + } + } + } + + for (const { page, certificateFailure, remotePageId, placement } of mirroredBrowserTabs) { + const current = nextBrowserPagesByWorkspace[page.workspaceId] ?? [] + if (!sameBrowserPages(current, [page])) { + nextBrowserPagesByWorkspace = + nextBrowserPagesByWorkspace === state.browserPagesByWorkspace + ? writableWebSessionTabsRecord(state, 'browserPagesByWorkspace', batchContext) + : nextBrowserPagesByWorkspace + nextBrowserPagesByWorkspace[page.workspaceId] = [page] + } + const currentHandle = nextRemoteBrowserPageHandlesByPageId[page.id] + if ( + currentHandle?.environmentId !== environmentId || + currentHandle.remotePageId !== remotePageId || + // A host snapshot is the adoption boundary for a staged handle. + currentHandle.staged === true || + // Restored markers are spent when the host republishes the page. + currentHandle.restoredFromSession === true || + !optionalRuntimeBrowserPlacementsEqual(currentHandle.placement, placement) + ) { + nextRemoteBrowserPageHandlesByPageId = + nextRemoteBrowserPageHandlesByPageId === state.remoteBrowserPageHandlesByPageId + ? writableWebSessionTabsRecord(state, 'remoteBrowserPageHandlesByPageId', batchContext) + : nextRemoteBrowserPageHandlesByPageId + nextRemoteBrowserPageHandlesByPageId[page.id] = { + environmentId, + remotePageId, + ...(placement ? { placement } : {}) + } + } + markWebSessionBrowserPlacementAdopted({ environmentId, worktreeId, remotePageId }) + if ( + placement?.kind !== 'client' && + !browserCertificateFailureEqual( + nextBrowserCertificateFailuresByPageId[page.id], + certificateFailure + ) + ) { + nextBrowserCertificateFailuresByPageId = + nextBrowserCertificateFailuresByPageId === state.browserCertificateFailuresByPageId + ? writableWebSessionTabsRecord(state, 'browserCertificateFailuresByPageId', batchContext) + : nextBrowserCertificateFailuresByPageId + if (certificateFailure) { + nextBrowserCertificateFailuresByPageId[page.id] = certificateFailure + } else { + delete nextBrowserCertificateFailuresByPageId[page.id] + } + } + } + + return { + ...context, + nextBrowserPagesByWorkspace, + nextRemoteBrowserPageHandlesByPageId, + nextBrowserCertificateFailuresByPageId + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts new file mode 100644 index 00000000000..e15e6369c41 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts @@ -0,0 +1,146 @@ +import type { WebSessionTabsSyncState } from './state' +import type { applyActiveStateUpdates } from './apply-active-state' +import { buildMirroredAgentStatusPatch } from './agent-status-patch' +import { + buildRemirroredClosedTabMarkerLiftPatch, + buildRetractedMirroredTabSweepPatch +} from './agent-status-primitives' +import { isWebSessionTabsWorktreeRemovalFrame } from './tracking' + +type FinalPatchContext = ReturnType + +/** Build the minimal store patch after all reconciliation stages have completed. */ +export function buildWebSessionTabsFinalPatch( + context: FinalPatchContext +): WebSessionTabsSyncState | Partial { + const { + state, + snapshot, + worktreeId, + now, + batchContext, + currentTerminalTabs, + terminalSurfaceTabs, + mirroredTerminalTabs, + nextTabsByWorktree, + removedTerminalResourceIds, + mirroredTerminalIds, + nextOpenFiles, + nextBrowserTabsByWorktree, + nextUnifiedTabsByWorktree, + nextGroupsByWorktree, + nextActiveGroupIdByWorktree, + nextLayoutByWorktree, + nextTabBarOrderByWorktree, + nextPtyIdsByTabId, + nextTerminalLayoutsByTabId, + nextUnreadTerminalTabs, + pendingStartupByTabId, + nextPendingStartupByTabId, + automaticAgentResumeClaimsByTabId, + nextAutomaticAgentResumeClaimsByTabId, + nextBrowserPagesByWorkspace, + nextRemoteBrowserPageHandlesByPageId, + nextBrowserCertificateFailuresByPageId, + nextActiveTabIdByWorktree, + nextActiveBrowserTabIdByWorktree, + nextActiveFileIdByWorktree, + nextActiveTabId, + nextActiveBrowserTabId, + nextActiveFileId, + nextActiveTabType, + nextActiveTabTypeByWorktree + } = context + + const agentStatusPatch = buildMirroredAgentStatusPatch( + state, + currentTerminalTabs, + terminalSurfaceTabs, + mirroredTerminalTabs, + now, + batchContext + ) + // A tombstone clears all environments' view of a worktree; it is not a terminal retraction. + const retractedTabSweepPatch = isWebSessionTabsWorktreeRemovalFrame(snapshot) + ? null + : buildRetractedMirroredTabSweepPatch( + state, + worktreeId, + nextTabsByWorktree, + agentStatusPatch, + removedTerminalResourceIds, + batchContext + ) + const remirroredClosedTabLiftPatch = buildRemirroredClosedTabMarkerLiftPatch( + retractedTabSweepPatch?.recentlyClosedAgentStatusTabIds ?? + state.recentlyClosedAgentStatusTabIds, + mirroredTerminalIds + ) + + const patch: Partial = { + ...agentStatusPatch, + ...retractedTabSweepPatch, + ...remirroredClosedTabLiftPatch, + ...(nextOpenFiles !== state.openFiles ? { openFiles: nextOpenFiles } : {}), + ...(nextTabsByWorktree !== state.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}), + ...(nextBrowserTabsByWorktree !== state.browserTabsByWorktree + ? { browserTabsByWorktree: nextBrowserTabsByWorktree } + : {}), + ...(nextUnifiedTabsByWorktree !== state.unifiedTabsByWorktree + ? { unifiedTabsByWorktree: nextUnifiedTabsByWorktree } + : {}), + ...(nextGroupsByWorktree !== state.groupsByWorktree + ? { groupsByWorktree: nextGroupsByWorktree } + : {}), + ...(nextActiveGroupIdByWorktree !== state.activeGroupIdByWorktree + ? { activeGroupIdByWorktree: nextActiveGroupIdByWorktree } + : {}), + ...(nextLayoutByWorktree !== state.layoutByWorktree + ? { layoutByWorktree: nextLayoutByWorktree } + : {}), + ...(nextTabBarOrderByWorktree !== state.tabBarOrderByWorktree + ? { tabBarOrderByWorktree: nextTabBarOrderByWorktree } + : {}), + ...(nextPtyIdsByTabId !== state.ptyIdsByTabId ? { ptyIdsByTabId: nextPtyIdsByTabId } : {}), + ...(nextTerminalLayoutsByTabId !== state.terminalLayoutsByTabId + ? { terminalLayoutsByTabId: nextTerminalLayoutsByTabId } + : {}), + ...(nextUnreadTerminalTabs !== state.unreadTerminalTabs + ? { unreadTerminalTabs: nextUnreadTerminalTabs } + : {}), + ...(nextPendingStartupByTabId !== pendingStartupByTabId + ? { pendingStartupByTabId: nextPendingStartupByTabId } + : {}), + ...(nextAutomaticAgentResumeClaimsByTabId !== automaticAgentResumeClaimsByTabId + ? { automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId } + : {}), + ...(nextBrowserPagesByWorkspace !== state.browserPagesByWorkspace + ? { browserPagesByWorkspace: nextBrowserPagesByWorkspace } + : {}), + ...(nextRemoteBrowserPageHandlesByPageId !== state.remoteBrowserPageHandlesByPageId + ? { remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId } + : {}), + ...(nextBrowserCertificateFailuresByPageId !== state.browserCertificateFailuresByPageId + ? { browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId } + : {}), + ...(nextActiveTabIdByWorktree !== state.activeTabIdByWorktree + ? { activeTabIdByWorktree: nextActiveTabIdByWorktree } + : {}), + ...(nextActiveBrowserTabIdByWorktree !== state.activeBrowserTabIdByWorktree + ? { activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree } + : {}), + ...(nextActiveFileIdByWorktree !== state.activeFileIdByWorktree + ? { activeFileIdByWorktree: nextActiveFileIdByWorktree } + : {}), + ...(nextActiveTabId !== state.activeTabId ? { activeTabId: nextActiveTabId } : {}), + ...(nextActiveBrowserTabId !== state.activeBrowserTabId + ? { activeBrowserTabId: nextActiveBrowserTabId } + : {}), + ...(nextActiveFileId !== state.activeFileId ? { activeFileId: nextActiveFileId } : {}), + ...(nextActiveTabType !== state.activeTabType ? { activeTabType: nextActiveTabType } : {}), + ...(nextActiveTabTypeByWorktree !== state.activeTabTypeByWorktree + ? { activeTabTypeByWorktree: nextActiveTabTypeByWorktree } + : {}) + } + return Object.keys(patch).length === 0 ? state : patch +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts new file mode 100644 index 00000000000..1671fc5a975 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts @@ -0,0 +1,212 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { + WebSessionTabsBatchContext, + WebSessionTabsSnapshotApplyOptions, + WebSessionTabsSyncState +} from './state' +import { + reconcileWebSessionCloseIntents, + isWebSessionCloseIntentPending +} from '../web-session-close-intent' +import { + peekWebSessionFocusIntent, + resolveWebSessionVisibleTabId, + clearWebSessionFocusIntent +} from '../web-session-focus-intent' +import { + resolveWebAgentSessionHandoff, + isWebAgentSessionHandoffPostCreateSnapshotConfirmed, + clearWebAgentSessionHandoff +} from '../web-agent-session-handoff' +import { toRemoteRuntimePtyId } from '../runtime-terminal-stream' +import { toWebTerminalSurfaceTabId } from '../web-runtime-session' +import { + isReadyTerminalTab, + isTerminalSurfaceTab, + isMirroredTerminalSurfaceId, + shouldReplaceTerminalTab +} from './terminal-surfaces' +import { buildMirroredTerminalTabs } from './terminal-build' + +export function prepareWebSessionTabsSnapshotBase( + state: WebSessionTabsSyncState, + rawSnapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + worktreeId: string, + now: number, + batchContext: WebSessionTabsBatchContext | undefined, + options: WebSessionTabsSnapshotApplyOptions | undefined +) { + const snapshotHostTabId = (tab: RuntimeMobileSessionTabsResult['tabs'][number]): string => + tab.type === 'terminal' ? tab.parentTabId : tab.id + reconcileWebSessionCloseIntents( + { environmentId }, + worktreeId, + new Set(rawSnapshot.tabs.map((tab) => snapshotHostTabId(tab))) + ) + const snapshot: RuntimeMobileSessionTabsResult = rawSnapshot.tabs.some((tab) => + isWebSessionCloseIntentPending({ environmentId }, worktreeId, snapshotHostTabId(tab), now) + ) + ? { + ...rawSnapshot, + tabs: rawSnapshot.tabs.filter( + (tab) => + !isWebSessionCloseIntentPending( + { environmentId }, + worktreeId, + snapshotHostTabId(tab), + now + ) + ) + } + : rawSnapshot + // Why: only a caller-recorded create intent may focus its arriving tab; unsolicited server-active must not steal focus (#5435). + const focusIntent = peekWebSessionFocusIntent({ environmentId }, worktreeId) + const focusIntentHostTabId = focusIntent?.hostTabId ?? null + const matchingFocusIntentTab = + focusIntentHostTabId === null + ? null + : focusIntent?.leafId + ? (snapshot.tabs.find( + (tab) => + tab.type === 'terminal' && + tab.leafId === focusIntent.leafId && + (tab.id === focusIntentHostTabId || tab.parentTabId === focusIntentHostTabId) + ) ?? null) + : (snapshot.tabs.find( + (tab) => + tab.id === focusIntentHostTabId || + (tab.type === 'terminal' && tab.parentTabId === focusIntentHostTabId) || + (tab.type === 'browser' && tab.browserPageId === focusIntentHostTabId) + ) ?? null) + const expectedCurrentLocalTabId = focusIntent?.expectedCurrentLocalTabId + const currentVisibleLocalTabId = resolveWebSessionVisibleTabId(state, worktreeId) + const callerFocusIntentTab = + matchingFocusIntentTab && + (expectedCurrentLocalTabId === undefined || + expectedCurrentLocalTabId === currentVisibleLocalTabId) + ? matchingFocusIntentTab + : null + const followIntentTab = + snapshot.navigationIntent === 'follow' + ? (snapshot.tabs.find((tab) => tab.id === snapshot.activeTabId) ?? null) + : null + const navigationIntentTab = callerFocusIntentTab ?? followIntentTab + const honorSnapshotActiveFocus = navigationIntentTab !== null + if (matchingFocusIntentTab) { + clearWebSessionFocusIntent({ environmentId }, worktreeId) + } + const currentTerminalTabs = state.tabsByWorktree[worktreeId] ?? [] + const existingTerminalById = new Map(currentTerminalTabs.map((tab) => [tab.id, tab])) + const reconcilesNonAgentTabs = options?.contentScope !== 'agent-session' + const terminalSurfaceTabs = reconcilesNonAgentTabs + ? snapshot.tabs.filter(isTerminalSurfaceTab) + : [] + const readyTerminalTabs = terminalSurfaceTabs.filter(isReadyTerminalTab) + const nextRemotePtyIds = new Set( + readyTerminalTabs.map((tab) => toRemoteRuntimePtyId(tab.terminal, environmentId)) + ) + const nextMirroredTerminalIds = new Set( + terminalSurfaceTabs.map((tab) => toWebTerminalSurfaceTabId(tab.parentTabId)) + ) + const nextHostTerminalTabIds = new Set(terminalSurfaceTabs.map((tab) => tab.parentTabId)) + const provisionalHandoffHostTabIds = new Map() + for (const tab of currentTerminalTabs) { + if (isMirroredTerminalSurfaceId(tab.id)) { + continue + } + if (nextHostTerminalTabIds.has(tab.id)) { + provisionalHandoffHostTabIds.set(tab.id, tab.id) + continue + } + const handoff = { + environmentId, + worktreeId, + provisionalTabId: tab.id + } + const hostTabId = resolveWebAgentSessionHandoff(handoff) + if ( + hostTabId !== null && + (nextHostTerminalTabIds.has(hostTabId) || + isWebAgentSessionHandoffPostCreateSnapshotConfirmed(handoff)) + ) { + provisionalHandoffHostTabIds.set(tab.id, hostTabId) + } + } + const exactProvisionalHandoffs = new Set(provisionalHandoffHostTabIds.keys()) + const retainedTerminalTabs = reconcilesNonAgentTabs + ? currentTerminalTabs.filter( + (tab) => + !shouldReplaceTerminalTab( + tab, + environmentId, + nextRemotePtyIds, + nextMirroredTerminalIds, + exactProvisionalHandoffs + ) + ) + : currentTerminalTabs + const mirroredTerminalTabs = buildMirroredTerminalTabs( + snapshot, + environmentId, + existingTerminalById, + state.terminalLayoutsByTabId, + retainedTerminalTabs.length, + now, + callerFocusIntentTab?.type === 'terminal' + ? { + parentTabId: callerFocusIntentTab.parentTabId, + leafId: callerFocusIntentTab.leafId + } + : undefined, + options?.terminalPtyMode + ) + const mirroredTerminalTabEntries = mirroredTerminalTabs.map((entry) => entry.tab) + const retainedTerminalIds = new Set(retainedTerminalTabs.map((tab) => tab.id)) + const nextTerminalTabs = + retainedTerminalTabs.length + mirroredTerminalTabEntries.length > 0 + ? [...retainedTerminalTabs, ...mirroredTerminalTabEntries] + : null + const mirroredTerminalIds = new Set(mirroredTerminalTabEntries.map((tab) => tab.id)) + const removedTerminalIds = new Set( + currentTerminalTabs.filter((tab) => !retainedTerminalIds.has(tab.id)).map((tab) => tab.id) + ) + const removedTerminalResourceIds = [...removedTerminalIds].filter( + (tabId) => !mirroredTerminalIds.has(tabId) + ) + for (const provisionalTabId of exactProvisionalHandoffs) { + clearWebAgentSessionHandoff({ environmentId, worktreeId, provisionalTabId }) + } + + return { + state, + rawSnapshot, + snapshot, + environmentId, + worktreeId, + now, + batchContext, + options, + focusIntent, + matchingFocusIntentTab, + callerFocusIntentTab, + navigationIntentTab, + honorSnapshotActiveFocus, + currentTerminalTabs, + existingTerminalById, + reconcilesNonAgentTabs, + terminalSurfaceTabs, + readyTerminalTabs, + nextRemotePtyIds, + nextMirroredTerminalIds, + provisionalHandoffHostTabIds, + exactProvisionalHandoffs, + retainedTerminalTabs, + mirroredTerminalTabs, + mirroredTerminalTabEntries, + nextTerminalTabs, + mirroredTerminalIds, + removedTerminalIds, + removedTerminalResourceIds + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts new file mode 100644 index 00000000000..6263d9d8467 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts @@ -0,0 +1,227 @@ +import { buildWebSessionExistingTabIndex } from '../web-session-existing-tab-index' +import { chooseTargetGroupId, buildHostGroupIdByTabId } from './layout-helpers' +import { + buildMirroredBrowserTabs, + browserWorkspaceHasRemoteEnvironmentPage, + browserWorkspaceHasClientHostedEnvironmentPage +} from './browser-helpers' +import { buildMirroredEditorTabs } from './tab-builders' +import { buildMirroredAgentTabs, isReadyBrowserTab, isReadyEditorTab } from './terminal-surfaces' +import { hostSnapshotAffirmsClientHostedPages } from '../host-session-snapshot-authority' +import type { prepareWebSessionTabsSnapshotBase } from './apply-preparation-base' +import type { OpenFile } from '../../store/slices/editor' +import { + advanceWebSessionOpenFilesIndex, + firstOpenFileByIdForWorktree, + sameOpenFiles, + webSessionOpenFilesForWorktree +} from './state-equality-files' + +export function prepareWebSessionTabsSnapshotBrowser( + base: ReturnType +) { + const { + state, + snapshot, + environmentId, + worktreeId, + now, + mirroredTerminalTabEntries, + mirroredTerminalIds, + removedTerminalIds, + reconcilesNonAgentTabs, + batchContext + } = base + const targetGroupId = chooseTargetGroupId(state, snapshot) + const hostGroupIdByTabId = buildHostGroupIdByTabId(snapshot.tabGroups) + const currentUnifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] + const existingTabIndex = buildWebSessionExistingTabIndex({ unifiedTabs: currentUnifiedTabs }) + const readyBrowserTabs = reconcilesNonAgentTabs ? snapshot.tabs.filter(isReadyBrowserTab) : [] + const nextRemoteBrowserPageIds = new Set(readyBrowserTabs.map((tab) => tab.browserPageId)) + const mirroredBrowserTabs = buildMirroredBrowserTabs( + snapshot, + environmentId, + state, + hostGroupIdByTabId, + targetGroupId, + mirroredTerminalTabEntries.length, + now + ) + const mirroredBrowserWorkspaceIds = new Set( + mirroredBrowserTabs.map((entry) => entry.workspace.id) + ) + const currentBrowserTabs = state.browserTabsByWorktree[worktreeId] ?? [] + const removedBrowserWorkspaceIds = new Set( + (reconcilesNonAgentTabs ? currentBrowserTabs : []) + .filter((tab) => { + if (mirroredBrowserWorkspaceIds.has(tab.id)) { + return true + } + if (!browserWorkspaceHasRemoteEnvironmentPage(state, tab, environmentId)) { + return false + } + // Why: a staged tab holds a handle before its create RPC answers, and a restored tab holds + // one rebuilt from disk before the relaunched host has republished the page. Neither + // handle is evidence the host ever saw this snapshot's worktree, so absence proves + // nothing — culling here would erase an optimistic tab mid-create or a restored tab a + // recovering host is still about to hand back. + if ( + (state.browserPagesByWorkspace[tab.id] ?? []).some((page) => { + const handle = state.remoteBrowserPageHandlesByPageId[page.id] + return handle?.staged === true || handle?.restoredFromSession === true + }) + ) { + return false + } + // Why: a runtime with nothing published for this worktree, or one that has restarted and not + // yet taken its client-hosted pages back, answers with a frame that looks exactly like + // "everything was closed". A page this desktop is still hosting outlives the runtime + // process, so its own guest is the better evidence — hold the row and let adoption publish + // it, rather than deleting a tab that is still rendering. The unreconciled flag is + // host-bounded; the unpublished-worktree frame is not, and its hold lasts until the runtime + // publishes that worktree at all. + if ( + !hostSnapshotAffirmsClientHostedPages(snapshot) && + browserWorkspaceHasClientHostedEnvironmentPage(state, tab, environmentId) + ) { + return false + } + return !(state.browserPagesByWorkspace[tab.id] ?? []).some((page) => { + const handle = state.remoteBrowserPageHandlesByPageId[page.id] + return ( + handle?.environmentId === environmentId && + nextRemoteBrowserPageIds.has(handle.remotePageId) + ) + }) + }) + .map((tab) => tab.id) + ) + const retainedBrowserTabs = currentBrowserTabs.filter( + (tab) => !removedBrowserWorkspaceIds.has(tab.id) + ) + const nextBrowserTabs = + retainedBrowserTabs.length + mirroredBrowserTabs.length > 0 + ? [...retainedBrowserTabs, ...mirroredBrowserTabs.map((entry) => entry.workspace)] + : null + const readyEditorTabs = reconcilesNonAgentTabs ? snapshot.tabs.filter(isReadyEditorTab) : [] + const worktreeOpenFiles = webSessionOpenFilesForWorktree(state, worktreeId, batchContext) + const mirroredEditorTabs = buildMirroredEditorTabs( + snapshot, + environmentId, + firstOpenFileByIdForWorktree(worktreeOpenFiles), + existingTabIndex, + hostGroupIdByTabId, + targetGroupId, + mirroredTerminalTabEntries.length + mirroredBrowserTabs.length, + now + ) + const mirroredAgentTabs = buildMirroredAgentTabs( + snapshot, + hostGroupIdByTabId, + targetGroupId, + mirroredTerminalTabEntries.length + mirroredBrowserTabs.length + mirroredEditorTabs.length, + currentUnifiedTabs, + now + ) + const mirroredEditorFileIds = new Set(mirroredEditorTabs.map((entry) => entry.file.id)) + const mirroredEditorHostTabIds = new Set(mirroredEditorTabs.map((entry) => entry.hostTabId)) + const removedEditorFileIds = new Set( + (reconcilesNonAgentTabs ? worktreeOpenFiles : []) + .filter( + (file) => + file.runtimeEnvironmentId === environmentId && + (file.mode === 'edit' || file.mode === 'markdown-preview') && + // Why: only cull host-mirrored tabs; locally opened files have no host counterpart, so their omission isn't a close signal. + file.mirroredFromRuntimeSession === true && + !mirroredEditorFileIds.has(file.id) + ) + .map((file) => file.id) + ) + const isReplacedOpenFile = (file: OpenFile): boolean => + file.runtimeEnvironmentId === environmentId && + (removedEditorFileIds.has(file.id) || mirroredEditorFileIds.has(file.id)) + const replacedOpenFileCount = worktreeOpenFiles.filter(isReplacedOpenFile).length + // Why: both consumers below ask only about this worktree, so the surviving ids answer + // them in worktree scope instead of walking every open file in the app. + const nextWorktreeOpenFileIds = new Set( + worktreeOpenFiles.filter((file) => !isReplacedOpenFile(file)).map((file) => file.id) + ) + for (const fileId of mirroredEditorFileIds) { + nextWorktreeOpenFileIds.add(fileId) + } + const mirroredOpenFiles = mirroredEditorTabs.map((entry) => entry.file) + const nextOpenFiles = (() => { + // Why: with nothing to drop or mirror, rebuilding reproduces the array exactly, so + // skip the global rebuild the equality check below would have thrown away anyway. + if (replacedOpenFileCount === 0 && mirroredOpenFiles.length === 0) { + return state.openFiles + } + const retained = state.openFiles.filter( + (file) => + !( + file.worktreeId === worktreeId && + file.runtimeEnvironmentId === environmentId && + (removedEditorFileIds.has(file.id) || mirroredEditorFileIds.has(file.id)) + ) + ) + const next = [...retained, ...mirroredOpenFiles] + return sameOpenFiles(state.openFiles, next) ? state.openFiles : next + })() + advanceWebSessionOpenFilesIndex(batchContext, nextOpenFiles, worktreeId) + const retainedUnifiedTabs = currentUnifiedTabs.filter((tab) => { + if (tab.contentType === 'agent-session') { + return false + } + if (tab.contentType === 'browser') { + return ( + !removedBrowserWorkspaceIds.has(tab.entityId) && + !mirroredBrowserWorkspaceIds.has(tab.entityId) + ) + } + if (tab.contentType === 'editor') { + return ( + !removedEditorFileIds.has(tab.entityId) && + !mirroredEditorFileIds.has(tab.entityId) && + !mirroredEditorHostTabIds.has(tab.id) + ) + } + if (tab.contentType !== 'terminal') { + return true + } + if (removedTerminalIds.has(tab.entityId) || removedTerminalIds.has(tab.id)) { + return false + } + return !mirroredTerminalIds.has(tab.entityId) && !mirroredTerminalIds.has(tab.id) + }) + const existingViewModeByTabId = new Map( + currentUnifiedTabs + .filter((tab) => tab.contentType === 'terminal' && tab.viewMode) + .map((tab) => [tab.id, tab.viewMode] as const) + ) + return { + ...base, + targetGroupId, + hostGroupIdByTabId, + currentUnifiedTabs, + existingTabIndex, + readyBrowserTabs, + nextRemoteBrowserPageIds, + mirroredBrowserTabs, + mirroredBrowserWorkspaceIds, + currentBrowserTabs, + removedBrowserWorkspaceIds, + retainedBrowserTabs, + nextBrowserTabs, + readyEditorTabs, + worktreeOpenFiles, + mirroredEditorTabs, + mirroredAgentTabs, + mirroredEditorFileIds, + mirroredEditorHostTabIds, + removedEditorFileIds, + nextWorktreeOpenFileIds, + nextOpenFiles, + retainedUnifiedTabs, + existingViewModeByTabId + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-groups.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-groups.ts new file mode 100644 index 00000000000..db5904fe9e6 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-groups.ts @@ -0,0 +1,233 @@ +import type { TabGroup } from '../../../../shared/tab-types' +import type { prepareWebSessionTabsSnapshotUnified } from './apply-preparation-unified' +import { toWebTerminalSurfaceTabId } from '../web-runtime-session' +import { peekWebSessionBrowserPlacementGroup } from '../web-session-browser-placement' +import { peekWebSessionTerminalPlacementGroup } from '../web-session-terminal-placement' +import { isWebSessionBrowserPlacementGroupReserved } from '../web-session-browser-placement' +import { reconcileClientOwnedTabPlacement } from '../web-session-client-owned-tab-placement' +import { buildMirroredHostGroups, retainClientPlacedMirroredTabs } from './layout-groups' +import { sanitizeRecentTabIds, pushRecentTabId } from './state-equality-core' + +export function prepareWebSessionTabsSnapshotGroups( + base: ReturnType +) { + const { + state, + snapshot, + environmentId, + worktreeId, + now, + options, + terminalSurfaceTabs, + mirroredBrowserTabs, + mirroredEditorTabs, + mirroredUnifiedIds, + mirroredUnifiedTabs, + nextUnifiedTabs, + validUnifiedTabIds, + nextActiveUnifiedTabId, + retainedUnifiedTabs, + targetGroupId, + hostToLocalTabId, + provisionalHandoffHostTabIds, + existingTabIndex, + honorSnapshotActiveFocus, + intentUnifiedTabId, + reservedEmptyPreviewFallbackTabId + } = base + const currentGroups = state.groupsByWorktree[worktreeId] ?? [] + const clientGroupIdByLocalTabId = new Map( + mirroredBrowserTabs.flatMap((entry) => + entry.clientGroupId ? [[entry.unifiedTab.id, entry.clientGroupId]] : [] + ) + ) + // Why: once this worktree has client groups, placement is client-owned — snapshots may only + // append never-seen tabs, drop vanished ones, and honor explicit focus intent. Host order, + // host actives, and host layout apply only on first adoption (no client groups yet). + const clientOwnedPlacement = (() => { + // Why: a preserveLocalLayout owner keeps the local layout authoritative, so placement + // is client-owned even before any local group record exists — first adoption on an + // empty worktree must repair a rendered-leaf-without-record or materialize a rendered + // group instead of publishing the tab into a group no local leaf will ever show. + if (!nextUnifiedTabs || (currentGroups.length === 0 && !options?.preserveLocalLayout)) { + return null + } + // Why: an entity-identical replacement (provisional terminal → mirrored surface, local + // editor → host editor tab) is a rename — its position and focus must carry over. + const rekeyedTabIds = new Map() + for (const [provisionalTabId, hostTabId] of provisionalHandoffHostTabIds) { + const mirroredId = toWebTerminalSurfaceTabId(hostTabId) + if (mirroredId !== provisionalTabId) { + rekeyedTabIds.set(provisionalTabId, mirroredId) + } + } + for (const entry of mirroredEditorTabs) { + const existing = existingTabIndex.getEditorUnifiedTab(entry.file.id, entry.hostTabId) + if (existing && existing.id !== entry.unifiedTab.id) { + rekeyedTabIds.set(existing.id, entry.unifiedTab.id) + } + } + const knownGroupTabIds = new Set( + currentGroups.flatMap((group) => + group.tabOrder.map((tabId) => rekeyedTabIds.get(tabId) ?? tabId) + ) + ) + // Why: a pending record is this client's own create intent — authoritative even when the + // provisional tab was provisionally adopted elsewhere or the target group record lags its leaf. + // The entry's own clientGroupId comes first, so a group the user moved the row into after the + // create was recorded wins over the group the create asked for. + const placementMoves = mirroredBrowserTabs.flatMap((entry) => { + const recordedGroupId = peekWebSessionBrowserPlacementGroup({ + environmentId, + worktreeId, + remotePageId: entry.remotePageId + }) + if (!recordedGroupId) { + return [] + } + return [{ tabId: entry.unifiedTab.id, groupId: entry.clientGroupId ?? recordedGroupId }] + }) + for (const parentTabId of new Set(terminalSurfaceTabs.map((tab) => tab.parentTabId))) { + const recordedGroupId = peekWebSessionTerminalPlacementGroup({ + environmentId, + worktreeId, + hostTabId: parentTabId + }) + if (recordedGroupId) { + placementMoves.push({ + tabId: toWebTerminalSurfaceTabId(parentTabId), + groupId: recordedGroupId + }) + } + } + const adoptedTabs = mirroredUnifiedTabs + .filter((tab) => !knownGroupTabIds.has(tab.id)) + .map((tab) => ({ + tabId: tab.id, + groupId: clientGroupIdByLocalTabId.get(tab.id) ?? tab.groupId + })) + return reconcileClientOwnedTabPlacement({ + currentGroups, + worktreeId, + validUnifiedTabIds, + adoptedTabs, + placementMoves, + rekeyedTabIds, + intentTabId: honorSnapshotActiveFocus ? (intentUnifiedTabId ?? null) : null, + reservedEmptyGroupFallbackTabId: reservedEmptyPreviewFallbackTabId, + currentActiveGroupId: state.activeGroupIdByWorktree[worktreeId] ?? null, + currentLayout: state.layoutByWorktree[worktreeId] ?? null, + isGroupReserved: (groupId) => + isWebSessionBrowserPlacementGroupReserved({ worktreeId, groupId }) + }) + })() + const nextGroups = (() => { + if (clientOwnedPlacement) { + return clientOwnedPlacement.groups + } + if (!nextUnifiedTabs || nextUnifiedTabs.length === 0) { + return null + } + if (snapshot.tabGroups && snapshot.tabGroups.length > 0) { + return buildMirroredHostGroups({ + currentGroups, + hostGroups: snapshot.tabGroups, + hostToLocalTabId, + mirroredUnifiedIds, + nextActiveUnifiedTabId, + now, + validUnifiedTabIds, + environmentId, + worktreeId, + clientGroupIdByLocalTabId + }) + } + const strippedGroups = retainClientPlacedMirroredTabs({ + groups: currentGroups, + mirroredUnifiedIds, + validUnifiedTabIds, + clientGroupIdByLocalTabId, + nextActiveUnifiedTabId + }) + const target = strippedGroups.find((group) => group.id === targetGroupId) ?? { + id: targetGroupId, + worktreeId, + activeTabId: null, + tabOrder: [], + recentTabIds: [] + } + const targetOrder = [ + ...target.tabOrder.filter((tabId) => validUnifiedTabIds.has(tabId)), + ...mirroredUnifiedTabs + .filter((tab) => !clientGroupIdByLocalTabId.has(tab.id)) + .map((tab) => tab.id) + ] + const targetActiveTabId = + nextActiveUnifiedTabId && targetOrder.includes(nextActiveUnifiedTabId) + ? nextActiveUnifiedTabId + : target.activeTabId && targetOrder.includes(target.activeTabId) + ? target.activeTabId + : (targetOrder[0] ?? null) + const updatedTarget: TabGroup = { + ...target, + worktreeId, + tabOrder: targetOrder, + activeTabId: targetActiveTabId, + recentTabIds: targetActiveTabId + ? pushRecentTabId(sanitizeRecentTabIds(target.recentTabIds, targetOrder), targetActiveTabId) + : [] + } + const merged = strippedGroups.some((group) => group.id === targetGroupId) + ? strippedGroups.map((group) => (group.id === targetGroupId ? updatedTarget : group)) + : [...strippedGroups, updatedTarget] + return merged.filter( + (group) => + group.id === targetGroupId || + group.tabOrder.length > 0 || + isWebSessionBrowserPlacementGroupReserved({ + worktreeId, + groupId: group.id + }) + ) + })() + + const nextTabBarOrder = (() => { + const current = state.tabBarOrderByWorktree[worktreeId] ?? [] + const validTabBarIds = new Set([ + ...retainedUnifiedTabs.map((tab) => tab.id), + ...mirroredUnifiedTabs.map((tab) => tab.id) + ]) + const hostTabBarOrder = + snapshot.tabGroups?.flatMap((group) => + group.tabOrder + .map((tabId) => hostToLocalTabId.get(tabId)) + .filter((tabId): tabId is string => tabId !== undefined && validTabBarIds.has(tabId)) + ) ?? [] + const next: string[] = [] + const seen = new Set() + const push = (tabId: string): void => { + if (validTabBarIds.has(tabId) && !seen.has(tabId)) { + seen.add(tabId) + next.push(tabId) + } + } + // Why: snapshots can arrive after the client staged local browser tabs, so preserve visible order and only append new host tabs. + for (const tabId of current) { + push(tabId) + } + const hostOrMirroredOrder = + hostTabBarOrder.length > 0 ? hostTabBarOrder : mirroredUnifiedTabs.map((tab) => tab.id) + for (const tabId of hostOrMirroredOrder) { + push(tabId) + } + return next + })() + return { + ...base, + currentGroups, + clientGroupIdByLocalTabId, + clientOwnedPlacement, + nextGroups, + nextTabBarOrder + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-unified.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-unified.ts new file mode 100644 index 00000000000..888f458c314 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-unified.ts @@ -0,0 +1,278 @@ +import type { prepareWebSessionTabsSnapshotBrowser } from './apply-preparation-browser' +import { buildTerminalUnifiedTab } from './tab-builders' +import { isAgentSessionTab } from './terminal-surfaces' +import { toWebTerminalSurfaceTabId } from '../web-runtime-session' +import { + resolveWebSessionSiblingVisibleTabId, + resolveWebSessionVisibleTabId +} from '../web-session-focus-intent' +import { isWebSessionBrowserPlacementGroupReserved } from '../web-session-browser-placement' +import { buildHostToLocalTabIdMap, updateHostSessionTabIdMappings } from './layout-groups' + +export function prepareWebSessionTabsSnapshotUnified( + base: ReturnType +) { + const { + state, + snapshot, + environmentId, + worktreeId, + navigationIntentTab, + honorSnapshotActiveFocus, + terminalSurfaceTabs, + mirroredTerminalTabs, + mirroredTerminalTabEntries, + nextTerminalTabs, + mirroredBrowserTabs, + mirroredEditorTabs, + mirroredAgentTabs, + readyBrowserTabs, + readyEditorTabs, + nextBrowserTabs, + nextWorktreeOpenFileIds, + retainedUnifiedTabs, + existingViewModeByTabId, + hostGroupIdByTabId, + targetGroupId + } = base + const mirroredTerminalUnifiedTabs = mirroredTerminalTabs.map((entry) => + buildTerminalUnifiedTab( + entry.tab, + hostGroupIdByTabId.get(entry.hostTabId) ?? targetGroupId, + environmentId, + entry.tab.viewMode ?? existingViewModeByTabId.get(entry.tab.id) + ) + ) + const mirroredBrowserUnifiedTabs = mirroredBrowserTabs.map((entry) => entry.unifiedTab) + const mirroredEditorUnifiedTabs = mirroredEditorTabs.map((entry) => entry.unifiedTab) + const mirroredAgentUnifiedTabs = mirroredAgentTabs.map((entry) => entry.unifiedTab) + const mirroredUnifiedTabs = [ + ...mirroredTerminalUnifiedTabs, + ...mirroredBrowserUnifiedTabs, + ...mirroredEditorUnifiedTabs, + ...mirroredAgentUnifiedTabs + ] + const nextUnifiedTabs = + retainedUnifiedTabs.length + mirroredUnifiedTabs.length > 0 + ? [...retainedUnifiedTabs, ...mirroredUnifiedTabs] + : null + const validUnifiedTabIds = new Set(nextUnifiedTabs?.map((tab) => tab.id) ?? []) + const activeHostTerminalId = + terminalSurfaceTabs.find((tab) => tab.id === snapshot.activeTabId)?.id ?? + terminalSurfaceTabs.find((tab) => tab.isActive)?.id ?? + null + const activeHostTerminalParentId = + terminalSurfaceTabs.find((tab) => tab.id === activeHostTerminalId)?.parentTabId ?? + terminalSurfaceTabs.find((tab) => tab.isActive)?.parentTabId ?? + null + const activeMirroredTerminalId = activeHostTerminalId + ? toWebTerminalSurfaceTabId(activeHostTerminalParentId ?? activeHostTerminalId) + : null + const activeHostBrowser = + readyBrowserTabs.find((tab) => tab.id === snapshot.activeTabId) ?? + readyBrowserTabs.find((tab) => tab.isActive) ?? + null + const activeMirroredBrowser = activeHostBrowser + ? (mirroredBrowserTabs.find( + (entry) => entry.remotePageId === activeHostBrowser.browserPageId + ) ?? null) + : null + const activeMirroredBrowserTabId = activeMirroredBrowser?.unifiedTab.id ?? null + const activeMirroredBrowserWorkspaceId = activeMirroredBrowser?.workspace.id ?? null + const activeHostEditor = + readyEditorTabs.find((tab) => tab.id === snapshot.activeTabId) ?? + readyEditorTabs.find((tab) => tab.isActive) ?? + null + const activeMirroredEditor = activeHostEditor + ? (mirroredEditorTabs.find((entry) => entry.hostTabId === activeHostEditor.id) ?? null) + : null + const activeMirroredEditorFileId = activeMirroredEditor?.file.id ?? null + const activeMirroredEditorTabId = activeMirroredEditor?.unifiedTab.id ?? null + const activeHostAgent = + snapshot.tabs + .filter(isAgentSessionTab) + .find((tab) => tab.id === snapshot.activeTabId || tab.isActive) ?? null + const activeMirroredAgentTabId = activeHostAgent + ? (mirroredAgentTabs.find((entry) => entry.hostTabId === activeHostAgent.id)?.unifiedTab.id ?? + null) + : null + const intentMirroredTerminalId = + navigationIntentTab?.type === 'terminal' + ? toWebTerminalSurfaceTabId(navigationIntentTab.parentTabId) + : null + const intentMirroredBrowser = + navigationIntentTab?.type === 'browser' + ? (mirroredBrowserTabs.find( + (entry) => + entry.hostTabId === navigationIntentTab.id || + entry.remotePageId === navigationIntentTab.browserPageId + ) ?? null) + : null + const intentMirroredEditor = + navigationIntentTab?.type === 'markdown' || navigationIntentTab?.type === 'file' + ? (mirroredEditorTabs.find((entry) => entry.hostTabId === navigationIntentTab.id) ?? null) + : null + const intentMirroredAgent = + navigationIntentTab?.type === 'agent-session' + ? (mirroredAgentTabs.find((entry) => entry.hostTabId === navigationIntentTab.id) ?? null) + : null + const currentActiveTerminalStillExists = + state.activeTabIdByWorktree[worktreeId] && + (nextTerminalTabs ?? []).some((tab) => tab.id === state.activeTabIdByWorktree[worktreeId]) + ? state.activeTabIdByWorktree[worktreeId] + : null + // Why: caller intent targets the requested tab even when an older host leaves its own active tab unchanged. + const intentTerminalId = + honorSnapshotActiveFocus && navigationIntentTab?.type === 'terminal' + ? intentMirroredTerminalId + : null + const nextActiveTerminalId = + intentTerminalId ?? + currentActiveTerminalStillExists ?? + (snapshot.activeTabType === 'terminal' + ? (activeMirroredTerminalId ?? mirroredTerminalTabEntries[0]?.id) + : mirroredTerminalTabEntries[0]?.id) ?? + null + const currentActiveBrowserStillExists = + state.activeBrowserTabIdByWorktree[worktreeId] && + (nextBrowserTabs ?? []).some((tab) => tab.id === state.activeBrowserTabIdByWorktree[worktreeId]) + ? state.activeBrowserTabIdByWorktree[worktreeId] + : null + const intentBrowserWorkspaceId = + honorSnapshotActiveFocus && navigationIntentTab?.type === 'browser' + ? (intentMirroredBrowser?.workspace.id ?? null) + : null + const nextActiveBrowserWorkspaceId = + intentBrowserWorkspaceId ?? + currentActiveBrowserStillExists ?? + (snapshot.activeTabType === 'browser' + ? (activeMirroredBrowserWorkspaceId ?? mirroredBrowserTabs[0]?.workspace.id) + : mirroredBrowserTabs[0]?.workspace.id) ?? + null + const activeEditorFileIdForWorktree = state.activeFileIdByWorktree[worktreeId] + const currentActiveEditorStillExists = + activeEditorFileIdForWorktree && nextWorktreeOpenFileIds.has(activeEditorFileIdForWorktree) + ? activeEditorFileIdForWorktree + : null + const intentEditorFileId = honorSnapshotActiveFocus + ? (intentMirroredEditor?.file.id ?? null) + : null + const nextActiveEditorFileId = + intentEditorFileId ?? + currentActiveEditorStillExists ?? + (snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file' + ? (activeMirroredEditorFileId ?? mirroredEditorTabs[0]?.file.id) + : mirroredEditorTabs[0]?.file.id) ?? + null + const currentVisibleUnifiedTabId = resolveWebSessionVisibleTabId( + state, + worktreeId, + nextUnifiedTabs ?? [] + ) + const currentVisibleStructuredTabId = + currentVisibleUnifiedTabId && + nextUnifiedTabs?.find( + (tab) => tab.id === currentVisibleUnifiedTabId && tab.contentType === 'agent-session' + ) + ? currentVisibleUnifiedTabId + : null + const activeGroupId = state.activeGroupIdByWorktree[worktreeId] + // Why: Open Preview to the Side can activate an empty reserved group before the host + // browser lands. A snapshot that still has the host terminal active must not treat + // that emptiness as a terminal focus change. + const reservedEmptyPreviewFallbackTabId = + currentVisibleUnifiedTabId == null && + activeGroupId != null && + isWebSessionBrowserPlacementGroupReserved({ worktreeId, groupId: activeGroupId }) + ? resolveWebSessionSiblingVisibleTabId(state, worktreeId, nextUnifiedTabs ?? []) + : null + // Why: a client-initiated activation also drives the visible unified tab, overriding the sticky current-visible tab. + const intentUnifiedTabId = honorSnapshotActiveFocus + ? navigationIntentTab?.type === 'browser' + ? (intentMirroredBrowser?.unifiedTab.id ?? null) + : navigationIntentTab?.type === 'terminal' + ? intentTerminalId + : navigationIntentTab?.type === 'agent-session' + ? (intentMirroredAgent?.unifiedTab.id ?? null) + : navigationIntentTab?.type === 'markdown' || navigationIntentTab?.type === 'file' + ? (intentMirroredEditor?.unifiedTab.id ?? null) + : null + : null + const nextActiveUnifiedTabId = + intentUnifiedTabId ?? + currentVisibleUnifiedTabId ?? + reservedEmptyPreviewFallbackTabId ?? + (snapshot.activeTabType === 'agent-session' + ? (activeMirroredAgentTabId ?? mirroredAgentUnifiedTabs[0]?.id ?? nextActiveTerminalId) + : snapshot.activeTabType === 'browser' + ? (activeMirroredBrowserTabId ?? + mirroredBrowserTabs[0]?.unifiedTab.id ?? + state.activeTabIdByWorktree[worktreeId] ?? + nextActiveTerminalId) + : snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file' + ? (activeMirroredEditorTabId ?? + mirroredEditorTabs[0]?.unifiedTab.id ?? + state.activeTabIdByWorktree[worktreeId] ?? + nextActiveTerminalId) + : nextActiveTerminalId) + const mirroredUnifiedIds = new Set(mirroredUnifiedTabs.map((tab) => tab.id)) + const hostToLocalTabId = buildHostToLocalTabIdMap({ + terminalSurfaces: terminalSurfaceTabs, + terminalTabs: mirroredTerminalTabEntries, + browserTabs: mirroredBrowserTabs, + editorTabs: mirroredEditorTabs, + agentTabs: mirroredAgentTabs + }) + updateHostSessionTabIdMappings({ + environmentId, + worktreeId, + terminalSurfaces: terminalSurfaceTabs, + terminalTabs: mirroredTerminalTabEntries, + browserTabs: mirroredBrowserTabs, + editorTabs: mirroredEditorTabs, + agentTabs: mirroredAgentTabs + }) + + return { + ...base, + mirroredTerminalUnifiedTabs, + mirroredBrowserUnifiedTabs, + mirroredEditorUnifiedTabs, + mirroredAgentUnifiedTabs, + mirroredUnifiedTabs, + nextUnifiedTabs, + validUnifiedTabIds, + activeHostTerminalId, + activeMirroredTerminalId, + activeHostBrowser, + activeMirroredBrowser, + activeMirroredBrowserTabId, + activeMirroredBrowserWorkspaceId, + activeHostEditor, + activeMirroredEditor, + activeMirroredEditorFileId, + activeMirroredEditorTabId, + activeHostAgent, + activeMirroredAgentTabId, + intentMirroredTerminalId, + intentMirroredBrowser, + intentMirroredEditor, + intentMirroredAgent, + currentActiveTerminalStillExists, + intentTerminalId, + nextActiveTerminalId, + currentActiveBrowserStillExists, + intentBrowserWorkspaceId, + nextActiveBrowserWorkspaceId, + currentActiveEditorStillExists, + intentEditorFileId, + nextActiveEditorFileId, + currentVisibleUnifiedTabId, + currentVisibleStructuredTabId, + reservedEmptyPreviewFallbackTabId, + intentUnifiedTabId, + nextActiveUnifiedTabId, + mirroredUnifiedIds, + hostToLocalTabId + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-snapshot.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-snapshot.ts new file mode 100644 index 00000000000..41013a8f323 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-snapshot.ts @@ -0,0 +1,52 @@ +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { + WebSessionTabsBatchContext, + WebSessionTabsSnapshotApplyOptions, + WebSessionTabsSyncState +} from './state' +import { suppressE2eWebRuntimeBrowserSnapshot } from '../web-runtime-browser-creation-e2e-fault' +import { prepareWebSessionTabsSnapshotBase } from './apply-preparation-base' +import { prepareWebSessionTabsSnapshotBrowser } from './apply-preparation-browser' +import { prepareWebSessionTabsSnapshotUnified } from './apply-preparation-unified' +import { prepareWebSessionTabsSnapshotGroups } from './apply-preparation-groups' +import { applyTerminalRecordUpdates } from './apply-terminal-records' +import { applyBrowserRecordUpdates } from './apply-browser-records' +import { applyWorktreeRecordUpdates } from './apply-worktree-records' +import { applyActiveStateUpdates } from './apply-active-state' +import { buildWebSessionTabsFinalPatch } from './apply-final-patch' + +/** Reconcile one host frame through the staged terminal/browser/layout pipeline. */ +export function applyWebSessionTabsSnapshotWithContext( + state: WebSessionTabsSyncState, + rawSnapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + now = Date.now(), + batchContext?: WebSessionTabsBatchContext, + options?: WebSessionTabsSnapshotApplyOptions +): WebSessionTabsSyncState | Partial { + if ( + suppressE2eWebRuntimeBrowserSnapshot(rawSnapshot) || + rawSnapshot.worktree === FLOATING_TERMINAL_WORKTREE_ID + ) { + return state + } + const worktreeId = rawSnapshot.worktree + const base = prepareWebSessionTabsSnapshotBase( + state, + rawSnapshot, + environmentId, + worktreeId, + now, + batchContext, + options + ) + const browser = prepareWebSessionTabsSnapshotBrowser(base) + const unified = prepareWebSessionTabsSnapshotUnified(browser) + const groups = prepareWebSessionTabsSnapshotGroups(unified) + const terminalRecords = applyTerminalRecordUpdates(groups) + const browserRecords = applyBrowserRecordUpdates(terminalRecords) + const worktreeRecords = applyWorktreeRecordUpdates(browserRecords) + const activeState = applyActiveStateUpdates(worktreeRecords) + return buildWebSessionTabsFinalPatch(activeState) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-terminal-records.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-terminal-records.ts new file mode 100644 index 00000000000..087237abb32 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-terminal-records.ts @@ -0,0 +1,116 @@ +import { terminalLayoutEqual } from '@/lib/terminal-layout-equality' +import type { prepareWebSessionTabsSnapshotGroups } from './apply-preparation-groups' +import { sameStringArray, writableWebSessionTabsRecord } from './state-equality-core' + +type TerminalRecordContext = ReturnType + +/** Reconcile PTY ownership, layouts, unread markers, and provisional startup records. */ +export function applyTerminalRecordUpdates(context: TerminalRecordContext) { + const { + state, + batchContext, + mirroredTerminalTabs, + removedTerminalResourceIds, + removedTerminalIds, + exactProvisionalHandoffs + } = context + + let nextPtyIdsByTabId = state.ptyIdsByTabId + for (const removedId of removedTerminalResourceIds) { + if (!nextPtyIdsByTabId[removedId]) { + continue + } + nextPtyIdsByTabId = + nextPtyIdsByTabId === state.ptyIdsByTabId + ? writableWebSessionTabsRecord(state, 'ptyIdsByTabId', batchContext) + : nextPtyIdsByTabId + delete nextPtyIdsByTabId[removedId] + } + for (const { tab, ptyIds } of mirroredTerminalTabs) { + if (ptyIds.length === 0) { + if (nextPtyIdsByTabId[tab.id]) { + nextPtyIdsByTabId = + nextPtyIdsByTabId === state.ptyIdsByTabId + ? writableWebSessionTabsRecord(state, 'ptyIdsByTabId', batchContext) + : nextPtyIdsByTabId + delete nextPtyIdsByTabId[tab.id] + } + continue + } + const current = nextPtyIdsByTabId[tab.id] ?? [] + if (sameStringArray(current, ptyIds)) { + continue + } + nextPtyIdsByTabId = + nextPtyIdsByTabId === state.ptyIdsByTabId + ? writableWebSessionTabsRecord(state, 'ptyIdsByTabId', batchContext) + : nextPtyIdsByTabId + nextPtyIdsByTabId[tab.id] = ptyIds + } + + let nextTerminalLayoutsByTabId = state.terminalLayoutsByTabId + for (const removedId of removedTerminalResourceIds) { + if (!nextTerminalLayoutsByTabId[removedId]) { + continue + } + nextTerminalLayoutsByTabId = + nextTerminalLayoutsByTabId === state.terminalLayoutsByTabId + ? writableWebSessionTabsRecord(state, 'terminalLayoutsByTabId', batchContext) + : nextTerminalLayoutsByTabId + delete nextTerminalLayoutsByTabId[removedId] + } + for (const { tab, layout } of mirroredTerminalTabs) { + if (terminalLayoutEqual(nextTerminalLayoutsByTabId[tab.id], layout)) { + continue + } + nextTerminalLayoutsByTabId = + nextTerminalLayoutsByTabId === state.terminalLayoutsByTabId + ? writableWebSessionTabsRecord(state, 'terminalLayoutsByTabId', batchContext) + : nextTerminalLayoutsByTabId + nextTerminalLayoutsByTabId[tab.id] = layout + } + + let nextUnreadTerminalTabs = state.unreadTerminalTabs + for (const removedId of removedTerminalIds) { + if (!nextUnreadTerminalTabs[removedId]) { + continue + } + nextUnreadTerminalTabs = + nextUnreadTerminalTabs === state.unreadTerminalTabs + ? writableWebSessionTabsRecord(state, 'unreadTerminalTabs', batchContext) + : nextUnreadTerminalTabs + delete nextUnreadTerminalTabs[removedId] + } + + const pendingStartupByTabId = state.pendingStartupByTabId ?? {} + let nextPendingStartupByTabId = pendingStartupByTabId + const automaticAgentResumeClaimsByTabId = state.automaticAgentResumeClaimsByTabId ?? {} + let nextAutomaticAgentResumeClaimsByTabId = automaticAgentResumeClaimsByTabId + for (const removedId of exactProvisionalHandoffs) { + if (nextPendingStartupByTabId[removedId]) { + nextPendingStartupByTabId = + nextPendingStartupByTabId === pendingStartupByTabId + ? writableWebSessionTabsRecord(state, 'pendingStartupByTabId', batchContext) + : nextPendingStartupByTabId + delete nextPendingStartupByTabId[removedId] + } + if (nextAutomaticAgentResumeClaimsByTabId[removedId]) { + nextAutomaticAgentResumeClaimsByTabId = + nextAutomaticAgentResumeClaimsByTabId === automaticAgentResumeClaimsByTabId + ? writableWebSessionTabsRecord(state, 'automaticAgentResumeClaimsByTabId', batchContext) + : nextAutomaticAgentResumeClaimsByTabId + delete nextAutomaticAgentResumeClaimsByTabId[removedId] + } + } + + return { + ...context, + nextPtyIdsByTabId, + nextTerminalLayoutsByTabId, + nextUnreadTerminalTabs, + pendingStartupByTabId, + nextPendingStartupByTabId, + automaticAgentResumeClaimsByTabId, + nextAutomaticAgentResumeClaimsByTabId + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-worktree-records.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-worktree-records.ts new file mode 100644 index 00000000000..dda0a14d322 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-worktree-records.ts @@ -0,0 +1,92 @@ +import type { applyBrowserRecordUpdates } from './apply-browser-records' +import { withWorktreeEntry } from './state-equality-core' +import { sameGroups, sameUnifiedTabs } from './state-equality-files' +import { sameBrowserTabs as sameBrowserWorkspaces, sameTerminalTabs } from './state-equality-tabs' + +type WorktreeRecordContext = ReturnType + +/** Commit worktree-scoped tab collections and retain client-owned placement metadata. */ +export function applyWorktreeRecordUpdates(context: WorktreeRecordContext) { + const { + state, + batchContext, + worktreeId, + nextTerminalTabs, + nextBrowserTabs, + nextUnifiedTabs, + nextGroups, + clientOwnedPlacement, + retainedUnifiedTabs, + mirroredUnifiedTabs, + mirroredTerminalIds, + nextTabBarOrder + } = context + + const nextTabsByWorktree = withWorktreeEntry( + state, + 'tabsByWorktree', + worktreeId, + nextTerminalTabs, + sameTerminalTabs, + batchContext + ) + const nextBrowserTabsByWorktree = withWorktreeEntry( + state, + 'browserTabsByWorktree', + worktreeId, + nextBrowserTabs, + sameBrowserWorkspaces, + batchContext + ) + + // Group membership is the source of truth for a client-owned placement. + const placedUnifiedTabs = (() => { + if (!clientOwnedPlacement?.groups || !nextUnifiedTabs) { + return nextUnifiedTabs + } + const groupIdByTabId = new Map( + clientOwnedPlacement.groups.flatMap((group) => + group.tabOrder.map((tabId) => [tabId, group.id] as const) + ) + ) + let changed = false + const placed = nextUnifiedTabs.map((tab) => { + const groupId = groupIdByTabId.get(tab.id) + if (!groupId || groupId === tab.groupId) { + return tab + } + changed = true + return { ...tab, groupId } + }) + return changed ? placed : nextUnifiedTabs + })() + const nextUnifiedTabsByWorktree = withWorktreeEntry( + state, + 'unifiedTabsByWorktree', + worktreeId, + placedUnifiedTabs, + sameUnifiedTabs, + batchContext + ) + const nextGroupsByWorktree = withWorktreeEntry( + state, + 'groupsByWorktree', + worktreeId, + nextGroups, + sameGroups, + batchContext + ) + + return { + ...context, + nextTabsByWorktree, + nextBrowserTabsByWorktree, + placedUnifiedTabs, + nextUnifiedTabsByWorktree, + nextGroupsByWorktree, + mirroredTerminalIds, + retainedUnifiedTabs, + mirroredUnifiedTabs, + nextTabBarOrder + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/browser-helpers.ts b/src/renderer/src/runtime/web-session-tabs-sync/browser-helpers.ts new file mode 100644 index 00000000000..8597547cc0c --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/browser-helpers.ts @@ -0,0 +1,237 @@ +import type { + RuntimeMobileSessionBrowserTab, + RuntimeMobileSessionTabsResult +} from '../../../../shared/runtime-types' +import type { BrowserPage, BrowserWorkspace } from '../../../../shared/browser-workspace-types' +import type { Tab } from '../../../../shared/tab-types' +import type { WebSessionTabsSyncState, MirroredBrowserTab } from './state' +import { readBrowserClientHostId } from '../browser-client-host-identity' +import { peekWebSessionBrowserPlacementGroup } from '../web-session-browser-placement' +import { browserPageEqual } from './state-equality-tabs' +import { collectLayoutGroupIds } from './layout-helpers' +import { buildBrowserUnifiedTab } from './tab-builders' +import { isReadyBrowserTab } from './terminal-surfaces' + +export function findBrowserWorkspaceForRemotePage( + state: WebSessionTabsSyncState, + worktreeId: string, + environmentId: string, + remotePageId: string +): { workspace: BrowserWorkspace; page: BrowserPage; unifiedTab: Tab | null } | null { + const workspaces = state.browserTabsByWorktree[worktreeId] ?? [] + for (const workspace of workspaces) { + const pages = state.browserPagesByWorkspace[workspace.id] ?? [] + for (const page of pages) { + const handle = state.remoteBrowserPageHandlesByPageId[page.id] + if (handle?.environmentId === environmentId && handle.remotePageId === remotePageId) { + return { + workspace, + page, + unifiedTab: + (state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (tab) => tab.contentType === 'browser' && tab.entityId === workspace.id + ) ?? null + } + } + } + } + return null +} + +export function browserWorkspaceHasRemoteEnvironmentPage( + state: WebSessionTabsSyncState, + workspace: BrowserWorkspace, + environmentId: string +): boolean { + return (state.browserPagesByWorkspace[workspace.id] ?? []).some( + (page) => state.remoteBrowserPageHandlesByPageId[page.id]?.environmentId === environmentId + ) +} + +/** A page of this environment whose guest runs on this desktop, so it survives the host process. */ +export function browserWorkspaceHasClientHostedEnvironmentPage( + state: WebSessionTabsSyncState, + workspace: BrowserWorkspace, + environmentId: string +): boolean { + return (state.browserPagesByWorkspace[workspace.id] ?? []).some((page) => { + const handle = state.remoteBrowserPageHandlesByPageId[page.id] + return ( + handle?.environmentId === environmentId && + (handle.placement?.kind === 'client' || + handle.stagedClientHosted === true || + handle.restoredClientHosted === true) + ) + }) +} + +/** + * The host publishes `title || url || 'Browser'` (see the runtime's browser tab projection), so a + * page that has not produced a real title yet arrives as its own url — or as the bare default. + * Neither says anything the local row does not already say better, so while the page still sits at + * the url this client gave it, the local title stands and only a real navigation replaces it. + * + * Scope: the pre-adoption/staged window, every placement the host actually drives, and the mirror + * of a page some other client hosts. Rows whose guest is ours never reach here — see + * resolveMirroredBrowserPageContent. + */ +export function resolveMirroredBrowserTitle( + tab: RuntimeMobileSessionBrowserTab, + existingPage: BrowserPage | undefined +): string { + const published = tab.title.trim() + const publishedIsHostFallback = + published === '' || published === tab.url.trim() || published === 'Browser' + if (existingPage && publishedIsHostFallback && tab.url === existingPage.url) { + return existingPage.title + } + return published || 'Browser' +} + +/** + * Whether the guest running this page is one of ours. `placement.kind === 'client'` says only that + * *some* client hosts it: a second desktop, or the web client that installs no page renderer at + * all, mirrors the same row with nothing of its own to see it with. The placement names the lease + * holder, so the comparison is exact, and it holds from the moment the host mints the placement — + * before our guest has attached — which is the window the title would otherwise flicker through. + */ +export function clientHostsMirroredBrowserPage(tab: RuntimeMobileSessionBrowserTab): boolean { + if (tab.placement?.kind !== 'client') { + return false + } + const hostClientId = readBrowserClientHostId() + return hostClientId !== null && tab.placement.browserHostClientId === hostClientId +} + +/** + * A page hosted by this client lives in our own guest webview; the host only ever learns its + * content second-hand through a fire-and-forget metadata publish, so its snapshot is a stale echo + * at best and the registry's `'Browser'`/create-time defaults at worst. Once a local row exists it + * is the truth. + * + * The url is part of the payload and not just a symptom: rewinding it to the host's create-time + * value is what breaks the staged-title hold's url-equality arm on the following snapshot. + * + * Every other row keeps taking the host values — a client that hosts no guest has no second + * opinion to offer, and freezing its mirror would strand it on whatever its first snapshot said. + */ +export function resolveMirroredBrowserPageContent( + tab: RuntimeMobileSessionBrowserTab, + existingPage: BrowserPage | undefined +): Pick { + if (clientHostsMirroredBrowserPage(tab) && existingPage) { + return { + url: existingPage.url, + title: existingPage.title, + loading: existingPage.loading, + canGoBack: existingPage.canGoBack, + canGoForward: existingPage.canGoForward + } + } + return { + url: tab.url, + title: resolveMirroredBrowserTitle(tab, existingPage), + loading: tab.loading, + canGoBack: tab.canGoBack, + canGoForward: tab.canGoForward + } +} + +export function buildMirroredBrowserTabs( + snapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + state: WebSessionTabsSyncState, + hostGroupIdByTabId: ReadonlyMap, + fallbackGroupId: string, + sortOffset: number, + now: number +): MirroredBrowserTab[] { + const renderedGroupIds = collectLayoutGroupIds(state.layoutByWorktree[snapshot.worktree]) + const clientGroupIds = new Set( + (state.groupsByWorktree[snapshot.worktree] ?? []).map((group) => group.id) + ) + return snapshot.tabs.filter(isReadyBrowserTab).map((tab, index) => { + const existing = findBrowserWorkspaceForRemotePage( + state, + snapshot.worktree, + environmentId, + tab.browserPageId + ) + const workspaceId = existing?.workspace.id ?? tab.browserWorkspaceId + const pageId = existing?.page.id ?? tab.browserPageId + const createdAt = existing?.page.createdAt ?? now + sortOffset + index + const recordedClientGroupId = peekWebSessionBrowserPlacementGroup({ + environmentId, + worktreeId: snapshot.worktree, + remotePageId: tab.browserPageId + }) + const hostGroupId = hostGroupIdByTabId.get(tab.id) ?? fallbackGroupId + const existingClientGroupId = + existing?.unifiedTab?.groupId !== hostGroupId ? existing?.unifiedTab?.groupId : undefined + // Why: a staged row was placed by this client, so wherever it sits now is the user's own + // choice — including a split made after the create recorded its intent. Rows the client never + // staged carry no such truth: a pre-response snapshot may have parked them in the host group, + // and there the record is what repairs them. + const preferredClientGroupId = + existing && state.remoteBrowserPageHandlesByPageId[existing.page.id]?.staged === true + ? (existing.unifiedTab?.groupId ?? recordedClientGroupId) + : (recordedClientGroupId ?? existingClientGroupId) + const clientGroupId = + preferredClientGroupId && + clientGroupIds.has(preferredClientGroupId) && + (renderedGroupIds.size === 0 || renderedGroupIds.has(preferredClientGroupId)) + ? preferredClientGroupId + : undefined + const groupId = clientGroupId ?? hostGroupId + const content = resolveMirroredBrowserPageContent(tab, existing?.page) + const nextPage: BrowserPage = { + id: pageId, + workspaceId, + worktreeId: snapshot.worktree, + ...content, + faviconUrl: existing?.page.faviconUrl ?? null, + // Why: a client-hosted page's load failure is observed by the local guest webview and the + // host publishes no loadError for it, so the host snapshot must not clear the local record. + loadError: + (tab.placement?.kind === 'client' ? existing?.page.loadError : tab.loadError) ?? null, + createdAt, + browserRuntimeEnvironmentId: environmentId, + viewportPresetId: existing?.page.viewportPresetId ?? null + } + // Why: reuse hinges on browserPageEqual comparing workspaceId — the removed-workspace + // page-list cleanup gates on page.workspaceId matching this entry's workspace.id. + const page = existing && browserPageEqual(existing.page, nextPage) ? existing.page : nextPage + const workspace: BrowserWorkspace = { + id: workspaceId, + worktreeId: snapshot.worktree, + label: existing?.workspace.label, + sessionProfileId: existing?.workspace.sessionProfileId ?? null, + activePageId: page.id, + pageIds: [page.id], + url: page.url, + title: page.title, + loading: page.loading, + faviconUrl: page.faviconUrl, + canGoBack: page.canGoBack, + canGoForward: page.canGoForward, + loadError: page.loadError, + createdAt + } + return { + workspace, + page, + certificateFailure: tab.certificateFailure ?? null, + remotePageId: tab.browserPageId, + ...(tab.placement ? { placement: tab.placement } : {}), + unifiedTab: buildBrowserUnifiedTab( + workspace, + tab, + existing?.unifiedTab ?? null, + groupId, + environmentId + ), + hostTabId: tab.id, + ...(clientGroupId ? { clientGroupId } : {}) + } + }) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts b/src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts new file mode 100644 index 00000000000..f04f586241e --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/global-session-events.ts @@ -0,0 +1,272 @@ +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { isRuntimeSubscriptionReplayResponse } from '../../../../shared/runtime-subscription-replay' +import { useAppStore } from '../../store' +import { recoverWebSessionTerminalOrphansBeforeApply } from '../web-session-terminal-orphan-recovery' +import { + beginWebSessionTabsSnapshotRecovery, + recordReceivedWebSessionTabsInventory, + recordReceivedWebSessionTabsSnapshot, + shouldApplyRecoveredWebSessionTabsSnapshot +} from './tracking' +import { + decideWebSessionTabsSnapshot, + WEB_SESSION_TABS_FRAME_OUTRANKED +} from './tracking-decisions' +import { acceptReplayedWebSessionTabsSnapshot } from './tracking-lifecycle' +import { applyWebSessionTabsSnapshot, applyWebSessionTabsSnapshots } from './snapshot-api' +import { + latestSessionTabsSnapshotByWorktree, + replayableSessionTabsSnapshotByWorktree +} from './state' +import { applyWebSessionTabsStorePatch } from './store-patch' +import { + hostSessionMirrorSettleForPatchlessFrame, + type HostSessionMirrorSettle +} from './mirror-settle' +import { queueAcceptedWebSessionTerminalSnapshot } from '../web-session-terminal-handle-events' +import { isHostMirroredWorktree } from './visibility-types' +import type { VisibilityResumeCoordinator } from './visibility-resume-coordinator' + +export type GlobalSessionEventArgs = { + environmentId: string + expectedEnvironmentConnectionGeneration: number + expectedEnvironmentPairingRevision?: number + expectedTrackingGeneration: number + visibilityGeneration: number + isCurrent: () => boolean + event: unknown + response: RuntimeRpcResponse + awaitingVisibilityResumeInventory: { value: boolean } + coordinator: VisibilityResumeCoordinator +} + +/** Apply one event from the all-worktrees stream, including visibility inventory fencing. */ +export function handleGlobalSessionEvent(args: GlobalSessionEventArgs): void { + const { + environmentId, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration, + visibilityGeneration, + isCurrent, + response, + awaitingVisibilityResumeInventory, + coordinator + } = args + if (!isCurrent()) { + return + } + if (response.ok === false) { + console.warn('[web-session-tabs-sync] global subscription failed:', response.error.message) + return + } + const event = args.event as + | (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }) + | { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[]; authoritative?: boolean } + | { type: 'end' } + const replayed = isRuntimeSubscriptionReplayResponse(response) + if (event.type === 'snapshots') { + const skipUnchangedResumeWork = awaitingVisibilityResumeInventory.value && !replayed + awaitingVisibilityResumeInventory.value = false + const unchanged = event.snapshots.map((snapshot) => { + const key = `${environmentId}:${snapshot.worktree}` + const freshness = getLatestFreshness(key) + return Boolean( + skipUnchangedResumeWork && + !hasReplayAllowance(key) && + freshness?.publicationEpoch === snapshot.publicationEpoch && + freshness.snapshotVersion === snapshot.snapshotVersion + ) + }) + const receivedFrames = event.snapshots.map((snapshot) => { + const frame = recordReceivedWebSessionTabsSnapshot(environmentId, snapshot) + coordinator.recordSnapshotReceipt(environmentId, snapshot, frame) + return frame + }) + const inventoryFrame = recordReceivedWebSessionTabsInventory() + const missing = coordinator.recordInventoryReceipt( + environmentId, + visibilityGeneration, + inventoryFrame, + event.snapshots + ) + const finishRecoveries = event.snapshots.map((snapshot, index) => + unchanged[index] + ? null + : beginWebSessionTabsSnapshotRecovery( + environmentId, + snapshot.worktree, + receivedFrames[index]! + ) + ) + let settleHydration: (() => void) | null = null + void Promise.all( + event.snapshots.map((snapshot, index) => + unchanged[index] + ? Promise.resolve(snapshot) + : recoverWebSessionTerminalOrphansBeforeApply( + useAppStore.getState(), + snapshot, + environmentId + ) + ) + ) + .then((recovered) => { + if (!isCurrent()) { + return + } + const applicable = recovered.flatMap((snapshot, index) => + snapshot !== null && + shouldApplyRecoveredWebSessionTabsSnapshot( + environmentId, + snapshot, + receivedFrames[index]! + ) && + coordinator.shouldApplySnapshot(environmentId, snapshot, receivedFrames[index]!) + ? [{ index, snapshot }] + : [] + ) + if (visibilityGeneration > 0 || replayed) { + for (const { index, snapshot } of applicable) { + if (!unchanged[index]) { + acceptReplayedWebSessionTabsSnapshot(environmentId, snapshot.worktree) + } + } + } + const decisions = applicable.map(({ index, snapshot }) => + unchanged[index] + ? WEB_SESSION_TABS_FRAME_OUTRANKED + : decideWebSessionTabsSnapshot(snapshot, environmentId) + ) + const freshSnapshots = applicable.flatMap(({ snapshot }, index) => + decisions[index]!.apply ? [snapshot] : [] + ) + settleHydration = applyWebSessionTabsStorePatch( + (state) => applyWebSessionTabsSnapshots(state, freshSnapshots, environmentId), + { + frames: applicable.map(({ snapshot }, index) => ({ + environmentId, + worktreeId: snapshot.worktree, + decision: decisions[index]!, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration + })), + fullInventory: { + environmentId, + authoritative: event.authoritative === true, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration, + publishedSnapshotCount: event.snapshots.filter((snapshot) => + isHostMirroredWorktree(snapshot.worktree) + ).length + } + }, + freshSnapshots + ) + const freshSet = new Set(freshSnapshots) + for (const { index, snapshot } of applicable) { + if (unchanged[index]) { + queueAcceptedWebSessionTerminalSnapshot(snapshot, environmentId) + } + if (unchanged[index] || freshSet.has(snapshot)) { + coordinator.recordSnapshot(environmentId, snapshot, receivedFrames[index]!) + } + } + coordinator.recordInventory(environmentId, visibilityGeneration, inventoryFrame, missing) + }) + .catch((error) => { + if (isCurrent()) { + console.warn('[web-session-tabs-sync] snapshot recovery failed:', error) + } + }) + .finally(() => { + for (const finishRecovery of finishRecoveries) { + finishRecovery?.() + } + if (isCurrent()) { + settleHydration?.() + } + }) + return + } + if (event.type !== 'snapshot' && event.type !== 'updated') { + return + } + const receivedFrame = recordReceivedWebSessionTabsSnapshot(environmentId, event) + coordinator.recordSnapshotReceipt(environmentId, event, receivedFrame) + const finishRecovery = beginWebSessionTabsSnapshotRecovery( + environmentId, + event.worktree, + receivedFrame + ) + let settleHydration: HostSessionMirrorSettle | null = null + void recoverWebSessionTerminalOrphansBeforeApply(useAppStore.getState(), event, environmentId) + .then((recovered) => { + if ( + !isCurrent() || + !recovered || + !shouldApplyRecoveredWebSessionTabsSnapshot(environmentId, recovered, receivedFrame) || + !coordinator.shouldApplySnapshot(environmentId, recovered, receivedFrame) + ) { + return + } + if (replayed) { + acceptReplayedWebSessionTabsSnapshot(environmentId, recovered.worktree) + } + const decision = decideWebSessionTabsSnapshot(recovered, environmentId) + if (decision.apply) { + settleHydration = applyWebSessionTabsStorePatch( + (state) => applyWebSessionTabsSnapshot(state, recovered, environmentId), + { + frames: [ + { + environmentId, + worktreeId: recovered.worktree, + decision, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration + } + ] + }, + recovered, + event.type === 'updated' && !replayed + ) + coordinator.recordSnapshot(environmentId, recovered, receivedFrame) + } else { + settleHydration = hostSessionMirrorSettleForPatchlessFrame( + decision, + environmentId, + recovered.worktree, + { + connectionGeneration: expectedEnvironmentConnectionGeneration, + pairingRevision: expectedEnvironmentPairingRevision, + trackingGeneration: expectedTrackingGeneration + } + ) + } + }) + .catch((error) => { + if (isCurrent()) { + console.warn('[web-session-tabs-sync] snapshot recovery failed:', error) + } + }) + .finally(() => { + finishRecovery() + if (isCurrent()) { + settleHydration?.() + } + }) +} + +function getLatestFreshness( + key: string +): { publicationEpoch: string; snapshotVersion: number } | undefined { + return latestSessionTabsSnapshotByWorktree.get(key) +} +function hasReplayAllowance(key: string): boolean { + return replayableSessionTabsSnapshotByWorktree.has(key) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/global-session-subscription.ts b/src/renderer/src/runtime/web-session-tabs-sync/global-session-subscription.ts new file mode 100644 index 00000000000..6cd1eb4527a --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/global-session-subscription.ts @@ -0,0 +1,211 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { getRuntimeEnvironmentRevision } from '../runtime-environment-revision' +import { clearWebSessionCloseIntentsForOwner } from '../web-session-close-intent' +import { clearWebSessionFocusIntentsForOwner } from '../web-session-focus-intent' +import { clearWebSessionReorderIntentsForOwner } from '../web-session-reorder-intent' +import { + installWindowVisibilitySubscriptionParking, + type WindowVisibilitySubscriptionSpec +} from '../window-visibility-subscription-parking' +import { shouldSyncAllRuntimeSessionTabs } from './tracking-decisions' +import { + getWebSessionTabsTrackingGeneration, + clearWebSessionTabsTrackingForEnvironment +} from './tracking-lifecycle' +import { loadInitialWebSessionTabs } from './load-initial' +import { handleGlobalSessionEvent } from './global-session-events' +import { VisibilityResumeCoordinator } from './visibility-resume-coordinator' +import type { VisibilityResumeOmission, SessionTabsStreamEvent } from './state' +import type { MirroredRuntimeEnvironment } from './visibility-resume-types' +import { WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS } from './state' + +type Ref = { current: T } + +export type GlobalSubscriptionRefs = { + activeRuntimeEnvironmentId: Ref + activeRuntimeWorktreeKey: Ref + visibilityResumeOmissions: Ref> + snapshotReceipt: Ref< + (environmentId: string, snapshot: RuntimeMobileSessionTabsResult, receivedFrame: number) => void + > + snapshotApply: Ref< + ( + environmentId: string, + snapshot: RuntimeMobileSessionTabsResult, + receivedFrame: number + ) => boolean + > + ownerRevisions: Ref> +} + +export type GlobalSubscriptionInstallArgs = { + runtimeSessionMirrorEnvironmentKey: string | null + workspaceSessionReady: boolean + refs: GlobalSubscriptionRefs +} + +function parseEnvironments(value: string | null): MirroredRuntimeEnvironment[] { + if (!value) { + return [] + } + return value + .split('\u0000') + .map((entry) => { + const [environmentId = '', , rawGeneration = '0', rawRevision = ''] = entry.split('\u0001') + return { + environmentId, + expectedEnvironmentConnectionGeneration: Number(rawGeneration), + expectedEnvironmentPairingRevision: rawRevision === '' ? undefined : Number(rawRevision) + } + }) + .filter(({ environmentId }) => environmentId.trim()) +} + +/** Install all-worktree subscriptions and their visibility-resume coordinator. */ +export function installGlobalSessionTabsSubscriptions({ + runtimeSessionMirrorEnvironmentKey, + workspaceSessionReady, + refs +}: GlobalSubscriptionInstallArgs): (() => void) | undefined { + const environments = parseEnvironments(runtimeSessionMirrorEnvironmentKey) + const ownerRevisions = new Map( + (workspaceSessionReady ? environments : []).map( + ({ environmentId, expectedEnvironmentPairingRevision }) => + [environmentId, expectedEnvironmentPairingRevision] as const + ) + ) + const previousOwnerRevisions = refs.ownerRevisions.current + for (const [environmentId, previousRevision] of previousOwnerRevisions) { + if ( + !ownerRevisions.has(environmentId) || + ownerRevisions.get(environmentId) !== previousRevision + ) { + clearWebSessionTabsTrackingForEnvironment(environmentId) + } + } + refs.ownerRevisions.current = ownerRevisions + for (const [key, omission] of refs.visibilityResumeOmissions.current) { + const previousRevision = previousOwnerRevisions.get(omission.environmentId) + if ( + !ownerRevisions.has(omission.environmentId) || + (previousOwnerRevisions.has(omission.environmentId) && + ownerRevisions.get(omission.environmentId) !== previousRevision) + ) { + refs.visibilityResumeOmissions.current.delete(key) + } + } + if (!workspaceSessionReady || environments.length === 0) { + return undefined + } + + const subscriptionSpecs: WindowVisibilitySubscriptionSpec[] = [] + const environmentIdBySpec: string[] = [] + const coordinator = new VisibilityResumeCoordinator({ + environments, + environmentIdBySubscriptionSpec: environmentIdBySpec, + omissions: refs.visibilityResumeOmissions.current, + activeRuntimeWorktreeKey: () => refs.activeRuntimeWorktreeKey.current + }) + refs.snapshotReceipt.current = coordinator.recordSnapshotReceipt.bind(coordinator) + refs.snapshotApply.current = coordinator.shouldApplySnapshot.bind(coordinator) + + for (const environment of environments) { + const { + environmentId, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision + } = environment + if ( + !shouldSyncAllRuntimeSessionTabs({ + activeRuntimeEnvironmentId: environmentId, + workspaceSessionReady + }) + ) { + continue + } + let requestedInitialLoad = false + const expectedTrackingGeneration = getWebSessionTabsTrackingGeneration(environmentId) + environmentIdBySpec.push(environmentId) + subscriptionSpecs.push({ + subscribe: (isCurrent, { visibilityGeneration }) => { + const awaitingVisibilityResumeInventory = { value: visibilityGeneration > 0 } + if (!requestedInitialLoad) { + requestedInitialLoad = true + loadInitialWebSessionTabs({ + environmentId, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration, + isCurrent + }) + } + return window.api.runtimeEnvironments.subscribe( + { + selector: environmentId, + method: 'session.tabs.subscribeAll', + params: {}, + timeoutMs: 15_000, + expectedEnvironmentPairingRevision + }, + { + onResponse: (response) => { + if ( + !isCurrent() || + getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision + ) { + return + } + handleGlobalSessionEvent({ + environmentId, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration, + visibilityGeneration, + isCurrent, + event: response.ok + ? response.result + : ({ type: 'end' } satisfies SessionTabsStreamEvent), + response, + awaitingVisibilityResumeInventory, + coordinator + }) + }, + onError: (error) => { + if (isCurrent()) { + console.warn('[web-session-tabs-sync] global subscription error:', error.message) + } + } + } + ) + }, + onSubscribeError: (error) => { + console.warn( + '[web-session-tabs-sync] failed to subscribe globally:', + error instanceof Error ? error.message : String(error) + ) + }, + onUnsubscribeError: (error) => { + console.warn('[web-session-tabs-sync] failed to unsubscribe globally:', error) + } + }) + } + + const dispose = installWindowVisibilitySubscriptionParking(subscriptionSpecs, { + getVisibilityResumePriority: (index) => + environmentIdBySpec[index] === refs.activeRuntimeEnvironmentId.current ? 0 : 1, + visibilityResumeStaggerMs: WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS, + onVisibilityResume: ({ visibilityGeneration, restartingSpecIndexes }) => + coordinator.beginVisibilityResume(visibilityGeneration, restartingSpecIndexes) + }) + return () => { + refs.snapshotReceipt.current = () => {} + refs.snapshotApply.current = () => true + dispose() + for (const { environmentId, expectedEnvironmentPairingRevision } of environments) { + const owner = { environmentId, pairingRevision: expectedEnvironmentPairingRevision } + clearWebSessionCloseIntentsForOwner(owner) + clearWebSessionFocusIntentsForOwner(owner) + clearWebSessionReorderIntentsForOwner(owner) + } + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/layout-groups.ts b/src/renderer/src/runtime/web-session-tabs-sync/layout-groups.ts new file mode 100644 index 00000000000..ad5a1a0b5ef --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/layout-groups.ts @@ -0,0 +1,217 @@ +import type { RuntimeMobileSessionTabGroup } from '../../../../shared/runtime-types' +import type { TabGroup } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { + MirroredAgentTab, + MirroredBrowserTab, + MirroredEditorTab, + TerminalSurface +} from './state' +import { toWebTerminalSurfaceTabId } from '../web-runtime-session' +import { clearHostSessionTabIdMappings, setHostSessionTabIdMapping } from './tracking-mappings' +import { isWebSessionBrowserPlacementGroupReserved } from '../web-session-browser-placement' +import { resolveWebSessionReorderedOrder } from '../web-session-reorder-intent' +import { mapHostRecentTabIds } from './layout-helpers' +import { pushRecentTabId, sanitizeRecentTabIds } from './state-equality-core' + +export function buildHostToLocalTabIdMap({ + terminalSurfaces, + terminalTabs, + browserTabs, + editorTabs, + agentTabs +}: { + terminalSurfaces: readonly TerminalSurface[] + terminalTabs: readonly TerminalTab[] + browserTabs: readonly MirroredBrowserTab[] + editorTabs: readonly MirroredEditorTab[] + agentTabs: readonly MirroredAgentTab[] +}): Map { + const hostToLocal = new Map() + const terminalIds = new Set(terminalTabs.map((tab) => tab.id)) + for (const surface of terminalSurfaces) { + const localId = toWebTerminalSurfaceTabId(surface.parentTabId) + if (terminalIds.has(localId)) { + hostToLocal.set(surface.parentTabId, localId) + hostToLocal.set(surface.id, localId) + } + } + for (const entry of browserTabs) { + hostToLocal.set(entry.hostTabId, entry.unifiedTab.id) + hostToLocal.set(entry.unifiedTab.id, entry.unifiedTab.id) + } + for (const entry of editorTabs) { + hostToLocal.set(entry.hostTabId, entry.unifiedTab.id) + } + for (const entry of agentTabs) { + hostToLocal.set(entry.hostTabId, entry.unifiedTab.id) + } + return hostToLocal +} + +export function updateHostSessionTabIdMappings(args: { + environmentId: string + worktreeId: string + terminalSurfaces: readonly TerminalSurface[] + terminalTabs: readonly TerminalTab[] + browserTabs: readonly MirroredBrowserTab[] + editorTabs: readonly MirroredEditorTab[] + agentTabs: readonly MirroredAgentTab[] +}): void { + clearHostSessionTabIdMappings(args.environmentId, args.worktreeId) + + const mirroredTerminalIds = new Set(args.terminalTabs.map((tab) => tab.id)) + for (const surface of args.terminalSurfaces) { + const localId = toWebTerminalSurfaceTabId(surface.parentTabId) + if (mirroredTerminalIds.has(localId)) { + setHostSessionTabIdMapping({ ...args, tabId: localId }, surface.parentTabId) + } + } + for (const entry of args.browserTabs) { + setHostSessionTabIdMapping({ ...args, tabId: entry.unifiedTab.id }, entry.hostTabId) + } + for (const entry of args.editorTabs) { + setHostSessionTabIdMapping({ ...args, tabId: entry.unifiedTab.id }, entry.hostTabId) + } + for (const entry of args.agentTabs) { + setHostSessionTabIdMapping({ ...args, tabId: entry.unifiedTab.id }, entry.hostTabId) + } +} + +export function retainClientPlacedMirroredTabs(args: { + groups: readonly TabGroup[] + mirroredUnifiedIds: ReadonlySet + validUnifiedTabIds: ReadonlySet + clientGroupIdByLocalTabId: ReadonlyMap + nextActiveUnifiedTabId: string | null +}): TabGroup[] { + return args.groups.map((group) => { + const retainedTabOrder = group.tabOrder.filter( + (tabId) => + args.validUnifiedTabIds.has(tabId) && + (!args.mirroredUnifiedIds.has(tabId) || + args.clientGroupIdByLocalTabId.get(tabId) === group.id) + ) + const placedTabIds = [...args.clientGroupIdByLocalTabId] + .filter( + ([tabId, groupId]) => + groupId === group.id && + args.validUnifiedTabIds.has(tabId) && + !retainedTabOrder.includes(tabId) + ) + .map(([tabId]) => tabId) + const tabOrder = [...retainedTabOrder, ...placedTabIds] + const activeTabId = + args.nextActiveUnifiedTabId && tabOrder.includes(args.nextActiveUnifiedTabId) + ? args.nextActiveUnifiedTabId + : group.activeTabId && tabOrder.includes(group.activeTabId) + ? group.activeTabId + : (tabOrder[0] ?? null) + return { + ...group, + tabOrder, + activeTabId, + recentTabIds: activeTabId + ? pushRecentTabId(sanitizeRecentTabIds(group.recentTabIds, tabOrder), activeTabId) + : [] + } + }) +} + +export function buildMirroredHostGroups({ + currentGroups, + hostGroups, + hostToLocalTabId, + mirroredUnifiedIds, + nextActiveUnifiedTabId, + now, + validUnifiedTabIds, + environmentId, + worktreeId, + clientGroupIdByLocalTabId +}: { + currentGroups: readonly TabGroup[] + hostGroups: readonly RuntimeMobileSessionTabGroup[] + hostToLocalTabId: ReadonlyMap + mirroredUnifiedIds: ReadonlySet + nextActiveUnifiedTabId: string | null + now: number + validUnifiedTabIds: ReadonlySet + environmentId: string + worktreeId: string + clientGroupIdByLocalTabId: ReadonlyMap +}): TabGroup[] | null { + const strippedGroups = retainClientPlacedMirroredTabs({ + groups: currentGroups, + mirroredUnifiedIds, + validUnifiedTabIds, + clientGroupIdByLocalTabId, + nextActiveUnifiedTabId + }) + const groupsById = new Map(strippedGroups.map((group) => [group.id, group])) + const orderedGroups: TabGroup[] = [] + const seen = new Set() + + for (const hostGroup of hostGroups) { + const existing = groupsById.get(hostGroup.id) + const localHostOrder = hostGroup.tabOrder + .map((tabId) => hostToLocalTabId.get(tabId)) + .filter( + (tabId): tabId is string => + tabId !== undefined && + validUnifiedTabIds.has(tabId) && + !clientGroupIdByLocalTabId.has(tabId) + ) + const localHostOrderIds = new Set(localHostOrder) + const hostTabOrder = [ + ...(existing?.tabOrder.filter((tabId) => !localHostOrderIds.has(tabId)) ?? []), + ...localHostOrder + ] + // Why: a pending client reorder wins over a stale pre-move host order until the host echoes the move (or membership changes). + const tabOrder = resolveWebSessionReorderedOrder( + { environmentId }, + worktreeId, + hostGroup.id, + hostTabOrder, + now + ) + if (tabOrder.length === 0) { + continue + } + const activeFromHost = + hostGroup.activeTabId !== null ? (hostToLocalTabId.get(hostGroup.activeTabId) ?? null) : null + const activeTabId = + nextActiveUnifiedTabId && tabOrder.includes(nextActiveUnifiedTabId) + ? nextActiveUnifiedTabId + : activeFromHost && tabOrder.includes(activeFromHost) + ? activeFromHost + : existing?.activeTabId && tabOrder.includes(existing.activeTabId) + ? existing.activeTabId + : (tabOrder[0] ?? null) + orderedGroups.push({ + id: hostGroup.id, + worktreeId, + tabOrder, + activeTabId, + recentTabIds: activeTabId + ? pushRecentTabId( + mapHostRecentTabIds(hostGroup.recentTabIds, hostToLocalTabId, tabOrder), + activeTabId + ) + : [] + }) + seen.add(hostGroup.id) + } + + for (const group of strippedGroups) { + if ( + !seen.has(group.id) && + (group.tabOrder.length > 0 || + isWebSessionBrowserPlacementGroupReserved({ worktreeId, groupId: group.id })) + ) { + orderedGroups.push(group) + } + } + + return orderedGroups.length > 0 ? orderedGroups : null +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/layout-helpers.ts b/src/renderer/src/runtime/web-session-tabs-sync/layout-helpers.ts new file mode 100644 index 00000000000..c2b9a526290 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/layout-helpers.ts @@ -0,0 +1,162 @@ +import type { + RuntimeMobileSessionTabGroup, + RuntimeMobileSessionTabsResult +} from '../../../../shared/runtime-types' +import type { TabGroupLayoutNode } from '../../../../shared/tab-types' +import { WEB_SESSION_GROUP_PREFIX, type WebSessionTabsSyncState } from './state' +import { sanitizeRecentTabIds } from './state-equality-core' + +export function chooseTargetGroupId( + state: WebSessionTabsSyncState, + snapshot: RuntimeMobileSessionTabsResult +): string { + const groups = state.groupsByWorktree[snapshot.worktree] ?? [] + const layoutGroupIds = collectLayoutGroupIds(state.layoutByWorktree[snapshot.worktree]) + const inRenderedLayout = (groupId: string | null | undefined): boolean => + Boolean(groupId && (layoutGroupIds.size === 0 || layoutGroupIds.has(groupId))) + const preferred = + groups.find((group) => group.id === snapshot.activeGroupId && inRenderedLayout(group.id)) ?? + groups.find( + (group) => + group.id === state.activeGroupIdByWorktree[snapshot.worktree] && inRenderedLayout(group.id) + ) ?? + groups.find((group) => inRenderedLayout(group.id)) + // Why: host snapshots can reference desktop-only group ids; the rendered group is the only safe CSS anchor for mirrored panes. + const firstRenderedLayoutGroupId = layoutGroupIds.values().next().value as string | undefined + return ( + preferred?.id ?? + firstRenderedLayoutGroupId ?? + snapshot.activeGroupId ?? + `${WEB_SESSION_GROUP_PREFIX}${snapshot.worktree}` + ) +} + +export function collectLayoutGroupIds(layout: TabGroupLayoutNode | undefined): Set { + const result = new Set() + const visit = (node: TabGroupLayoutNode | undefined): void => { + if (!node) { + return + } + if (node.type === 'leaf') { + result.add(node.groupId) + return + } + visit(node.first) + visit(node.second) + } + visit(layout) + return result +} + +export function buildHostGroupIdByTabId( + hostGroups: readonly RuntimeMobileSessionTabGroup[] | undefined +): Map { + const result = new Map() + for (const group of hostGroups ?? []) { + for (const tabId of group.tabOrder) { + result.set(tabId, group.id) + } + if (group.activeTabId) { + result.set(group.activeTabId, group.id) + } + } + return result +} + +export function pruneTabGroupLayout( + layout: TabGroupLayoutNode | null | undefined, + validGroupIds: ReadonlySet +): TabGroupLayoutNode | null { + if (!layout) { + return null + } + if (layout.type === 'leaf') { + return validGroupIds.has(layout.groupId) ? layout : null + } + const first = pruneTabGroupLayout(layout.first, validGroupIds) + const second = pruneTabGroupLayout(layout.second, validGroupIds) + if (first && second) { + return { ...layout, first, second } + } + return first ?? second +} + +export function dropTabGroupLayoutGroups( + layout: TabGroupLayoutNode | null, + excludedGroupIds: ReadonlySet +): TabGroupLayoutNode | null { + if (!layout) { + return null + } + if (layout.type === 'leaf') { + return excludedGroupIds.has(layout.groupId) ? null : layout + } + const first = dropTabGroupLayoutGroups(layout.first, excludedGroupIds) + const second = dropTabGroupLayoutGroups(layout.second, excludedGroupIds) + if (first && second) { + return { ...layout, first, second } + } + return first ?? second +} + +export function appendTabGroupLayout( + first: TabGroupLayoutNode | null, + second: TabGroupLayoutNode | null +): TabGroupLayoutNode | null { + if (!first) { + return second + } + if (!second) { + return first + } + // Why: a group already placed by `first` must not gain a second leaf — two + // leaves for one group render the same tab strip in two columns, and each + // later snapshot appends another copy. + const appended = dropTabGroupLayoutGroups(second, collectLayoutGroupIds(first)) + if (!appended) { + return first + } + return { + type: 'split', + direction: 'horizontal', + first, + second: appended + } +} + +export function tabGroupLayoutEqual( + a: TabGroupLayoutNode | null | undefined, + b: TabGroupLayoutNode | null | undefined +): boolean { + if (!a || !b) { + return !a && !b + } + if (a.type !== b.type) { + return false + } + if (a.type === 'leaf') { + return b.type === 'leaf' && a.groupId === b.groupId + } + return ( + b.type === 'split' && + a.direction === b.direction && + a.ratio === b.ratio && + tabGroupLayoutEqual(a.first, b.first) && + tabGroupLayoutEqual(a.second, b.second) + ) +} + +export function mapHostRecentTabIds( + recentTabIds: readonly string[] | undefined, + hostToLocalTabId: ReadonlyMap, + 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] + ) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts b/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts new file mode 100644 index 00000000000..27b4fcd7e8c --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/load-initial.ts @@ -0,0 +1,143 @@ +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { useAppStore } from '../../store' +import { getRuntimeEnvironmentRevision } from '../runtime-environment-revision' +import { recoverWebSessionTerminalOrphansBeforeApply } from '../web-session-terminal-orphan-recovery' +import { + beginWebSessionTabsSnapshotRecovery, + isSessionTabsListAllResult, + recordReceivedWebSessionTabsSnapshot, + shouldApplyRecoveredWebSessionTabsSnapshot +} from './tracking' +import { decideWebSessionTabsSnapshot } from './tracking-decisions' +import { applyWebSessionTabsSnapshots } from './snapshot-api' +import { applyWebSessionTabsStorePatch } from './store-patch' +import { isHostMirroredWorktree } from './visibility-types' + +export type InitialSessionTabsLoadArgs = { + environmentId: string + expectedEnvironmentConnectionGeneration: number + expectedEnvironmentPairingRevision: number | undefined + expectedTrackingGeneration: number + isCurrent: () => boolean +} + +/** Perform the one-shot inventory fetch used to seed the mirror deterministically. */ +export function loadInitialWebSessionTabs({ + environmentId, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration, + isCurrent +}: InitialSessionTabsLoadArgs): void { + let settleHydration: (() => void) | null = null + void window.api.runtimeEnvironments + .call({ + selector: environmentId, + method: 'session.tabs.listAll', + params: {}, + timeoutMs: 15_000, + expectedEnvironmentPairingRevision + }) + .then(async (response: RuntimeRpcResponse) => { + if ( + !isCurrent() || + getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision + ) { + return + } + if (response.ok === false) { + console.warn('[web-session-tabs-sync] initial listAll failed:', response.error.message) + return + } + const result = response.result + if (!isSessionTabsListAllResult(result)) { + console.warn('[web-session-tabs-sync] initial listAll returned an invalid payload') + return + } + const receivedFrames = result.snapshots.map((snapshot) => + recordReceivedWebSessionTabsSnapshot(environmentId, snapshot) + ) + const finishRecoveries = result.snapshots.map((snapshot, index) => + beginWebSessionTabsSnapshotRecovery( + environmentId, + snapshot.worktree, + receivedFrames[index]! + ) + ) + try { + const recovered = await Promise.all( + result.snapshots.map((snapshot) => + recoverWebSessionTerminalOrphansBeforeApply( + useAppStore.getState(), + snapshot, + environmentId + ) + ) + ) + if ( + !isCurrent() || + getRuntimeEnvironmentRevision(environmentId) !== expectedEnvironmentPairingRevision + ) { + return + } + const applicable = recovered.filter( + (snapshot, index): snapshot is RuntimeMobileSessionTabsResult => + snapshot !== null && + shouldApplyRecoveredWebSessionTabsSnapshot( + environmentId, + snapshot, + receivedFrames[index]! + ) + ) + const decisions = applicable.map((snapshot) => + decideWebSessionTabsSnapshot(snapshot, environmentId) + ) + const freshSnapshots = applicable.filter((_snapshot, index) => decisions[index]!.apply) + settleHydration = applyWebSessionTabsStorePatch( + (state) => applyWebSessionTabsSnapshots(state, freshSnapshots, environmentId), + { + frames: applicable.map((snapshot, index) => ({ + environmentId, + worktreeId: snapshot.worktree, + decision: decisions[index]!, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration + })), + fullInventory: { + environmentId, + authoritative: result.authoritative === true, + expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision, + expectedTrackingGeneration, + publishedSnapshotCount: result.snapshots.filter((snapshot) => + isHostMirroredWorktree(snapshot.worktree) + ).length + } + }, + applicable + ) + } finally { + for (const finishRecovery of finishRecoveries) { + finishRecovery() + } + } + }) + .catch((error) => { + if (isCurrent()) { + console.warn( + '[web-session-tabs-sync] failed to load initial session tabs:', + error instanceof Error ? error.message : String(error) + ) + } + }) + .finally(() => { + if ( + isCurrent() && + getRuntimeEnvironmentRevision(environmentId) === expectedEnvironmentPairingRevision + ) { + settleHydration?.() + } + }) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/mirror-settle.ts b/src/renderer/src/runtime/web-session-tabs-sync/mirror-settle.ts new file mode 100644 index 00000000000..8da8b63f2cd --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/mirror-settle.ts @@ -0,0 +1,155 @@ +import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' +import { probeHostLiveTerminals } from '../host-live-terminal-probe' +import { + markHostSessionMirrorHydrated, + markHostSessionMirrorWorktreeHydrated +} from '../host-session-mirror-hydration' +import { getRuntimeEnvironmentRevision } from '../runtime-environment-revision' +import { getWebSessionTabsTrackingGeneration } from './tracking-lifecycle' +import type { WebSessionTabsSnapshotDecision } from './tracking-decisions' + +/** Proof that the store patch committed and its host evidence may be settled. */ +export type HostSessionMirrorSettle = () => void + +export type HostSessionMirrorPatchFrame = { + environmentId: string + worktreeId: string + decision: WebSessionTabsSnapshotDecision + expectedEnvironmentConnectionGeneration?: number + expectedEnvironmentPairingRevision?: number + expectedTrackingGeneration?: number +} + +export type HostSessionMirrorPatchVerdict = { + frames: readonly HostSessionMirrorPatchFrame[] + fullInventory?: { + environmentId: string + publishedSnapshotCount: number + authoritative?: boolean + expectedEnvironmentConnectionGeneration?: number + expectedEnvironmentPairingRevision?: number + expectedTrackingGeneration?: number + } +} + +type HostSessionMirrorSettleFence = { + environmentId: string + connectionGeneration: number + pairingRevision?: number + trackingGeneration: number +} + +function captureHostSessionMirrorSettleFence( + environmentId: string, + expected: { + connectionGeneration?: number + pairingRevision?: number + trackingGeneration?: number + } = {} +): HostSessionMirrorSettleFence { + return { + environmentId, + connectionGeneration: + expected.connectionGeneration ?? getRuntimeEnvironmentConnectionGeneration(environmentId), + pairingRevision: expected.pairingRevision ?? getRuntimeEnvironmentRevision(environmentId), + trackingGeneration: + expected.trackingGeneration ?? getWebSessionTabsTrackingGeneration(environmentId) + } +} + +function hostSessionMirrorSettleFenceIsCurrent(fence: HostSessionMirrorSettleFence): boolean { + return ( + getRuntimeEnvironmentConnectionGeneration(fence.environmentId) === fence.connectionGeneration && + getRuntimeEnvironmentRevision(fence.environmentId) === fence.pairingRevision && + getWebSessionTabsTrackingGeneration(fence.environmentId) === fence.trackingGeneration + ) +} + +function settleEmptyHostInventoryOnlyIfHostHasNoTerminals( + fence: HostSessionMirrorSettleFence +): void { + void probeHostLiveTerminals( + fence.environmentId, + undefined, + fence.connectionGeneration, + fence.pairingRevision + ).then((verdict) => { + // A reconnect while probing invalidates the answer for this connection. + if (verdict === 'none' && hostSessionMirrorSettleFenceIsCurrent(fence)) { + markHostSessionMirrorHydrated(fence.environmentId) + } + }) +} + +export function createHostSessionMirrorSettle( + verdict: HostSessionMirrorPatchVerdict +): HostSessionMirrorSettle { + const fenceByEnvironment = new Map() + for (const frame of verdict.frames) { + fenceByEnvironment.set( + frame.environmentId, + captureHostSessionMirrorSettleFence(frame.environmentId, { + connectionGeneration: frame.expectedEnvironmentConnectionGeneration, + pairingRevision: frame.expectedEnvironmentPairingRevision, + trackingGeneration: frame.expectedTrackingGeneration + }) + ) + } + if (verdict.fullInventory) { + fenceByEnvironment.set( + verdict.fullInventory.environmentId, + captureHostSessionMirrorSettleFence(verdict.fullInventory.environmentId, { + connectionGeneration: verdict.fullInventory.expectedEnvironmentConnectionGeneration, + pairingRevision: verdict.fullInventory.expectedEnvironmentPairingRevision, + trackingGeneration: verdict.fullInventory.expectedTrackingGeneration + }) + ) + } + return () => { + const { frames, fullInventory } = verdict + const settles = frames.filter(({ decision }) => decision.settlesHostMirror) + if (fullInventory && settles.length === fullInventory.publishedSnapshotCount) { + const fence = fenceByEnvironment.get(fullInventory.environmentId) + if (!fence || !hostSessionMirrorSettleFenceIsCurrent(fence)) { + return + } + if (fullInventory.publishedSnapshotCount === 0) { + if (fullInventory.authoritative) { + markHostSessionMirrorHydrated(fullInventory.environmentId) + } else { + settleEmptyHostInventoryOnlyIfHostHasNoTerminals(fence) + } + return + } + markHostSessionMirrorHydrated(fullInventory.environmentId) + return + } + for (const { environmentId, worktreeId } of settles) { + const fence = fenceByEnvironment.get(environmentId) + if (fence && hostSessionMirrorSettleFenceIsCurrent(fence)) { + markHostSessionMirrorWorktreeHydrated(environmentId, worktreeId) + } + } + } +} + +export function hostSessionMirrorSettleForPatchlessFrame( + decision: WebSessionTabsSnapshotDecision, + environmentId: string, + worktreeId: string, + expected: { + connectionGeneration?: number + pairingRevision?: number + trackingGeneration?: number + } = {} +): HostSessionMirrorSettle | null { + if (!decision.settlesHostMirror) { + return null + } + const fence = captureHostSessionMirrorSettleFence(environmentId, expected) + return () => { + if (hostSessionMirrorSettleFenceIsCurrent(fence)) { + markHostSessionMirrorWorktreeHydrated(environmentId, worktreeId) + } + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/snapshot-api.ts b/src/renderer/src/runtime/web-session-tabs-sync/snapshot-api.ts new file mode 100644 index 00000000000..b8c6143dd26 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/snapshot-api.ts @@ -0,0 +1,125 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { + WebSessionTabsBatchContext, + WebSessionTabsSnapshotApplyOptions, + WebSessionTabsSyncState +} from './state' +import { applyWebSessionTabsSnapshotWithContext } from './apply-snapshot' +import { + decideWebSessionTabsSnapshot, + shouldApplyWebSessionTabsSnapshot, + type WebSessionTabsSnapshotDecision +} from './tracking-decisions' + +export function applyWebSessionTabsSnapshot( + state: WebSessionTabsSyncState, + snapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + now = Date.now(), + options?: WebSessionTabsSnapshotApplyOptions +): WebSessionTabsSyncState | Partial { + return applyWebSessionTabsSnapshotWithContext( + state, + snapshot, + environmentId, + now, + undefined, + options + ) +} + +export function applyWebSessionTabsSnapshots( + state: WebSessionTabsSyncState, + snapshots: readonly RuntimeMobileSessionTabsResult[], + environmentId: string, + now = Date.now() +): WebSessionTabsSyncState | Partial { + const nextState = { ...state } + const batchContext: WebSessionTabsBatchContext = { + agentPaneKeysByTabId: null, + changedRecords: new Set(), + openFilesIndex: null + } + let mergedPatch: Partial = {} + for (const snapshot of snapshots) { + const patch = applyWebSessionTabsSnapshotWithContext( + nextState, + snapshot, + environmentId, + now, + batchContext + ) + if (patch === nextState) { + continue + } + mergedPatch = { ...mergedPatch, ...patch } + Object.assign(nextState, patch) + } + const mutableMergedPatch = mergedPatch as Record + const mutableNextState = nextState as unknown as Record + for (const recordKey of batchContext.changedRecords) { + mutableMergedPatch[recordKey] = mutableNextState[recordKey] + } + return Object.keys(mergedPatch).length === 0 ? state : mergedPatch +} + +export function applyFreshWebSessionTabsSnapshot( + state: WebSessionTabsSyncState, + snapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + now = Date.now() +): WebSessionTabsSyncState | Partial { + return shouldApplyWebSessionTabsSnapshot(snapshot, environmentId) + ? applyWebSessionTabsSnapshot(state, snapshot, environmentId, now) + : state +} + +export function applyFreshWebSessionTabsSnapshots( + state: WebSessionTabsSyncState, + snapshots: readonly RuntimeMobileSessionTabsResult[], + environmentId: string, + now = Date.now() +): WebSessionTabsSyncState | Partial { + const fresh = snapshots.filter((snapshot) => + shouldApplyWebSessionTabsSnapshot(snapshot, environmentId) + ) + return fresh.length === 0 ? state : applyWebSessionTabsSnapshots(state, fresh, environmentId, now) +} + +export type WebSessionTabsSnapshotOperation = { + environmentId: string + snapshot: RuntimeMobileSessionTabsResult +} + +export type DecidedWebSessionTabsSnapshotOperation = WebSessionTabsSnapshotOperation & { + decision: WebSessionTabsSnapshotDecision +} + +export function decideWebSessionTabsSnapshotOperations( + operations: readonly WebSessionTabsSnapshotOperation[] +): DecidedWebSessionTabsSnapshotOperation[] { + return operations.map((operation) => ({ + ...operation, + decision: decideWebSessionTabsSnapshot(operation.snapshot, operation.environmentId) + })) +} + +export function applyWebSessionTabsSnapshotOperations( + state: WebSessionTabsSyncState, + operations: readonly DecidedWebSessionTabsSnapshotOperation[] +): WebSessionTabsSyncState | Partial { + let nextState = state + let mergedPatch: Partial = {} + for (const { environmentId, snapshot, decision } of operations) { + if (!decision.apply) { + continue + } + const patch = applyWebSessionTabsSnapshot(nextState, snapshot, environmentId) + if (patch === nextState) { + continue + } + mergedPatch = { ...mergedPatch, ...patch } + nextState = { ...nextState, ...patch } + } + return Object.keys(mergedPatch).length === 0 ? state : mergedPatch +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts new file mode 100644 index 00000000000..a996df378fb --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts @@ -0,0 +1,154 @@ +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' +import type { + WebSessionTabsBatchContext, + WebSessionTabsBatchRecordKey, + WebSessionTabsSyncState +} from './state' + +export function sameStringArray(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) { + return false + } + return a.every((value, index) => value === b[index]) +} + +export function sameAgentStateHistory( + a: AgentStatusEntry['stateHistory'], + b: AgentStatusEntry['stateHistory'] +): boolean { + if (a.length !== b.length) { + return false + } + return a.every( + (entry, index) => + entry.state === b[index]?.state && + entry.prompt === b[index]?.prompt && + entry.startedAt === b[index]?.startedAt && + entry.interrupted === b[index]?.interrupted + ) +} + +export function agentStatusEntryEqual( + a: AgentStatusEntry | undefined, + b: AgentStatusEntry +): boolean { + if (!a) { + return false + } + return ( + a.state === b.state && + a.workingMode === b.workingMode && + a.prompt === b.prompt && + a.updatedAt === b.updatedAt && + a.stateStartedAt === b.stateStartedAt && + a.agentType === b.agentType && + a.paneKey === b.paneKey && + a.worktreeId === b.worktreeId && + a.tabId === b.tabId && + a.terminalTitle === b.terminalTitle && + a.toolName === b.toolName && + a.toolInput === b.toolInput && + a.interactivePrompt === b.interactivePrompt && + a.lastAssistantMessage === b.lastAssistantMessage && + a.interrupted === b.interrupted && + a.promptInteractionKey === b.promptInteractionKey && + a.restoredUnconfirmed === b.restoredUnconfirmed && + agentProviderSessionsEqual(a.agentType, a.providerSession, b.providerSession) && + sameAgentStateHistory(a.stateHistory, b.stateHistory) + ) +} + +export function isAgentStatusFresh( + entry: Pick, + now: number +): boolean { + return entry.restoredUnconfirmed !== true && now - entry.updatedAt <= AGENT_STATUS_STALE_AFTER_MS +} + +export function isMirroredCommandCodeTurnBump( + existing: AgentStatusEntry | undefined, + entry: AgentStatusEntry +): boolean { + return ( + existing?.agentType === 'command-code' && + entry.agentType === 'command-code' && + existing.state === 'working' && + entry.state === 'working' && + entry.stateStartedAt > existing.stateStartedAt + ) +} + +export function sanitizeRecentTabIds(recent: string[] | undefined, tabOrder: string[]): string[] { + if (!recent || recent.length === 0) { + return [] + } + const valid = new Set(tabOrder) + const seen = new Set() + const reversed: string[] = [] + for (let i = recent.length - 1; i >= 0; i -= 1) { + const id = recent[i] + if (!valid.has(id) || seen.has(id)) { + continue + } + seen.add(id) + reversed.push(id) + } + return reversed.toReversed() +} + +export function pushRecentTabId(recent: string[] | undefined, tabId: string): string[] { + const base = recent ?? [] + if (base.length > 0 && base.at(-1) === tabId) { + return base + } + return [...base.filter((id) => id !== tabId), tabId] +} + +export function writableWebSessionTabsRecord( + state: WebSessionTabsSyncState, + recordKey: K, + batchContext?: WebSessionTabsBatchContext +): NonNullable { + const record = (state[recordKey] ?? {}) as NonNullable + if (!batchContext) { + return { ...record } as NonNullable + } + // Why: one batch owns its record copies, so later snapshots can update them without recopying every workspace. + if (batchContext.changedRecords.has(recordKey)) { + return record + } + const next = { ...record } as NonNullable + const mutableState = state as unknown as Record< + WebSessionTabsBatchRecordKey, + Record + > + mutableState[recordKey] = next as Record + batchContext.changedRecords.add(recordKey) + return next +} + +export function withWorktreeEntry( + state: WebSessionTabsSyncState, + recordKey: WebSessionTabsBatchRecordKey, + key: string, + value: T | null, + equal: (a: T | undefined, b: T | null) => boolean, + batchContext?: WebSessionTabsBatchContext, + deleteNull = true +): Record { + const record = (state[recordKey] ?? {}) as Record + if (equal(record[key], value)) { + return record + } + const next = writableWebSessionTabsRecord(state, recordKey, batchContext) as Record + if (value === null && deleteNull) { + delete next[key] + } else { + next[key] = value as T + } + return next +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-files.ts b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-files.ts new file mode 100644 index 00000000000..41054c25f07 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-files.ts @@ -0,0 +1,154 @@ +import type { Tab, TabGroup } from '../../../../shared/tab-types' +import type { OpenFile } from '../../store/slices/editor' +import type { WebSessionTabsBatchContext, WebSessionTabsSyncState } from './state' +import { sameStringArray } from './state-equality-core' + +export function openFileEqual(a: OpenFile, b: OpenFile): boolean { + return ( + a.id === b.id && + a.filePath === b.filePath && + a.relativePath === b.relativePath && + a.worktreeId === b.worktreeId && + a.language === b.language && + a.isDirty === b.isDirty && + a.runtimeEnvironmentId === b.runtimeEnvironmentId && + a.markdownPreviewSourceFileId === b.markdownPreviewSourceFileId && + a.markdownPreviewAnchor === b.markdownPreviewAnchor && + a.isPreview === b.isPreview && + a.isUntitled === b.isUntitled && + a.deleteUntouchedOnClose === b.deleteUntouchedOnClose && + a.externalMutation === b.externalMutation && + a.mirroredFromRuntimeSession === b.mirroredFromRuntimeSession && + a.mode === b.mode + ) +} + +export function sameOpenFiles(a: readonly OpenFile[], b: readonly OpenFile[]): boolean { + if (a.length !== b.length) { + return false + } + return a.every((file, index) => openFileEqual(file, b[index]!)) +} + +/** This worktree's open files — the only scope a snapshot reconciles, so a batch can + * answer from here instead of walking every open file in the app. */ +export function webSessionOpenFilesForWorktree( + state: WebSessionTabsSyncState, + worktreeId: string, + batchContext?: WebSessionTabsBatchContext +): readonly OpenFile[] { + if (!batchContext) { + return state.openFiles.filter((file) => file.worktreeId === worktreeId) + } + let index = batchContext.openFilesIndex + if (!index || index.source !== state.openFiles) { + const byWorktree = new Map() + for (const file of state.openFiles) { + const bucket = byWorktree.get(file.worktreeId) ?? [] + bucket.push(file) + byWorktree.set(file.worktreeId, bucket) + } + index = { source: state.openFiles, byWorktree } + batchContext.openFilesIndex = index + } + return index.byWorktree.get(worktreeId) ?? [] +} + +/** Retargets the index at the array a snapshot just produced, re-bucketing only the + * worktree that changed. Rebuilding it wholesale would cost the entire array again on + * every snapshot, which is the cost this index exists to avoid. */ +export function advanceWebSessionOpenFilesIndex( + batchContext: WebSessionTabsBatchContext | undefined, + nextOpenFiles: readonly OpenFile[], + worktreeId: string +): void { + const index = batchContext?.openFilesIndex + if (!index || index.source === nextOpenFiles) { + return + } + const bucket: OpenFile[] = [] + for (const file of nextOpenFiles) { + if (file.worktreeId === worktreeId) { + bucket.push(file) + } + } + index.byWorktree.set(worktreeId, bucket) + index.source = nextOpenFiles +} + +/** Mirrors `openFiles.find()` first-wins lookup, which duplicate ids make observable. */ +export function firstOpenFileByIdForWorktree(files: readonly OpenFile[]): Map { + const byId = new Map() + for (const file of files) { + if (!byId.has(file.id)) { + byId.set(file.id, file) + } + } + return byId +} + +export function tabEqual(a: Tab, b: Tab): boolean { + return ( + a.id === b.id && + a.entityId === b.entityId && + a.groupId === b.groupId && + a.worktreeId === b.worktreeId && + a.executionHostId === b.executionHostId && + a.contentType === b.contentType && + a.agentSessionAgent === b.agentSessionAgent && + a.label === b.label && + // Why: the generated label is the visible tab title; ignoring it let the + // equality bail keep a unified tab that disagreed with its terminal tab. + a.generatedLabel === b.generatedLabel && + a.aiVaultTitle?.agent === b.aiVaultTitle?.agent && + a.aiVaultTitle?.sessionId === b.aiVaultTitle?.sessionId && + a.aiVaultTitle?.title === b.aiVaultTitle?.title && + a.customLabel === b.customLabel && + a.color === b.color && + a.sortOrder === b.sortOrder && + a.createdAt === b.createdAt && + a.isPreview === b.isPreview && + a.isPinned === b.isPinned + ) +} + +export function sameUnifiedTabs(a: readonly Tab[] | undefined, b: readonly Tab[] | null): boolean { + const left = a ?? [] + const right = b ?? [] + if (left.length !== right.length) { + return false + } + return left.every((tab, index) => tabEqual(tab, right[index]!)) +} + +export function groupEqual(a: TabGroup, b: TabGroup): boolean { + return ( + a.id === b.id && + a.worktreeId === b.worktreeId && + a.activeTabId === b.activeTabId && + sameStringArray(a.tabOrder, b.tabOrder) && + sameStringArray(a.recentTabIds ?? [], b.recentTabIds ?? []) + ) +} + +export function sameGroups( + a: readonly TabGroup[] | undefined, + b: readonly TabGroup[] | null +): boolean { + const left = a ?? [] + const right = b ?? [] + if (left.length !== right.length) { + return false + } + return left.every((group, index) => groupEqual(group, right[index]!)) +} + +export function toVisibleTabType(tab: Tab): WebSessionTabsSyncState['activeTabType'] { + if (tab.contentType === 'agent-session') { + return 'agent-session' + } + if (tab.contentType === 'browser' || tab.contentType === 'terminal') { + return tab.contentType + } + return 'editor' +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-tabs.ts b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-tabs.ts new file mode 100644 index 00000000000..929c1d661ce --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-tabs.ts @@ -0,0 +1,142 @@ +import type { + BrowserCertificateFailure, + BrowserPage, + BrowserWorkspace +} from '../../../../shared/browser-workspace-types' +import type { RuntimeBrowserPlacement } from '../../../../shared/runtime-browser-placement' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { sameRuntimeBrowserPlacement } from '../../../../shared/runtime-browser-placement' +import { sameStringArray } from './state-equality-core' + +export function terminalTabEqual(a: TerminalTab, b: TerminalTab): boolean { + return ( + a.id === b.id && + a.ptyId === b.ptyId && + a.worktreeId === b.worktreeId && + a.title === b.title && + a.defaultTitle === b.defaultTitle && + a.quickCommandLabel === b.quickCommandLabel && + a.startupCwd === b.startupCwd && + a.generatedTitle === b.generatedTitle && + a.aiVaultTitle?.agent === b.aiVaultTitle?.agent && + a.aiVaultTitle?.sessionId === b.aiVaultTitle?.sessionId && + a.aiVaultTitle?.title === b.aiVaultTitle?.title && + a.customTitle === b.customTitle && + a.color === b.color && + a.sortOrder === b.sortOrder && + a.createdAt === b.createdAt && + a.generation === b.generation && + a.shellOverride === b.shellOverride && + a.launchAgent === b.launchAgent && + a.pendingActivationSpawn === b.pendingActivationSpawn + ) +} + +export function sameTerminalTabs( + a: readonly TerminalTab[] | undefined, + b: readonly TerminalTab[] | null +): boolean { + const left = a ?? [] + const right = b ?? [] + if (left.length !== right.length) { + return false + } + return left.every((tab, index) => terminalTabEqual(tab, right[index]!)) +} + +export function browserPageEqual(a: BrowserPage, b: BrowserPage): boolean { + return ( + a.id === b.id && + a.workspaceId === b.workspaceId && + a.worktreeId === b.worktreeId && + a.url === b.url && + a.title === b.title && + a.loading === b.loading && + a.faviconUrl === b.faviconUrl && + a.canGoBack === b.canGoBack && + a.canGoForward === b.canGoForward && + a.loadError?.code === b.loadError?.code && + a.loadError?.description === b.loadError?.description && + a.loadError?.validatedUrl === b.loadError?.validatedUrl && + a.createdAt === b.createdAt && + a.browserRuntimeEnvironmentId === b.browserRuntimeEnvironmentId && + a.viewportPresetId === b.viewportPresetId + ) +} + +export function optionalRuntimeBrowserPlacementsEqual( + left: RuntimeBrowserPlacement | undefined, + right: RuntimeBrowserPlacement | undefined +): boolean { + return left === undefined + ? right === undefined + : right !== undefined && sameRuntimeBrowserPlacement(left, right) +} + +export function browserCertificateFailureEqual( + a: BrowserCertificateFailure | null | undefined, + b: BrowserCertificateFailure | null | undefined +): boolean { + const left = a ?? null + const right = b ?? null + if (left === right) { + return true + } + return Boolean( + left && + right && + left.challengeId === right.challengeId && + left.browserPageId === right.browserPageId && + left.errorCode === right.errorCode && + left.error === right.error && + left.origin === right.origin && + left.displayHost === right.displayHost && + left.canProceed === right.canProceed && + left.observedAt === right.observedAt + ) +} + +export function sameBrowserPages( + a: readonly BrowserPage[] | undefined, + b: readonly BrowserPage[] | null +): boolean { + const left = a ?? [] + const right = b ?? [] + if (left.length !== right.length) { + return false + } + return left.every((page, index) => browserPageEqual(page, right[index]!)) +} + +export function browserWorkspaceEqual(a: BrowserWorkspace, b: BrowserWorkspace): boolean { + return ( + a.id === b.id && + a.worktreeId === b.worktreeId && + a.label === b.label && + a.sessionProfileId === b.sessionProfileId && + a.activePageId === b.activePageId && + sameStringArray(a.pageIds ?? [], b.pageIds ?? []) && + a.url === b.url && + a.title === b.title && + a.loading === b.loading && + a.faviconUrl === b.faviconUrl && + a.canGoBack === b.canGoBack && + a.canGoForward === b.canGoForward && + a.loadError?.code === b.loadError?.code && + a.loadError?.description === b.loadError?.description && + a.loadError?.validatedUrl === b.loadError?.validatedUrl && + a.createdAt === b.createdAt + ) +} + +export function sameBrowserTabs( + a: readonly BrowserWorkspace[] | undefined, + b: readonly BrowserWorkspace[] | null +): boolean { + const left = a ?? [] + const right = b ?? [] + if (left.length !== right.length) { + return false + } + return left.every((tab, index) => browserWorkspaceEqual(tab, right[index]!)) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state.ts b/src/renderer/src/runtime/web-session-tabs-sync/state.ts new file mode 100644 index 00000000000..48c6769bf82 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/state.ts @@ -0,0 +1,209 @@ +import type { AppState } from '../../store' +import type { + RuntimeMobileSessionAgentTab, + RuntimeMobileSessionBrowserTab, + RuntimeMobileSessionFileTab, + RuntimeMobileSessionMarkdownTab, + RuntimeMobileSessionTabGroup, + RuntimeMobileSessionTabsRemovedResult, + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionTerminalClientTab +} from '../../../../shared/runtime-types' +import type { + BrowserCertificateFailure, + BrowserPage, + BrowserWorkspace +} from '../../../../shared/browser-workspace-types' +import type { Tab } from '../../../../shared/tab-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { OpenFile } from '../../store/slices/editor' +import type { RuntimeBrowserPlacement } from '../../../../shared/runtime-browser-placement' + +export const WEB_SESSION_GROUP_PREFIX = 'web-session-tabs:' +export const WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS = 100 +export const VISIBILITY_INVENTORY_REMOVAL_EPOCH = 'visibility-inventory-removal' +export const HOST_WORKING_CLIENT_BOUNDARY_LIMIT = 512 + +export type SessionTabsStreamEvent = + | (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }) + | { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[]; authoritative?: boolean } + | { type: 'end' } + +export type SessionTabsListAllResult = { + snapshots: RuntimeMobileSessionTabsResult[] + authoritative?: boolean +} + +export type SnapshotFreshness = { + publicationEpoch: string + snapshotVersion: number +} + +export type ReceivedSessionTabsSnapshot = SnapshotFreshness & { receivedFrame: number } +export type SessionTabsRecoveryState = { pendingCount: number } +export type SessionTabsRemovalFence = { + receivedFrame: number + recoveryState: SessionTabsRecoveryState + pendingCount: number +} + +export type WebSessionTabsSnapshotApplyOptions = { + contentScope?: 'all' | 'agent-session' + preserveLocalLayout?: boolean + terminalPtyMode?: 'local' | 'remote' +} + +export type TrackedWebSessionTabsWorktree = { + worktree: string + freshness: SnapshotFreshness +} + +export type VisibilityResumeOmission = { + baseline: SnapshotFreshness + environmentId: string + inventoryReceivedFrame: number + superseded: boolean + visibilityGeneration: number +} + +export const latestSessionTabsSnapshotByWorktree = new Map() +export const replayableSessionTabsSnapshotByWorktree = new Map() +export const latestReceivedSessionTabsSnapshotByWorktree = new Map< + string, + ReceivedSessionTabsSnapshot +>() +export const latestSessionTabsRemovalFenceByWorktree = new Map() +export const sessionTabsRecoveryStateByWorktree = new Map() +export const trackedSessionTabsWorktreeIdsByEnvironment = new Map>() +export const sessionTabsEnvironmentsByWorktree = new Map>() +export const sessionTabsTrackingGenerationByEnvironment = new Map() +export const lastHostTerminalTabCountByWorktree = new Map() +export const hostSessionTabIdByLocalKey = new Map() +export const hostSessionTabMappingKeysByEnvironmentAndWorktree = new Map< + string, + Map> +>() +export const hostWorkingClientBoundaryByPaneKey = new Map< + string, + { + hostStateStartedAt: number + hostPrompt: string + clientStateStartedAt: number + stamped: boolean + } +>() + +export let receivedSessionTabsFrameSequence = 0 +export function nextReceivedSessionTabsFrame(): number { + receivedSessionTabsFrameSequence += 1 + return receivedSessionTabsFrameSequence +} +export function resetReceivedSessionTabsFrameSequence(): void { + receivedSessionTabsFrameSequence = 0 +} + +export type TerminalSurface = RuntimeMobileSessionTerminalClientTab +export type ReadyTerminalSurface = RuntimeMobileSessionTerminalClientTab & { status: 'ready' } +export type ReadyBrowserSurface = RuntimeMobileSessionBrowserTab & { browserPageId: string } +export type ReadyEditorSurface = RuntimeMobileSessionMarkdownTab | RuntimeMobileSessionFileTab + +export type MirroredAgentTab = { hostTabId: string; unifiedTab: Tab } +export type MirroredTerminalTab = { + tab: TerminalTab + hostTabId: string + ptyIds: string[] + layout: TerminalLayoutSnapshot + retainedSurfaceByPrunedLeafId?: ReadonlyMap +} +export type MirroredBrowserTab = { + workspace: BrowserWorkspace + page: BrowserPage + certificateFailure: BrowserCertificateFailure | null + remotePageId: string + placement?: RuntimeBrowserPlacement + unifiedTab: Tab + hostTabId: string + clientGroupId?: string +} +export type MirroredEditorTab = { file: OpenFile; unifiedTab: Tab; hostTabId: string } + +export type WebSessionTabsSyncState = Pick< + AppState, + | 'activeBrowserTabId' + | 'activeBrowserTabIdByWorktree' + | 'activeGroupIdByWorktree' + | 'activeFileId' + | 'activeFileIdByWorktree' + | 'activeTabId' + | 'activeTabIdByWorktree' + | 'activeTabType' + | 'activeTabTypeByWorktree' + | 'activeWorktreeId' + | 'agentStatusByPaneKey' + | 'agentStatusEpoch' + | 'browserPagesByWorkspace' + | 'browserCertificateFailuresByPageId' + | 'browserTabsByWorktree' + | 'groupsByWorktree' + | 'layoutByWorktree' + | 'openFiles' + | 'ptyIdsByTabId' + | 'remoteBrowserPageHandlesByPageId' + | 'tabBarOrderByWorktree' + | 'tabsByWorktree' + | 'terminalLayoutsByTabId' + | 'unifiedTabsByWorktree' + | 'unreadTerminalTabs' + | 'sortEpoch' +> & + Partial< + Pick< + AppState, + | 'acknowledgedAgentsByPaneKey' + | 'agentLaunchConfigByPaneKey' + | 'automaticAgentResumeClaimsByTabId' + | 'migrationUnsupportedByPtyId' + | 'paneForegroundAgentByPaneKey' + | 'pendingStartupByTabId' + | 'recentlyClosedAgentStatusTabIds' + | 'recentlyRetiredAgentStatusPaneKeys' + | 'retainedAgentsByPaneKey' + | 'retentionSuppressedPaneKeys' + > + > + +export type WebSessionTabsBatchRecordKey = + | 'activeBrowserTabIdByWorktree' + | 'activeFileIdByWorktree' + | 'activeGroupIdByWorktree' + | 'activeTabIdByWorktree' + | 'activeTabTypeByWorktree' + | 'agentStatusByPaneKey' + | 'automaticAgentResumeClaimsByTabId' + | 'browserCertificateFailuresByPageId' + | 'browserPagesByWorkspace' + | 'browserTabsByWorktree' + | 'groupsByWorktree' + | 'layoutByWorktree' + | 'pendingStartupByTabId' + | 'ptyIdsByTabId' + | 'remoteBrowserPageHandlesByPageId' + | 'tabBarOrderByWorktree' + | 'tabsByWorktree' + | 'terminalLayoutsByTabId' + | 'unifiedTabsByWorktree' + | 'unreadTerminalTabs' + +export type WebSessionOpenFilesIndex = { + source: readonly OpenFile[] + byWorktree: Map +} +export type WebSessionTabsBatchContext = { + agentPaneKeysByTabId: Map> | null + changedRecords: Set + openFilesIndex: WebSessionOpenFilesIndex | null +} + +export type AgentTab = RuntimeMobileSessionAgentTab +export type TabGroupSnapshot = RuntimeMobileSessionTabGroup +export type RemovedTabsResult = RuntimeMobileSessionTabsRemovedResult diff --git a/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts new file mode 100644 index 00000000000..db2758d4bbe --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts @@ -0,0 +1,173 @@ +import type { AppState } from '../../store' +import { useAppStore } from '../../store' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { pickParsedAgentStatusPayload } from '../../../../shared/agent-status-types' +import { normalizeTurnCompletedAtField } from '../../../../shared/agent-status-field-normalization' +import { isClientAuthoritativeAgentStatusPane } from '@/components/terminal-pane/renderer-owned-agent-status-registry' +import { observeAgentHookCompletionForNotification } from '@/hooks/agent-hook-completion-notifications' +import { remapHostAgentStatus } from './agent-status-primitives' +import { agentStatusEntryEqual } from './state-equality-core' +import { + HOST_WORKING_CLIENT_BOUNDARY_LIMIT, + hostWorkingClientBoundaryByPaneKey, + type WebSessionTabsSyncState +} from './state' +import { + createHostSessionMirrorSettle, + type HostSessionMirrorPatchVerdict, + type HostSessionMirrorSettle +} from './mirror-settle' + +/** Commit a reconciliation patch and return a receipt for its host evidence. */ +export function applyWebSessionTabsStorePatch( + buildPatch: (state: AppState) => WebSessionTabsSyncState | Partial, + hostMirrorVerdict: HostSessionMirrorPatchVerdict, + agentStatusSnapshots?: RuntimeMobileSessionTabsResult | readonly RuntimeMobileSessionTabsResult[], + allowCompletionNotification = false +): HostSessionMirrorSettle { + let mirroredAgentStatusChanged = false + // Zustand commits before notifying subscribers, so a completed producer has landed even if a subscriber throws. + let patchCommitted = false + const acceptedNotificationStatuses: { + paneKey: string + worktreeId: string + seedOnly?: true + payload: ReturnType & { + stateStartedAt: number + localStateStartedAt?: number + } + }[] = [] + + const runStorePatch = ( + state: AppState + ): WebSessionTabsSyncState | Partial => { + const patch = buildPatch(state) + mirroredAgentStatusChanged = patch !== state && Object.hasOwn(patch, 'agentStatusByPaneKey') + if (agentStatusSnapshots) { + const nextAgentStatuses = patch.agentStatusByPaneKey ?? state.agentStatusByPaneKey + const snapshots = Array.isArray(agentStatusSnapshots) + ? agentStatusSnapshots + : [agentStatusSnapshots] + for (const snapshot of snapshots) { + for (const surface of snapshot.tabs) { + if (surface.type !== 'terminal') { + continue + } + const remapped = remapHostAgentStatus(surface) + const accepted = remapped ? nextAgentStatuses[remapped.paneKey] : undefined + const turnCompletedAt = remapped + ? normalizeTurnCompletedAtField(surface.turnCompletedAt, remapped.state) + : undefined + if (remapped?.state === 'done' && turnCompletedAt === undefined) { + hostWorkingClientBoundaryByPaneKey.delete(remapped.paneKey) + } + // Client OSC owns display state; the host hook stream carries background-turn stamps. + const clientOwnedNotification = Boolean( + remapped && + isClientAuthoritativeAgentStatusPane(remapped.paneKey) && + (remapped.state === 'working' || turnCompletedAt !== undefined) + ) + if ( + !remapped || + (!clientOwnedNotification && + (!accepted || + accepted === state.agentStatusByPaneKey[remapped.paneKey] || + !agentStatusEntryEqual(accepted, remapped))) + ) { + continue + } + const notificationStatus = clientOwnedNotification ? remapped : accepted + if (!notificationStatus) { + continue + } + const currentClientStateStartedAt = clientOwnedNotification + ? state.agentStatusByPaneKey[notificationStatus.paneKey]?.stateStartedAt + : undefined + let localStateStartedAt = currentClientStateStartedAt + if ( + clientOwnedNotification && + notificationStatus.state === 'working' && + currentClientStateStartedAt !== undefined + ) { + if (turnCompletedAt === undefined) { + const retainedBoundary = hostWorkingClientBoundaryByPaneKey.get( + notificationStatus.paneKey + ) + if ( + !retainedBoundary || + retainedBoundary.hostStateStartedAt !== notificationStatus.stateStartedAt || + retainedBoundary.hostPrompt !== notificationStatus.prompt || + retainedBoundary.stamped + ) { + hostWorkingClientBoundaryByPaneKey.delete(notificationStatus.paneKey) + hostWorkingClientBoundaryByPaneKey.set(notificationStatus.paneKey, { + hostStateStartedAt: notificationStatus.stateStartedAt, + hostPrompt: notificationStatus.prompt, + clientStateStartedAt: currentClientStateStartedAt, + stamped: false + }) + if (hostWorkingClientBoundaryByPaneKey.size > HOST_WORKING_CLIENT_BOUNDARY_LIMIT) { + const oldestPaneKey = hostWorkingClientBoundaryByPaneKey.keys().next().value + if (oldestPaneKey !== undefined) { + hostWorkingClientBoundaryByPaneKey.delete(oldestPaneKey) + } + } + } + } else { + const retainedBoundary = hostWorkingClientBoundaryByPaneKey.get( + notificationStatus.paneKey + ) + if ( + retainedBoundary?.hostStateStartedAt === notificationStatus.stateStartedAt && + retainedBoundary.hostPrompt === notificationStatus.prompt + ) { + localStateStartedAt = retainedBoundary.clientStateStartedAt + retainedBoundary.stamped = true + } + } + } + if (!allowCompletionNotification && notificationStatus.state !== 'working') { + continue + } + acceptedNotificationStatuses.push({ + paneKey: notificationStatus.paneKey, + worktreeId: notificationStatus.worktreeId ?? snapshot.worktree, + ...(!allowCompletionNotification ? { seedOnly: true as const } : {}), + payload: { + ...pickParsedAgentStatusPayload({ + ...notificationStatus, + ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}) + }), + stateStartedAt: notificationStatus.stateStartedAt, + ...(clientOwnedNotification ? { localStateStartedAt } : {}) + } + }) + } + } + } + patchCommitted = true + return patch + } + + try { + useAppStore.setState(runStorePatch) + } catch (error) { + if (!patchCommitted) { + throw error + } + console.warn('[web-session-tabs-sync] a store subscriber failed after the patch landed:', error) + } + + const settleHostMirror = createHostSessionMirrorSettle(hostMirrorVerdict) + try { + if (mirroredAgentStatusChanged) { + useAppStore.getState().scheduleAgentStatusFreshness() + } + for (const status of acceptedNotificationStatuses) { + observeAgentHookCompletionForNotification(status) + } + } catch (error) { + console.warn('[web-session-tabs-sync] post-patch bookkeeping failed:', error) + } + return settleHostMirror +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tab-builders.ts b/src/renderer/src/runtime/web-session-tabs-sync/tab-builders.ts new file mode 100644 index 00000000000..94234af1276 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/tab-builders.ts @@ -0,0 +1,144 @@ +import type { + RuntimeMobileSessionBrowserTab, + RuntimeMobileSessionTabsResult +} from '../../../../shared/runtime-types' +import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types' +import type { Tab } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { OpenFile } from '../../store/slices/editor' +import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import type { ReadyEditorSurface, MirroredEditorTab } from './state' +import type { WebSessionExistingTabIndex } from '../web-session-existing-tab-index' +import { isReadyEditorTab, localEditorFileId, editorSourceFileId } from './terminal-surfaces' + +export function buildTerminalUnifiedTab( + tab: TerminalTab, + groupId: string, + environmentId: string, + // Why: viewMode is host-tracked but the client's optimistic toggle must win during the echo window; callers pass the reconciled value. + viewMode?: Tab['viewMode'] +): Tab { + return { + id: tab.id, + entityId: tab.id, + groupId, + worktreeId: tab.worktreeId, + executionHostId: toRuntimeExecutionHostId(environmentId), + contentType: 'terminal', + label: tab.title, + ...(tab.quickCommandLabel?.trim() ? { quickCommandLabel: tab.quickCommandLabel.trim() } : {}), + ...(tab.generatedTitle?.trim() ? { generatedLabel: tab.generatedTitle.trim() } : {}), + ...(tab.aiVaultTitle ? { aiVaultTitle: tab.aiVaultTitle } : {}), + customLabel: tab.customTitle, + color: tab.color, + sortOrder: tab.sortOrder, + createdAt: tab.createdAt, + isPreview: false, + isPinned: tab.isPinned === true, + ...(viewMode ? { viewMode } : {}) + } +} + +export function buildBrowserUnifiedTab( + tab: BrowserWorkspace, + hostTab: RuntimeMobileSessionBrowserTab, + existingUnifiedTab: Tab | null, + groupId: string, + environmentId: string +): Tab { + return { + id: existingUnifiedTab?.id ?? hostTab.id, + entityId: tab.id, + groupId, + worktreeId: tab.worktreeId, + executionHostId: toRuntimeExecutionHostId(environmentId), + contentType: 'browser', + label: tab.title, + customLabel: null, + color: hostTab.color !== undefined ? hostTab.color : (existingUnifiedTab?.color ?? null), + sortOrder: tab.createdAt, + createdAt: tab.createdAt, + isPreview: false, + isPinned: + hostTab.isPinned !== undefined + ? hostTab.isPinned === true + : existingUnifiedTab?.isPinned === true + } +} + +export function buildEditorUnifiedTab( + file: OpenFile, + tab: ReadyEditorSurface, + hostTabId: string, + existingUnifiedTab: Tab | null, + label: string, + groupId: string, + sortOrder: number, + createdAt: number, + environmentId: string +): Tab { + return { + id: hostTabId, + entityId: file.id, + groupId, + worktreeId: file.worktreeId, + executionHostId: toRuntimeExecutionHostId(environmentId), + contentType: 'editor', + label, + customLabel: null, + color: tab.color !== undefined ? tab.color : (existingUnifiedTab?.color ?? null), + sortOrder, + createdAt, + isPreview: false, + isPinned: + tab.isPinned !== undefined ? tab.isPinned === true : existingUnifiedTab?.isPinned === true + } +} + +export function buildMirroredEditorTabs( + snapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + worktreeOpenFileById: ReadonlyMap, + existingTabIndex: WebSessionExistingTabIndex, + hostGroupIdByTabId: ReadonlyMap, + fallbackGroupId: string, + sortOffset: number, + now: number +): MirroredEditorTab[] { + return snapshot.tabs.filter(isReadyEditorTab).map((tab, index) => { + const fileId = localEditorFileId(tab) + const existingFile = worktreeOpenFileById.get(fileId) + const existingUnifiedTab = existingTabIndex.getEditorUnifiedTab(fileId, tab.id) + const sourceFileId = editorSourceFileId(tab) + const groupId = hostGroupIdByTabId.get(tab.id) ?? fallbackGroupId + const file: OpenFile = { + ...existingFile, + id: fileId, + filePath: tab.filePath, + relativePath: tab.relativePath, + worktreeId: snapshot.worktree, + language: tab.language, + isDirty: tab.isDirty, + runtimeEnvironmentId: environmentId, + mode: tab.type === 'markdown' ? tab.mode : 'edit', + markdownPreviewSourceFileId: sourceFileId, + // Why: marks this tab host-owned so a later snapshot that omits it can cull it; locally opened tabs lack this flag and survive. + mirroredFromRuntimeSession: true + } + return { + file, + hostTabId: tab.id, + unifiedTab: buildEditorUnifiedTab( + file, + tab, + tab.id, + existingUnifiedTab, + tab.title.trim() || tab.relativePath || 'File', + groupId, + sortOffset + index, + existingUnifiedTab?.createdAt ?? now + sortOffset + index, + environmentId + ) + } + }) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts b/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts new file mode 100644 index 00000000000..8b3c3149e1b --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/terminal-build.ts @@ -0,0 +1,135 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import { normalizeTerminalLayoutPtyOwnership } from '@/components/terminal-pane/terminal-layout-pty-ownership' +import { resolvePaneAgentOwnerRecord } from '../../../../shared/pane-agent-owner' +import { normalizeCompatibleAgentTitleForOwner } from '../../../../shared/agent-title-owner' +import { toRemoteRuntimePtyId } from '../runtime-terminal-stream' +import { toWebTerminalSurfaceTabId } from '../web-runtime-session' +import type { MirroredTerminalTab, TerminalSurface, ReadyTerminalSurface } from './state' +import { chooseRemoteTerminalLayout, isTerminalSurfaceTab } from './terminal-surfaces' + +/** Constructs mirrored terminal tabs from the mobile session status payload, normalising Pi-compatible agent titles under launch ownership. */ +export function buildMirroredTerminalTabs( + snapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + existingById: ReadonlyMap, + existingLayoutsByTabId: Readonly>, + sortOffset: number, + now: number, + focusTarget?: { parentTabId: string; leafId: string }, + terminalPtyMode: 'local' | 'remote' = 'remote' +): MirroredTerminalTab[] { + const groups = new Map() + for (const tab of snapshot.tabs.filter(isTerminalSurfaceTab)) { + const group = groups.get(tab.parentTabId) ?? [] + group.push(tab) + groups.set(tab.parentTabId, group) + } + + return [...groups.entries()].map(([parentTabId, surfaces], index) => { + const localTabId = toWebTerminalSurfaceTabId(parentTabId) + const existingLayout = existingLayoutsByTabId[localTabId] + const requestedActiveLeafId = + focusTarget?.parentTabId === parentTabId ? focusTarget.leafId : undefined + const activeSurface = + (requestedActiveLeafId + ? surfaces.find((surface) => surface.leafId === requestedActiveLeafId) + : undefined) ?? + (existingLayout?.activeLeafId + ? surfaces.find((surface) => surface.leafId === existingLayout.activeLeafId) + : undefined) ?? + surfaces.find((surface) => surface.isActive) ?? + surfaces[0]! + const ptyIdForSurface = (handle: string): string => + terminalPtyMode === 'local' ? handle : toRemoteRuntimePtyId(handle, environmentId) + const ptyIdsByLeafId = Object.fromEntries( + surfaces + .filter((surface): surface is ReadyTerminalSurface => surface.status === 'ready') + .map((surface) => [surface.leafId, ptyIdForSurface(surface.terminal)]) + ) + const layout = normalizeTerminalLayoutPtyOwnership( + chooseRemoteTerminalLayout(surfaces, ptyIdsByLeafId, existingLayout, requestedActiveLeafId) + ).snapshot + const layoutPtyEntries = Object.entries(layout.ptyIdsByLeafId ?? {}) + const ptyIds = layoutPtyEntries.map(([, ptyId]) => ptyId) + let retainedSurfaceByPrunedLeafId: Map | undefined + if (layoutPtyEntries.length < Object.keys(ptyIdsByLeafId).length) { + const retainedLeafIdByPtyId = new Map( + layoutPtyEntries.map(([leafId, ptyId]) => [ptyId, leafId]) + ) + const surfaceByLeafId = new Map(surfaces.map((surface) => [surface.leafId, surface])) + retainedSurfaceByPrunedLeafId = new Map() + for (const [leafId, ptyId] of Object.entries(ptyIdsByLeafId)) { + const retainedLeafId = retainedLeafIdByPtyId.get(ptyId) + if (retainedLeafId && retainedLeafId !== leafId) { + const retainedSurface = surfaceByLeafId.get(retainedLeafId) + if (retainedSurface) { + retainedSurfaceByPrunedLeafId.set(leafId, retainedSurface) + } + } + } + } + const launchAgent = + activeSurface.launchAgent ?? surfaces.find((surface) => surface.launchAgent)?.launchAgent + const ownerRecord = resolvePaneAgentOwnerRecord({ + launchAgent, + hookAgent: activeSurface.agentStatus?.agentType, + siblingHookAgent: surfaces.find((surface) => surface.agentStatus?.agentType)?.agentStatus + ?.agentType + }) + const title = normalizeCompatibleAgentTitleForOwner( + activeSurface.title.trim() || surfaces[0]?.title.trim() || 'Terminal', + ownerRecord?.agent, + { ownerIsLaunch: ownerRecord?.ownerIsLaunch === true } + ) + const existing = + existingById.get(localTabId) ?? + existingById.get(parentTabId) ?? + surfaces + .map((surface) => existingById.get(toWebTerminalSurfaceTabId(surface.id))) + .find((tab): tab is TerminalTab => Boolean(tab)) + const quickCommandLabel = + activeSurface.quickCommandLabel?.trim() || + surfaces.find((surface) => surface.quickCommandLabel?.trim())?.quickCommandLabel?.trim() || + existing?.quickCommandLabel?.trim() + // Why: startupCwd is host-owned launch metadata; once the host omits it, don't resurrect stale subdirectory intent. + const startupCwd = + activeSurface.startupCwd || surfaces.find((surface) => surface.startupCwd)?.startupCwd + // Why: color/pin echo back through host snapshots, so prefer the client's own record and fall back to host only without a prior tab (avoids echo-window reverts). + const hostColorSurface = surfaces.find((surface) => surface.color != null) + const color = existing ? (existing.color ?? null) : (hostColorSurface?.color ?? null) + const isPinned = existing + ? existing.isPinned === true + : surfaces.some((surface) => surface.isPinned) + // Why: viewMode echoes back through host snapshots, so prefer the client's record during the echo window and adopt the host value only without a prior tab. + const hostViewModeSurface = surfaces.find((surface) => surface.viewMode) + const viewMode = existing ? existing.viewMode : hostViewModeSurface?.viewMode + return { + tab: { + id: localTabId, + ptyId: ptyIdsByLeafId[activeSurface.leafId] ?? null, + worktreeId: snapshot.worktree, + title, + defaultTitle: existing?.defaultTitle ?? title, + // Why: the host transport carries no generated title, so rebuilding the tab + // without this dropped the client's agent-prompt label on every snapshot. + ...(existing?.generatedTitle ? { generatedTitle: existing.generatedTitle } : {}), + ...(existing?.aiVaultTitle ? { aiVaultTitle: existing.aiVaultTitle } : {}), + ...(quickCommandLabel ? { quickCommandLabel } : {}), + ...(startupCwd ? { startupCwd } : {}), + customTitle: existing?.customTitle ?? null, + color, + isPinned, + ...(viewMode ? { viewMode } : {}), + sortOrder: sortOffset + index, + createdAt: existing?.createdAt ?? now + index, + // Why: launchAgent is host-owned lifecycle metadata; once the host omits it, don't resurrect stale startup intent. + ...(launchAgent ? { launchAgent } : {}) + }, + hostTabId: parentTabId, + ptyIds, + layout, + ...(retainedSurfaceByPrunedLeafId ? { retainedSurfaceByPrunedLeafId } : {}) + } + }) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts b/src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts new file mode 100644 index 00000000000..7480306ed5b --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts @@ -0,0 +1,197 @@ +import type { + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionAgentTab +} from '../../../../shared/runtime-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import { sanitizeTerminalLayoutPaneTitlesForLabels } from '@/lib/terminal-pane-title-sanitization' +import { resolveTerminalLayoutRoot } from '../remote-terminal-layout-resolution' +import { getRemoteRuntimePtyEnvironmentId } from '../runtime-terminal-stream' +import { + HOST_TERMINAL_SURFACE_SEPARATOR, + WEB_TERMINAL_SURFACE_TAB_PREFIX, + toWebTerminalSurfaceTabId +} from '../web-runtime-session' +import type { + ReadyBrowserSurface, + ReadyEditorSurface, + ReadyTerminalSurface, + TerminalSurface, + MirroredAgentTab +} from './state' +import type { Tab } from '../../../../shared/tab-types' +import { structuredAgentSessionTabId } from '../../../../shared/structured-agent-session-projection' + +export function isReadyTerminalTab( + tab: RuntimeMobileSessionTabsResult['tabs'][number] +): tab is ReadyTerminalSurface { + return tab.type === 'terminal' && tab.status === 'ready' && tab.terminal.trim().length > 0 +} + +export function isTerminalSurfaceTab( + tab: RuntimeMobileSessionTabsResult['tabs'][number] +): tab is TerminalSurface { + return tab.type === 'terminal' +} + +export function isReadyBrowserTab( + tab: RuntimeMobileSessionTabsResult['tabs'][number] +): tab is ReadyBrowserSurface { + return tab.type === 'browser' && typeof tab.browserPageId === 'string' && tab.browserPageId !== '' +} + +export function isReadyEditorTab( + tab: RuntimeMobileSessionTabsResult['tabs'][number] +): tab is ReadyEditorSurface { + return tab.type === 'markdown' || tab.type === 'file' +} + +export function isAgentSessionTab( + tab: RuntimeMobileSessionTabsResult['tabs'][number] +): tab is RuntimeMobileSessionAgentTab { + return tab.type === 'agent-session' +} + +export function buildMirroredAgentTabs( + snapshot: RuntimeMobileSessionTabsResult, + hostGroupIdByTabId: ReadonlyMap, + fallbackGroupId: string, + sortOffset: number, + currentUnifiedTabs: readonly Tab[], + now: number +): MirroredAgentTab[] { + return snapshot.tabs.filter(isAgentSessionTab).map((tab, index) => { + const localId = structuredAgentSessionTabId(tab.sessionId) + const existing = currentUnifiedTabs.find( + (candidate) => candidate.contentType === 'agent-session' && candidate.id === localId + ) + return { + hostTabId: tab.id, + unifiedTab: { + id: localId, + entityId: tab.sessionId, + groupId: hostGroupIdByTabId.get(tab.id) ?? fallbackGroupId, + worktreeId: snapshot.worktree, + contentType: 'agent-session', + agentSessionAgent: tab.agent, + label: tab.title.trim() || 'Codex Chat', + customLabel: null, + color: tab.color !== undefined ? tab.color : (existing?.color ?? null), + sortOrder: sortOffset + index, + createdAt: existing?.createdAt ?? now + sortOffset + index, + isPinned: tab.isPinned !== undefined ? tab.isPinned : existing?.isPinned === true + } + } + }) +} + +export function localEditorFileId(tab: ReadyEditorSurface): string { + if (tab.type === 'markdown' && tab.mode === 'markdown-preview') { + return `markdown-preview::${tab.sourceFilePath}` + } + return tab.filePath +} + +export function editorSourceFileId(tab: ReadyEditorSurface): string | undefined { + return tab.type === 'markdown' && tab.mode === 'markdown-preview' ? tab.sourceFilePath : undefined +} + +export function isRuntimeTerminalTabForEnvironment( + tab: TerminalTab, + environmentId: string +): boolean { + if (!tab.ptyId) { + return false + } + return getRemoteRuntimePtyEnvironmentId(tab.ptyId) === environmentId +} + +export function isMirroredTerminalSurfaceId(tabId: string): boolean { + return ( + tabId.startsWith(WEB_TERMINAL_SURFACE_TAB_PREFIX) || + tabId.includes(HOST_TERMINAL_SURFACE_SEPARATOR) + ) +} + +export function chooseRemoteTerminalLayout( + surfaces: readonly TerminalSurface[], + ptyIdsByLeafId: Record, + existingLayout?: TerminalLayoutSnapshot, + requestedActiveLeafId?: string +): TerminalLayoutSnapshot { + const leafIds = surfaces.map((surface) => surface.leafId) + const knownLeafIds = new Set(leafIds) + const parentLayoutSource = surfaces.find((surface) => surface.parentLayout) + const parentLayout = parentLayoutSource?.parentLayout + ? sanitizeTerminalLayoutPaneTitlesForLabels(parentLayoutSource.parentLayout, [ + parentLayoutSource.title + ]) + : undefined + const activeLeafId = + (requestedActiveLeafId && knownLeafIds.has(requestedActiveLeafId) + ? requestedActiveLeafId + : null) ?? + // Why: host title/status snapshots may still mark an agent pane active after this client selected a different split pane. + (existingLayout?.activeLeafId && knownLeafIds.has(existingLayout.activeLeafId) + ? existingLayout.activeLeafId + : null) ?? + (parentLayout?.activeLeafId && knownLeafIds.has(parentLayout.activeLeafId) + ? parentLayout.activeLeafId + : null) ?? + surfaces.find((surface) => surface.isActive)?.leafId ?? + leafIds[0] ?? + null + const expandedLeafId = + requestedActiveLeafId && + (Boolean(existingLayout?.expandedLeafId) || Boolean(parentLayout?.expandedLeafId)) + ? requestedActiveLeafId + : parentLayout?.expandedLeafId && knownLeafIds.has(parentLayout.expandedLeafId) + ? parentLayout.expandedLeafId + : null + return { + // Why: host parentLayout is authoritative for split direction; else keep the prior client tree, then degenerate — never re-guess a direction. + root: resolveTerminalLayoutRoot({ + authoritativeRoot: parentLayout?.root, + existingRoot: existingLayout?.root, + leafIds, + onSynthesize: (leafCount) => + console.warn( + `[web-session-tabs-sync] synthesized layout for ${leafCount} leaves; no authoritative or prior tree covered them` + ) + }), + activeLeafId, + expandedLeafId, + ptyIdsByLeafId, + // Why: surface.title is the tab/PTY label, not a pane title; restoring it as one renders a fake title bar. Only host layout titles are real pane titles. + ...(parentLayout?.titlesByLeafId ? { titlesByLeafId: parentLayout.titlesByLeafId } : {}) + } +} + +export function shouldReplaceTerminalTab( + tab: TerminalTab, + environmentId: string, + nextRemotePtyIds: ReadonlySet, + nextMirroredTerminalIds: ReadonlySet, + exactProvisionalHandoffs: ReadonlySet +): boolean { + if (exactProvisionalHandoffs.has(tab.id)) { + // Why: agent kind is not session identity; retire only the provisional tab + // whose request or structured response identifies this exact host surface. + return true + } + if (isMirroredTerminalSurfaceId(tab.id)) { + // Why: host snapshots are authoritative for mirrored tabs; replace old mirrors even when the next surface still awaits a stream handle, else parity drifts. + return true + } + if (tab.pendingActivationSpawn && tab.ptyId === null && nextRemotePtyIds.size > 0) { + return true + } + if (!isRuntimeTerminalTabForEnvironment(tab, environmentId)) { + return false + } + // Why: web-created remote tabs use local UUIDs until the host publishes their surface; only retire them once their PTY appears in the snapshot. + return ( + tab.ptyId !== null && + (nextRemotePtyIds.has(tab.ptyId) || + nextMirroredTerminalIds.has(toWebTerminalSurfaceTabId(tab.id))) + ) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts new file mode 100644 index 00000000000..cff69f36741 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts @@ -0,0 +1,155 @@ +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { + latestSessionTabsSnapshotByWorktree, + replayableSessionTabsSnapshotByWorktree, + type SessionTabsStreamEvent +} from './state' +import { + sessionTabsFreshnessKey, + rememberHostTerminalTabCount, + trackWebSessionTabsWorktree, + recordAcceptedWebSessionTabsEnvironment +} from './tracking' +import { clearWebSessionTabsTrackingForWorktree } from './tracking-lifecycle' +import { queueAcceptedWebSessionTerminalSnapshot } from '../web-session-terminal-handle-events' + +/** A frame's fate, paired with whether that fate is host evidence for the worktree. */ +export type WebSessionTabsSnapshotDecision = { + readonly apply: boolean + readonly settlesHostMirror: boolean +} + +const WEB_SESSION_TABS_FRAME_APPLIED = { + apply: true, + settlesHostMirror: true +} as const satisfies WebSessionTabsSnapshotDecision +export const WEB_SESSION_TABS_FRAME_OUTRANKED = { + apply: false, + settlesHostMirror: true +} as const satisfies WebSessionTabsSnapshotDecision +const WEB_SESSION_TABS_FRAME_UNMIRRORED = { + apply: false, + settlesHostMirror: false +} as const satisfies WebSessionTabsSnapshotDecision + +function isHostMirroredWorktree(worktreeId: string): boolean { + return worktreeId !== FLOATING_TERMINAL_WORKTREE_ID +} + +export function shouldApplyWebSessionTabsSnapshot( + snapshot: RuntimeMobileSessionTabsResult, + environmentId: string +): boolean { + return decideWebSessionTabsSnapshot(snapshot, environmentId).apply +} + +export function decideWebSessionTabsSnapshot( + snapshot: RuntimeMobileSessionTabsResult, + environmentId: string +): WebSessionTabsSnapshotDecision { + const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) + if ((snapshot as { removed?: unknown }).removed === true) { + // Why: removed worktrees can stop publishing, so clean up their tracking now instead of waiting for a replacement snapshot that may never arrive. + clearWebSessionTabsTrackingForWorktree(environmentId, snapshot.worktree) + queueAcceptedWebSessionTerminalSnapshot(snapshot, environmentId) + return WEB_SESSION_TABS_FRAME_APPLIED + } + if (!isHostMirroredWorktree(snapshot.worktree)) { + // Why: a remote empty same-id snapshot would delete the user's local floating tabs. + return WEB_SESSION_TABS_FRAME_UNMIRRORED + } + rememberHostTerminalTabCount(environmentId, snapshot) + const current = latestSessionTabsSnapshotByWorktree.get(key) + const replayable = replayableSessionTabsSnapshotByWorktree.get(key) + const isExactCurrentReplay = Boolean( + current && + replayable && + current.publicationEpoch === replayable.publicationEpoch && + current.snapshotVersion === replayable.snapshotVersion && + snapshot.publicationEpoch === replayable.publicationEpoch && + snapshot.snapshotVersion === replayable.snapshotVersion + ) + // Why: reject stale snapshots only within an epoch; host restarts create a new epoch. + if ( + current && + current.publicationEpoch === snapshot.publicationEpoch && + snapshot.snapshotVersion <= current.snapshotVersion && + !isExactCurrentReplay + ) { + return WEB_SESSION_TABS_FRAME_OUTRANKED + } + replayableSessionTabsSnapshotByWorktree.delete(key) + latestSessionTabsSnapshotByWorktree.set(key, { + publicationEpoch: snapshot.publicationEpoch, + snapshotVersion: snapshot.snapshotVersion + }) + trackWebSessionTabsWorktree(environmentId, snapshot.worktree) + recordAcceptedWebSessionTabsEnvironment(environmentId, snapshot) + // Why: a mounted mirror that exhausted bounded polling needs fresh host evidence without subscribing to every store write. + queueAcceptedWebSessionTerminalSnapshot(snapshot, environmentId) + return WEB_SESSION_TABS_FRAME_APPLIED +} + +export function shouldBootstrapInitialWebRuntimeTerminal(args: { + event: SessionTabsStreamEvent + activeWorktreeId: string + requestedInitialTerminal: boolean + snapshotIsFresh: boolean + localTerminalCount: number +}): boolean { + return ( + args.snapshotIsFresh && + args.event.type === 'snapshot' && + args.event.tabs.length === 0 && + args.localTerminalCount === 0 && + !args.requestedInitialTerminal && + args.activeWorktreeId === args.event.worktree + ) +} + +export function shouldRespawnWebRuntimeTerminalAfterWake(args: { + event: SessionTabsStreamEvent + activeWorktreeId: string + requestedRespawnAfterWake: boolean + snapshotIsFresh: boolean + localTerminalCount: number + hasLiveLocalPty: boolean + skipWakeRespawn?: boolean +}): boolean { + if ( + !args.snapshotIsFresh || + args.requestedRespawnAfterWake || + args.skipWakeRespawn === true || + args.localTerminalCount === 0 || + args.hasLiveLocalPty || + (args.event.type !== 'snapshot' && args.event.type !== 'updated') + ) { + return false + } + if (args.activeWorktreeId !== args.event.worktree) { + return false + } + const hostTerminalTabCount = args.event.tabs.filter((tab) => tab.type === 'terminal').length + return hostTerminalTabCount === 0 +} + +export function shouldSyncRuntimeSessionTabs(args: { + activeWorktreeId?: string | null + activeWorktreeRuntimeEnvironmentId?: string | null + workspaceSessionReady: boolean +}): boolean { + const environmentId = args.activeWorktreeRuntimeEnvironmentId?.trim() + if (!environmentId || !args.workspaceSessionReady) { + return false + } + return Boolean(args.activeWorktreeId?.trim()) +} + +export function shouldSyncAllRuntimeSessionTabs(args: { + activeRuntimeEnvironmentId: string | null | undefined + workspaceSessionReady: boolean +}): boolean { + const environmentId = args.activeRuntimeEnvironmentId?.trim() + return Boolean(environmentId && args.workspaceSessionReady) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts new file mode 100644 index 00000000000..e3fff1a9869 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts @@ -0,0 +1,200 @@ +import { + latestSessionTabsSnapshotByWorktree, + replayableSessionTabsSnapshotByWorktree, + latestReceivedSessionTabsSnapshotByWorktree, + latestSessionTabsRemovalFenceByWorktree, + sessionTabsRecoveryStateByWorktree, + trackedSessionTabsWorktreeIdsByEnvironment, + sessionTabsEnvironmentsByWorktree, + sessionTabsTrackingGenerationByEnvironment, + lastHostTerminalTabCountByWorktree, + hostSessionTabIdByLocalKey, + hostSessionTabMappingKeysByEnvironmentAndWorktree, + hostWorkingClientBoundaryByPaneKey, + resetReceivedSessionTabsFrameSequence +} from './state' +import { + clearWebRuntimeWakeTerminalRespawnForWorktree, + clearAllWebRuntimeWakeTerminalRespawn +} from '../web-runtime-wake-terminal-respawn' +import { clearWebSessionReorderIntentsForWorktree } from '../web-session-reorder-intent' +import { clearWebSessionCloseIntentsForWorktree } from '../web-session-close-intent' +import { + clearWebAgentSessionHandoffsForWorktree, + clearWebAgentSessionHandoffsForEnvironment +} from '../web-agent-session-handoff' +import { + clearWebSessionBrowserPlacementsForWorktree, + clearWebSessionBrowserPlacementsForEnvironment, + resetWebSessionBrowserPlacementsForTests +} from '../web-session-browser-placement' +import { + clearWebSessionTerminalPlacementsForWorktree, + clearWebSessionTerminalPlacementsForEnvironment +} from '../web-session-terminal-placement' +import { clearHostSessionMirrorHydration } from '../host-session-mirror-hydration' +import { clearHostSessionTabIdMappings } from './tracking-mappings' +import { + sessionTabsFreshnessKey, + untrackWebSessionTabsWorktree, + removeWebSessionTabsEnvironment +} from './tracking' + +export function getLastKnownHostTerminalTabCount( + environmentId: string, + worktreeId: string +): number { + return ( + lastHostTerminalTabCountByWorktree.get(sessionTabsFreshnessKey(environmentId, worktreeId)) ?? 0 + ) +} + +export function getLatestWebSessionTabsPublicationEpoch( + environmentId: string, + worktreeId: string +): string | null { + return ( + latestSessionTabsSnapshotByWorktree.get(sessionTabsFreshnessKey(environmentId, worktreeId)) + ?.publicationEpoch ?? null + ) +} + +// Why: a replay may repeat the current epoch/version; permit only that exact +// identity once so an older concurrent frame cannot bypass monotonic ordering. +export function acceptReplayedWebSessionTabsSnapshot( + environmentId: string, + worktreeId: string +): void { + const key = sessionTabsFreshnessKey(environmentId, worktreeId) + const current = latestSessionTabsSnapshotByWorktree.get(key) + if (current) { + replayableSessionTabsSnapshotByWorktree.set(key, current) + } +} +export function resetWebSessionTabsSnapshotFreshnessForTests(): void { + latestSessionTabsSnapshotByWorktree.clear() + replayableSessionTabsSnapshotByWorktree.clear() + latestReceivedSessionTabsSnapshotByWorktree.clear() + latestSessionTabsRemovalFenceByWorktree.clear() + sessionTabsRecoveryStateByWorktree.clear() + trackedSessionTabsWorktreeIdsByEnvironment.clear() + sessionTabsEnvironmentsByWorktree.clear() + resetReceivedSessionTabsFrameSequence() + lastHostTerminalTabCountByWorktree.clear() + hostSessionTabIdByLocalKey.clear() + hostSessionTabMappingKeysByEnvironmentAndWorktree.clear() + hostWorkingClientBoundaryByPaneKey.clear() + resetWebSessionBrowserPlacementsForTests() +} + +export function _getWebSessionTabsTrackingCountsForTest(): { + freshness: number + hostMappings: number + hostMappingWorktrees: number +} { + let hostMappingWorktrees = 0 + for (const mappingKeysByWorktree of hostSessionTabMappingKeysByEnvironmentAndWorktree.values()) { + hostMappingWorktrees += mappingKeysByWorktree.size + } + return { + freshness: latestSessionTabsSnapshotByWorktree.size, + hostMappings: hostSessionTabIdByLocalKey.size, + // Why: the mapping index is a parallel structure, so leak tests must see it drain alongside the flat map. + hostMappingWorktrees + } +} + +export function _getWebSessionTabsRecoveryTrackingCountsForTest(): { + pendingRecoveries: number + removalFrames: number +} { + return { + pendingRecoveries: sessionTabsRecoveryStateByWorktree.size, + removalFrames: latestSessionTabsRemovalFenceByWorktree.size + } +} + +export function clearWebSessionTabsTrackingForWorktree( + environmentId: string, + worktreeId: string +): void { + const key = sessionTabsFreshnessKey(environmentId, worktreeId) + latestSessionTabsSnapshotByWorktree.delete(key) + replayableSessionTabsSnapshotByWorktree.delete(key) + latestReceivedSessionTabsSnapshotByWorktree.delete(key) + untrackWebSessionTabsWorktree(environmentId, worktreeId) + removeWebSessionTabsEnvironment(environmentId, worktreeId) + lastHostTerminalTabCountByWorktree.delete(key) + clearWebRuntimeWakeTerminalRespawnForWorktree(worktreeId) + clearWebSessionReorderIntentsForWorktree({ environmentId }, worktreeId) + clearWebSessionCloseIntentsForWorktree({ environmentId }, worktreeId) + clearWebAgentSessionHandoffsForWorktree(environmentId, worktreeId) + clearHostSessionTabIdMappings(environmentId, worktreeId) + clearWebSessionBrowserPlacementsForWorktree(environmentId, worktreeId) + clearWebSessionTerminalPlacementsForWorktree(environmentId, worktreeId) +} + +export function clearWebSessionTabsTrackingForEnvironment(environmentId: string): void { + const trimmedEnvironmentId = environmentId.trim() + if (!trimmedEnvironmentId) { + return + } + const keyPrefix = `${trimmedEnvironmentId}:` + sessionTabsTrackingGenerationByEnvironment.set( + trimmedEnvironmentId, + (sessionTabsTrackingGenerationByEnvironment.get(trimmedEnvironmentId) ?? 0) + 1 + ) + for (const key of latestSessionTabsSnapshotByWorktree.keys()) { + if (key.startsWith(keyPrefix)) { + latestSessionTabsSnapshotByWorktree.delete(key) + } + } + for (const key of replayableSessionTabsSnapshotByWorktree.keys()) { + if (key.startsWith(keyPrefix)) { + replayableSessionTabsSnapshotByWorktree.delete(key) + } + } + for (const key of latestReceivedSessionTabsSnapshotByWorktree.keys()) { + if (key.startsWith(keyPrefix)) { + latestReceivedSessionTabsSnapshotByWorktree.delete(key) + } + } + for (const key of latestSessionTabsRemovalFenceByWorktree.keys()) { + if (key.startsWith(keyPrefix)) { + latestSessionTabsRemovalFenceByWorktree.delete(key) + } + } + for (const key of sessionTabsRecoveryStateByWorktree.keys()) { + if (key.startsWith(keyPrefix)) { + sessionTabsRecoveryStateByWorktree.delete(key) + } + } + trackedSessionTabsWorktreeIdsByEnvironment.delete(trimmedEnvironmentId) + for (const worktreeId of sessionTabsEnvironmentsByWorktree.keys()) { + removeWebSessionTabsEnvironment(trimmedEnvironmentId, worktreeId) + } + for (const key of lastHostTerminalTabCountByWorktree.keys()) { + if (key.startsWith(keyPrefix)) { + lastHostTerminalTabCountByWorktree.delete(key) + } + } + const mappingKeysByWorktree = + hostSessionTabMappingKeysByEnvironmentAndWorktree.get(trimmedEnvironmentId) + if (mappingKeysByWorktree) { + for (const mappingKeys of mappingKeysByWorktree.values()) { + for (const mappingKey of mappingKeys) { + hostSessionTabIdByLocalKey.delete(mappingKey) + } + } + hostSessionTabMappingKeysByEnvironmentAndWorktree.delete(trimmedEnvironmentId) + } + clearWebAgentSessionHandoffsForEnvironment(trimmedEnvironmentId) + clearWebSessionBrowserPlacementsForEnvironment(trimmedEnvironmentId) + clearWebSessionTerminalPlacementsForEnvironment(trimmedEnvironmentId) + clearHostSessionMirrorHydration(trimmedEnvironmentId) + clearAllWebRuntimeWakeTerminalRespawn() +} + +export function getWebSessionTabsTrackingGeneration(environmentId: string): number { + return sessionTabsTrackingGenerationByEnvironment.get(environmentId.trim()) ?? 0 +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking-mappings.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking-mappings.ts new file mode 100644 index 00000000000..bbd59cede61 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking-mappings.ts @@ -0,0 +1,58 @@ +import type { WebSessionTabsSyncState } from './state' +import { + hostSessionTabIdByLocalKey, + hostSessionTabMappingKeysByEnvironmentAndWorktree +} from './state' +import { resolveWebAgentSessionHandoff } from '../web-agent-session-handoff' + +function hostSessionTabMappingKey(args: { + environmentId: string + worktreeId: string + tabId: string +}): string { + return `${args.environmentId}:${args.worktreeId}:${args.tabId}` +} + +export function clearHostSessionTabIdMappings(environmentId: string, worktreeId: string): void { + const mappingKeysByWorktree = hostSessionTabMappingKeysByEnvironmentAndWorktree.get(environmentId) + const mappingKeys = mappingKeysByWorktree?.get(worktreeId) + if (!mappingKeys) { + return + } + for (const mappingKey of mappingKeys) { + hostSessionTabIdByLocalKey.delete(mappingKey) + } + mappingKeysByWorktree?.delete(worktreeId) + if (mappingKeysByWorktree?.size === 0) { + hostSessionTabMappingKeysByEnvironmentAndWorktree.delete(environmentId) + } +} + +export function setHostSessionTabIdMapping( + args: { environmentId: string; worktreeId: string; tabId: string }, + hostTabId: string +): void { + const mappingKey = hostSessionTabMappingKey(args) + hostSessionTabIdByLocalKey.set(mappingKey, hostTabId) + const mappingKeysByWorktree = + hostSessionTabMappingKeysByEnvironmentAndWorktree.get(args.environmentId) ?? new Map() + const mappingKeys = mappingKeysByWorktree.get(args.worktreeId) ?? new Set() + mappingKeys.add(mappingKey) + mappingKeysByWorktree.set(args.worktreeId, mappingKeys) + hostSessionTabMappingKeysByEnvironmentAndWorktree.set(args.environmentId, mappingKeysByWorktree) +} + +export function resolveHostSessionTabIdForWebSessionTab( + _state: WebSessionTabsSyncState, + args: { environmentId: string; worktreeId: string; tabId: string } +): string | null { + return ( + hostSessionTabIdByLocalKey.get(hostSessionTabMappingKey(args)) ?? + // Why: structured create returns canonical identity before its confirming snapshot; an immediate user close must already target that host tab. + resolveWebAgentSessionHandoff({ + environmentId: args.environmentId, + worktreeId: args.worktreeId, + provisionalTabId: args.tabId + }) + ) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts new file mode 100644 index 00000000000..ea6008b9956 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts @@ -0,0 +1,263 @@ +import type { + RuntimeMobileSessionTabsRemovedResult, + RuntimeMobileSessionTabsResult +} from '../../../../shared/runtime-types' +import { VISIBILITY_INVENTORY_REMOVAL_EPOCH, type SessionTabsListAllResult } from './state' +import { + latestReceivedSessionTabsSnapshotByWorktree, + latestSessionTabsRemovalFenceByWorktree, + latestSessionTabsSnapshotByWorktree, + lastHostTerminalTabCountByWorktree, + sessionTabsEnvironmentsByWorktree, + sessionTabsRecoveryStateByWorktree, + trackedSessionTabsWorktreeIdsByEnvironment, + type SnapshotFreshness, + type TrackedWebSessionTabsWorktree +} from './state' +import { nextReceivedSessionTabsFrame } from './state' + +export function isSessionTabsListAllResult(value: unknown): value is SessionTabsListAllResult { + return ( + Boolean(value) && + typeof value === 'object' && + Array.isArray((value as { snapshots?: unknown }).snapshots) + ) +} + +export function sessionTabsFreshnessKey(environmentId: string, worktreeId: string): string { + return `${environmentId}:${worktreeId}` +} + +export function advancesSessionTabsFreshness( + snapshot: RuntimeMobileSessionTabsResult, + baseline: SnapshotFreshness +): boolean { + return ( + snapshot.publicationEpoch !== baseline.publicationEpoch || + snapshot.snapshotVersion > baseline.snapshotVersion + ) +} + +export function getTrackedWebSessionTabsWorktrees( + environmentId: string +): TrackedWebSessionTabsWorktree[] { + return [...(trackedSessionTabsWorktreeIdsByEnvironment.get(environmentId) ?? [])].flatMap( + (worktree) => { + const key = sessionTabsFreshnessKey(environmentId, worktree) + const freshness = latestSessionTabsSnapshotByWorktree.get(key) + return freshness + ? [ + { + worktree, + freshness + } + ] + : [] + } + ) +} + +export function trackWebSessionTabsWorktree(environmentId: string, worktreeId: string): void { + const worktrees = trackedSessionTabsWorktreeIdsByEnvironment.get(environmentId) ?? new Set() + worktrees.add(worktreeId) + trackedSessionTabsWorktreeIdsByEnvironment.set(environmentId, worktrees) +} + +export function untrackWebSessionTabsWorktree(environmentId: string, worktreeId: string): void { + const worktrees = trackedSessionTabsWorktreeIdsByEnvironment.get(environmentId) + if (!worktrees) { + return + } + worktrees.delete(worktreeId) + if (worktrees.size === 0) { + trackedSessionTabsWorktreeIdsByEnvironment.delete(environmentId) + } +} + +export function recordReceivedWebSessionTabsSnapshot( + environmentId: string, + snapshot: RuntimeMobileSessionTabsResult +): number { + const receivedFrame = nextReceivedSessionTabsFrame() + const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) + latestReceivedSessionTabsSnapshotByWorktree.set(key, { + receivedFrame, + publicationEpoch: snapshot.publicationEpoch, + snapshotVersion: snapshot.snapshotVersion + }) + if ((snapshot as { removed?: unknown }).removed === true) { + recordReceivedWebSessionTabsRemoval(environmentId, snapshot.worktree, receivedFrame) + } + return receivedFrame +} + +export function recordReceivedWebSessionTabsInventory(): number { + return nextReceivedSessionTabsFrame() +} + +export function beginWebSessionTabsSnapshotRecovery( + environmentId: string, + worktreeId: string, + receivedFrame: number +): () => void { + const key = sessionTabsFreshnessKey(environmentId, worktreeId) + const recoveryState = sessionTabsRecoveryStateByWorktree.get(key) ?? { pendingCount: 0 } + recoveryState.pendingCount += 1 + sessionTabsRecoveryStateByWorktree.set(key, recoveryState) + let settled = false + return () => { + if (settled) { + return + } + settled = true + recoveryState.pendingCount -= 1 + if ( + recoveryState.pendingCount === 0 && + sessionTabsRecoveryStateByWorktree.get(key) === recoveryState + ) { + sessionTabsRecoveryStateByWorktree.delete(key) + } + const removalFence = latestSessionTabsRemovalFenceByWorktree.get(key) + if ( + removalFence?.recoveryState === recoveryState && + receivedFrame < removalFence.receivedFrame + ) { + removalFence.pendingCount -= 1 + if (removalFence.pendingCount === 0) { + latestSessionTabsRemovalFenceByWorktree.delete(key) + } + } + } +} + +export function recordReceivedWebSessionTabsRemoval( + environmentId: string, + worktreeId: string, + receivedFrame: number +): void { + const key = sessionTabsFreshnessKey(environmentId, worktreeId) + const current = latestSessionTabsRemovalFenceByWorktree.get(key) + if (current && current.receivedFrame >= receivedFrame) { + return + } + const recoveryState = sessionTabsRecoveryStateByWorktree.get(key) + if (!recoveryState || recoveryState.pendingCount === 0) { + latestSessionTabsRemovalFenceByWorktree.delete(key) + return + } + latestSessionTabsRemovalFenceByWorktree.set(key, { + receivedFrame, + recoveryState, + pendingCount: recoveryState.pendingCount + }) +} + +export function shouldApplyRecoveredWebSessionTabsSnapshot( + environmentId: string, + snapshot: RuntimeMobileSessionTabsResult, + receivedFrame: number +): boolean { + const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) + const removalFrame = latestSessionTabsRemovalFenceByWorktree.get(key)?.receivedFrame + if (removalFrame !== undefined && receivedFrame < removalFrame) { + return false + } + const latest = latestReceivedSessionTabsSnapshotByWorktree.get(key) + if (!latest || latest.receivedFrame === receivedFrame) { + return latest !== undefined + } + if (latest.publicationEpoch !== snapshot.publicationEpoch) { + return receivedFrame > latest.receivedFrame + } + return snapshot.snapshotVersion >= latest.snapshotVersion +} + +export function isTrackedWebSessionTabsOmissionCurrent( + environmentId: string, + trackedWorktree: TrackedWebSessionTabsWorktree +): boolean { + const key = sessionTabsFreshnessKey(environmentId, trackedWorktree.worktree) + const current = latestSessionTabsSnapshotByWorktree.get(key) + return ( + current?.publicationEpoch === trackedWorktree.freshness.publicationEpoch && + current.snapshotVersion === trackedWorktree.freshness.snapshotVersion + ) +} + +export function recordAcceptedWebSessionTabsEnvironment( + environmentId: string, + snapshot: RuntimeMobileSessionTabsResult +): void { + const environments = new Set(sessionTabsEnvironmentsByWorktree.get(snapshot.worktree) ?? []) + if (snapshot.tabs.length > 0) { + environments.add(environmentId) + } else { + environments.delete(environmentId) + } + if (environments.size > 0) { + sessionTabsEnvironmentsByWorktree.set(snapshot.worktree, environments) + } else { + sessionTabsEnvironmentsByWorktree.delete(snapshot.worktree) + } +} + +export function removeWebSessionTabsEnvironment(environmentId: string, worktreeId: string): void { + const environments = new Set(sessionTabsEnvironmentsByWorktree.get(worktreeId) ?? []) + environments.delete(environmentId) + if (environments.size > 0) { + sessionTabsEnvironmentsByWorktree.set(worktreeId, environments) + } else { + sessionTabsEnvironmentsByWorktree.delete(worktreeId) + } +} + +// Why: a tombstone empties the whole worktree mirror — including tabs a still-live sibling environment publishes — so it is a +// visibility fact, never evidence that the host closed anything. +export function isWebSessionTabsWorktreeRemovalFrame( + snapshot: RuntimeMobileSessionTabsResult +): boolean { + return ( + (snapshot as { removed?: unknown }).removed === true || + snapshot.publicationEpoch === VISIBILITY_INVENTORY_REMOVAL_EPOCH + ) +} + +// Why: omission means removal only because `listAllMobileSessionTabs` publishes every worktree it knows unfiltered; if a host ever +// scopes that map, this turns live worktrees into tombstones, so the fence below is deliberately short-lived. +export function buildMissingWebSessionTabsRemovals( + environmentId: string, + trackedWorktrees: readonly TrackedWebSessionTabsWorktree[], + publishedWorktrees: ReadonlySet +): { + trackedWorktree: TrackedWebSessionTabsWorktree + snapshot: RuntimeMobileSessionTabsRemovedResult +}[] { + return trackedWorktrees + .filter( + (trackedWorktree) => + !publishedWorktrees.has(trackedWorktree.worktree) && + isTrackedWebSessionTabsOmissionCurrent(environmentId, trackedWorktree) + ) + .map((trackedWorktree) => ({ + trackedWorktree, + snapshot: { + worktree: trackedWorktree.worktree, + publicationEpoch: VISIBILITY_INVENTORY_REMOVAL_EPOCH, + snapshotVersion: 0, + removed: true, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + })) +} + +export function rememberHostTerminalTabCount( + environmentId: string, + snapshot: RuntimeMobileSessionTabsResult +): void { + const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) + const terminalCount = snapshot.tabs.filter((tab) => tab.type === 'terminal').length + lastHostTerminalTabCountByWorktree.set(key, terminalCount) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts new file mode 100644 index 00000000000..f73749e9a8e --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts @@ -0,0 +1,109 @@ +import { useEffect, useLayoutEffect, useRef } from 'react' +import { useAppStore } from '../../store' +import { getExplicitRuntimeEnvironmentIdForWorktree } from '../../lib/worktree-runtime-owner' +import { useRuntimeSessionMirrorEnvironmentKey } from '../use-runtime-session-mirror-environment-key' +import { sessionTabsFreshnessKey } from './tracking' +import { clearWebSessionTabsTrackingForEnvironment } from './tracking-lifecycle' +import { + installGlobalSessionTabsSubscriptions, + type GlobalSubscriptionRefs +} from './global-session-subscription' +import { installActiveSessionTabsSubscription } from './active-session-subscription' + +/** Mount the paired-runtime tab mirrors and the selected-worktree stream. */ +export function useWebSessionTabsSync(): void { + const activeRuntimeEnvironmentIdRef = useRef(null) + const activeRuntimeWorktreeKeyRef = useRef(null) + const visibilityResumeOmissionsRef = useRef< + GlobalSubscriptionRefs['visibilityResumeOmissions']['current'] + >(new Map()) + const ownerRevisionsRef = useRef(new Map()) + const visibilitySnapshotReceiptRef = useRef( + () => {} + ) + const visibilitySnapshotApplyRef = useRef( + () => true + ) + + const activeWorktreeId = useAppStore((state) => state.activeWorktreeId) + const workspaceSessionReady = useAppStore((state) => state.workspaceSessionReady) + const runtimeSessionMirrorEnvironmentKey = useRuntimeSessionMirrorEnvironmentKey() + const activeWorktreeRuntimeEnvironmentId = useAppStore((state) => + getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId) + ) + // Keep this subscription dependency: a runtime reconnect can retain the same environment id + // while replacing its runtime instance, which must restart the scoped stream. + const activeWorktreeRuntimeId = useAppStore((state) => { + const environmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId) + return environmentId + ? (state.runtimeStatusByEnvironmentId.get(environmentId)?.status?.runtimeId ?? null) + : null + }) + const activeWorktreeRuntimeConnectionGeneration = useAppStore((state) => { + const environmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId) + return environmentId + ? (state.runtimeStatusByEnvironmentId.get(environmentId)?.connectionGeneration ?? 0) + : 0 + }) + const activeWorktreeRuntimePairingRevision = useAppStore((state) => { + const environmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId) + const environment = state.runtimeEnvironments.find( + (candidate) => candidate.id === environmentId + ) + return environment ? (environment.pairingRevision ?? environment.createdAt) : undefined + }) + + useLayoutEffect(() => { + const environmentId = activeWorktreeRuntimeEnvironmentId?.trim() || null + activeRuntimeEnvironmentIdRef.current = environmentId + activeRuntimeWorktreeKeyRef.current = + environmentId && activeWorktreeId + ? sessionTabsFreshnessKey(environmentId, activeWorktreeId) + : null + }, [activeWorktreeId, activeWorktreeRuntimeEnvironmentId]) + + useEffect( + () => () => { + for (const environmentId of ownerRevisionsRef.current.keys()) { + clearWebSessionTabsTrackingForEnvironment(environmentId) + } + ownerRevisionsRef.current.clear() + visibilityResumeOmissionsRef.current.clear() + }, + [] + ) + + useEffect(() => { + return installGlobalSessionTabsSubscriptions({ + runtimeSessionMirrorEnvironmentKey, + workspaceSessionReady, + refs: { + activeRuntimeEnvironmentId: activeRuntimeEnvironmentIdRef, + activeRuntimeWorktreeKey: activeRuntimeWorktreeKeyRef, + visibilityResumeOmissions: visibilityResumeOmissionsRef, + snapshotReceipt: visibilitySnapshotReceiptRef, + snapshotApply: visibilitySnapshotApplyRef, + ownerRevisions: ownerRevisionsRef + } + }) + }, [runtimeSessionMirrorEnvironmentKey, workspaceSessionReady]) + + useEffect(() => { + return installActiveSessionTabsSubscription({ + activeWorktreeId, + activeWorktreeRuntimeEnvironmentId, + activeWorktreeRuntimeConnectionGeneration, + activeWorktreeRuntimePairingRevision, + workspaceSessionReady, + visibilitySnapshotReceipt: visibilitySnapshotReceiptRef, + visibilitySnapshotApply: visibilitySnapshotApplyRef + }) + }, [ + activeWorktreeId, + activeWorktreeRuntimeEnvironmentId, + activeWorktreeRuntimeConnectionGeneration, + activeWorktreeRuntimePairingRevision, + activeWorktreeRuntimeId, + workspaceSessionReady + ]) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-batch.ts b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-batch.ts new file mode 100644 index 00000000000..d5358d89484 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-batch.ts @@ -0,0 +1,64 @@ +import { getTrackedWebSessionTabsWorktrees } from './tracking' +import { getWebSessionTabsTrackingGeneration } from './tracking-lifecycle' +import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' +import type { VisibilityResumeOmission } from './state' +import type { + MirroredRuntimeEnvironment, + VisibilityResumeBatch, + VisibilityResumeEnvironment +} from './visibility-resume-types' + +/** Build the inventory/replay state for the subscriptions restarted by a resume. */ +export function buildVisibilityResumeBatch(args: { + visibilityGeneration: number + restartingSpecIndexes: readonly number[] + environmentIdBySubscriptionSpec: readonly string[] + environments: readonly MirroredRuntimeEnvironment[] + omissions: Map + activeRuntimeWorktreeKey: () => string | null +}): VisibilityResumeBatch | null { + const activeKey = args.activeRuntimeWorktreeKey() + for (const [key, omission] of args.omissions) { + if (key !== activeKey || omission.visibilityGeneration < args.visibilityGeneration - 1) { + args.omissions.delete(key) + } + } + const resumed = new Map() + const trackedWorktreeIds = new Set() + for (const index of args.restartingSpecIndexes) { + const environmentId = args.environmentIdBySubscriptionSpec[index] + if (!environmentId) { + continue + } + const trackedWorktrees = getTrackedWebSessionTabsWorktrees(environmentId) + if (trackedWorktrees.length === 0) { + continue + } + for (const { worktree } of trackedWorktrees) { + trackedWorktreeIds.add(worktree) + } + const descriptor = args.environments.find((entry) => entry.environmentId === environmentId) + resumed.set(environmentId, { + trackedWorktrees, + inventoryReceived: false, + latestInventoryReceivedFrame: 0, + pendingMissingWorktrees: new Set(), + expectedEnvironmentConnectionGeneration: + descriptor?.expectedEnvironmentConnectionGeneration ?? + getRuntimeEnvironmentConnectionGeneration(environmentId), + expectedEnvironmentPairingRevision: descriptor?.expectedEnvironmentPairingRevision, + expectedTrackingGeneration: getWebSessionTabsTrackingGeneration(environmentId) + }) + } + return resumed.size + ? { + visibilityGeneration: args.visibilityGeneration, + environments: resumed, + pendingInventoryCount: resumed.size, + pendingMissingByWorktree: new Map(), + deferredRepairWorktrees: new Set(), + trackedWorktreeIds, + reapplyableSnapshotsByKey: new Map() + } + : null +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-coordinator.ts b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-coordinator.ts new file mode 100644 index 00000000000..2c9ff91c1a2 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-coordinator.ts @@ -0,0 +1,285 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { + advancesSessionTabsFreshness, + shouldApplyRecoveredWebSessionTabsSnapshot, + sessionTabsFreshnessKey +} from './tracking' +import { latestSessionTabsSnapshotByWorktree, sessionTabsEnvironmentsByWorktree } from './state' +import { acceptReplayedWebSessionTabsSnapshot } from './tracking-lifecycle' +import { + applyWebSessionTabsSnapshotOperations, + decideWebSessionTabsSnapshotOperations, + type WebSessionTabsSnapshotOperation +} from './snapshot-api' +import { applyWebSessionTabsStorePatch } from './store-patch' +import { + recordVisibilityResumeInventory, + recordVisibilityResumeInventoryReceipt +} from './visibility-resume-inventory' +import type { VisibilityResumeOmission } from './state' +import type { + MirroredRuntimeEnvironment, + VisibilityResumeBatch, + VisibilityResumeMissing +} from './visibility-resume-types' +import { buildVisibilityResumeBatch } from './visibility-resume-batch' + +export type VisibilityResumeCoordinatorOptions = { + environments: readonly MirroredRuntimeEnvironment[] + environmentIdBySubscriptionSpec: readonly string[] + omissions: Map + activeRuntimeWorktreeKey: () => string | null +} + +/** Coordinates inventory omissions and cross-environment replay during visibility resumes. */ +export class VisibilityResumeCoordinator { + private batch: VisibilityResumeBatch | null = null + + constructor(private readonly options: VisibilityResumeCoordinatorOptions) {} + + recordSnapshotReceipt( + environmentId: string, + snapshot: RuntimeMobileSessionTabsResult, + receivedFrame: number + ): void { + const omission = this.options.omissions.get( + sessionTabsFreshnessKey(environmentId, snapshot.worktree) + ) + if ( + omission && + receivedFrame > omission.inventoryReceivedFrame && + ((snapshot as { removed?: unknown }).removed === true || + advancesSessionTabsFreshness(snapshot, omission.baseline)) + ) { + omission.superseded = true + if (this.batch?.pendingMissingByWorktree.has(snapshot.worktree)) { + this.reconcileWorktrees([snapshot.worktree]) + } + } + } + + shouldApplySnapshot( + environmentId: string, + snapshot: RuntimeMobileSessionTabsResult, + receivedFrame: number + ): boolean { + const omission = this.options.omissions.get( + sessionTabsFreshnessKey(environmentId, snapshot.worktree) + ) + if (!omission) { + return true + } + if (receivedFrame < omission.inventoryReceivedFrame) { + return false + } + return ( + (snapshot as { removed?: unknown }).removed === true || + advancesSessionTabsFreshness(snapshot, omission.baseline) + ) + } + + private missingCurrent(missing: VisibilityResumeMissing): boolean { + const omission = this.options.omissions.get( + sessionTabsFreshnessKey(missing.environmentId, missing.snapshot.worktree) + ) + return ( + omission?.inventoryReceivedFrame === missing.inventoryReceivedFrame && !omission.superseded + ) + } + + private replayableSnapshot( + batch: VisibilityResumeBatch, + environmentId: string, + worktreeId: string + ): RuntimeMobileSessionTabsResult | null { + const key = sessionTabsFreshnessKey(environmentId, worktreeId) + const entry = batch.reapplyableSnapshotsByKey.get(key) + const freshness = entry ? latestSessionTabsSnapshotByWorktree.get(key) : undefined + if ( + !entry || + !freshness || + freshness.publicationEpoch !== entry.snapshot.publicationEpoch || + freshness.snapshotVersion !== entry.snapshot.snapshotVersion || + !shouldApplyRecoveredWebSessionTabsSnapshot( + environmentId, + entry.snapshot, + entry.receivedFrame + ) + ) { + return null + } + return entry.snapshot + } + + private finishIfIdle(batch: VisibilityResumeBatch): void { + if ( + batch.pendingInventoryCount === 0 && + batch.pendingMissingByWorktree.size === 0 && + this.batch === batch + ) { + this.batch = null + } + } + + private reconcileWorktrees(worktreeIds: Iterable): void { + const batch = this.batch + if (!batch) { + return + } + const operations: WebSessionTabsSnapshotOperation[] = [] + for (const worktreeId of new Set(worktreeIds)) { + const pendingMissing = batch.pendingMissingByWorktree.get(worktreeId) + if (!pendingMissing) { + batch.deferredRepairWorktrees.delete(worktreeId) + continue + } + for (const [environmentId, missing] of pendingMissing) { + if (this.missingCurrent(missing)) { + continue + } + pendingMissing.delete(environmentId) + batch.environments.get(environmentId)?.pendingMissingWorktrees.delete(worktreeId) + } + if (pendingMissing.size === 0) { + batch.pendingMissingByWorktree.delete(worktreeId) + batch.deferredRepairWorktrees.delete(worktreeId) + continue + } + const missingEnvironmentIds = new Set(pendingMissing.keys()) + const survivingSnapshots: { + environmentId: string + snapshot: RuntimeMobileSessionTabsResult + }[] = [] + let canRepair = true + for (const environmentId of sessionTabsEnvironmentsByWorktree.get(worktreeId) ?? []) { + if (missingEnvironmentIds.has(environmentId)) { + continue + } + const snapshot = this.replayableSnapshot(batch, environmentId, worktreeId) + if (!snapshot) { + canRepair = false + break + } + survivingSnapshots.push({ environmentId, snapshot }) + } + if (!canRepair) { + batch.deferredRepairWorktrees.add(worktreeId) + continue + } + for (const missing of pendingMissing.values()) { + operations.push({ environmentId: missing.environmentId, snapshot: missing.snapshot }) + } + for (const { environmentId, snapshot } of survivingSnapshots) { + acceptReplayedWebSessionTabsSnapshot(environmentId, worktreeId) + operations.push({ environmentId, snapshot }) + } + for (const environmentId of pendingMissing.keys()) { + batch.environments.get(environmentId)?.pendingMissingWorktrees.delete(worktreeId) + } + batch.pendingMissingByWorktree.delete(worktreeId) + batch.deferredRepairWorktrees.delete(worktreeId) + } + if (operations.length > 0) { + const decided = decideWebSessionTabsSnapshotOperations(operations) + const settle = applyWebSessionTabsStorePatch( + (state) => applyWebSessionTabsSnapshotOperations(state, decided), + { + frames: decided.map(({ environmentId, snapshot, decision }) => ({ + environmentId, + worktreeId: snapshot.worktree, + decision, + expectedEnvironmentConnectionGeneration: + batch.environments.get(environmentId)?.expectedEnvironmentConnectionGeneration, + expectedEnvironmentPairingRevision: + batch.environments.get(environmentId)?.expectedEnvironmentPairingRevision, + expectedTrackingGeneration: + batch.environments.get(environmentId)?.expectedTrackingGeneration + })) + }, + operations.map(({ snapshot }) => snapshot) + ) + settle() + } + this.finishIfIdle(batch) + } + + recordSnapshot( + environmentId: string, + snapshot: RuntimeMobileSessionTabsResult, + receivedFrame: number + ): void { + const batch = this.batch + if (!batch || !batch.trackedWorktreeIds.has(snapshot.worktree)) { + return + } + const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) + const existing = this.replayableSnapshot(batch, environmentId, snapshot.worktree) + const freshness = latestSessionTabsSnapshotByWorktree.get(key) + const crossHost = + (sessionTabsEnvironmentsByWorktree.get(snapshot.worktree)?.size ?? 0) > 1 || + batch.deferredRepairWorktrees.has(snapshot.worktree) + if ( + (snapshot as { removed?: unknown }).removed === true || + snapshot.tabs.length === 0 || + !crossHost || + freshness?.publicationEpoch !== snapshot.publicationEpoch || + freshness.snapshotVersion !== snapshot.snapshotVersion || + !shouldApplyRecoveredWebSessionTabsSnapshot(environmentId, snapshot, receivedFrame) + ) { + if (!existing) { + batch.reapplyableSnapshotsByKey.delete(key) + } + } else { + batch.reapplyableSnapshotsByKey.set(key, { snapshot, receivedFrame }) + } + if (batch.pendingMissingByWorktree.has(snapshot.worktree)) { + this.reconcileWorktrees([snapshot.worktree]) + } + } + + recordInventory( + environmentId: string, + visibilityGeneration: number, + inventoryReceivedFrame: number, + missingWorktrees: readonly VisibilityResumeMissing[] + ): void { + recordVisibilityResumeInventory({ + batch: this.batch, + environmentId, + visibilityGeneration, + inventoryReceivedFrame, + missingWorktrees, + reconcileWorktrees: (worktreeIds) => this.reconcileWorktrees(worktreeIds) + }) + } + + recordInventoryReceipt( + environmentId: string, + visibilityGeneration: number, + inventoryReceivedFrame: number, + snapshots: readonly RuntimeMobileSessionTabsResult[] + ): VisibilityResumeMissing[] { + return recordVisibilityResumeInventoryReceipt({ + batch: this.batch, + omissions: this.options.omissions, + environmentId, + visibilityGeneration, + inventoryReceivedFrame, + snapshots + }) + } + + beginVisibilityResume( + visibilityGeneration: number, + restartingSpecIndexes: readonly number[] + ): void { + this.batch = buildVisibilityResumeBatch({ + visibilityGeneration, + restartingSpecIndexes, + environmentIdBySubscriptionSpec: this.options.environmentIdBySubscriptionSpec, + environments: this.options.environments, + omissions: this.options.omissions, + activeRuntimeWorktreeKey: this.options.activeRuntimeWorktreeKey + }) + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-inventory.ts b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-inventory.ts new file mode 100644 index 00000000000..a7e0db6cab2 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-inventory.ts @@ -0,0 +1,105 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { + buildMissingWebSessionTabsRemovals, + recordReceivedWebSessionTabsRemoval, + sessionTabsFreshnessKey +} from './tracking' +import type { VisibilityResumeOmission } from './state' +import type { VisibilityResumeBatch, VisibilityResumeMissing } from './visibility-resume-types' + +export function recordVisibilityResumeInventoryReceipt(args: { + batch: VisibilityResumeBatch | null + omissions: Map + environmentId: string + visibilityGeneration: number + inventoryReceivedFrame: number + snapshots: readonly RuntimeMobileSessionTabsResult[] +}): VisibilityResumeMissing[] { + const { + batch, + omissions, + environmentId, + visibilityGeneration, + inventoryReceivedFrame, + snapshots + } = args + for (const snapshot of snapshots) { + omissions.delete(sessionTabsFreshnessKey(environmentId, snapshot.worktree)) + } + if (!batch || batch.visibilityGeneration !== visibilityGeneration) { + return [] + } + const environment = batch.environments.get(environmentId) + if (!environment) { + return [] + } + environment.latestInventoryReceivedFrame = Math.max( + environment.latestInventoryReceivedFrame, + inventoryReceivedFrame + ) + if (environment.latestInventoryReceivedFrame !== inventoryReceivedFrame) { + return [] + } + const publishedWorktrees = new Set(snapshots.map((snapshot) => snapshot.worktree)) + return buildMissingWebSessionTabsRemovals( + environmentId, + environment.trackedWorktrees, + publishedWorktrees + ).map((missing) => { + const key = sessionTabsFreshnessKey(environmentId, missing.snapshot.worktree) + omissions.set(key, { + baseline: missing.trackedWorktree.freshness, + environmentId, + inventoryReceivedFrame, + superseded: false, + visibilityGeneration + }) + recordReceivedWebSessionTabsRemoval( + environmentId, + missing.snapshot.worktree, + inventoryReceivedFrame + ) + return { environmentId, inventoryReceivedFrame, ...missing } + }) +} + +export function recordVisibilityResumeInventory(args: { + batch: VisibilityResumeBatch | null + environmentId: string + visibilityGeneration: number + inventoryReceivedFrame: number + missingWorktrees: readonly VisibilityResumeMissing[] + reconcileWorktrees: (worktreeIds: Iterable) => void +}): void { + const { batch, environmentId, visibilityGeneration, inventoryReceivedFrame, missingWorktrees } = + args + if (!batch || visibilityGeneration === 0 || batch.visibilityGeneration !== visibilityGeneration) { + return + } + const environment = batch.environments.get(environmentId) + if (!environment || environment.latestInventoryReceivedFrame !== inventoryReceivedFrame) { + return + } + const affectedWorktrees = new Set(environment.pendingMissingWorktrees) + for (const worktreeId of environment.pendingMissingWorktrees) { + const pendingMissing = batch.pendingMissingByWorktree.get(worktreeId) + pendingMissing?.delete(environmentId) + if (pendingMissing?.size === 0) { + batch.pendingMissingByWorktree.delete(worktreeId) + } + } + environment.pendingMissingWorktrees.clear() + for (const missing of missingWorktrees) { + const worktreeId = missing.snapshot.worktree + const pendingMissing = batch.pendingMissingByWorktree.get(worktreeId) ?? new Map() + pendingMissing.set(environmentId, missing) + batch.pendingMissingByWorktree.set(worktreeId, pendingMissing) + environment.pendingMissingWorktrees.add(worktreeId) + affectedWorktrees.add(worktreeId) + } + if (!environment.inventoryReceived) { + environment.inventoryReceived = true + batch.pendingInventoryCount -= 1 + } + args.reconcileWorktrees(affectedWorktrees) +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-types.ts b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-types.ts new file mode 100644 index 00000000000..6ed44bf678c --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/visibility-resume-types.ts @@ -0,0 +1,41 @@ +import type { + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionTabsRemovedResult +} from '../../../../shared/runtime-types' +import type { TrackedWebSessionTabsWorktree } from './state' + +export type VisibilityResumeEnvironment = { + trackedWorktrees: readonly TrackedWebSessionTabsWorktree[] + inventoryReceived: boolean + latestInventoryReceivedFrame: number + pendingMissingWorktrees: Set + expectedEnvironmentConnectionGeneration: number + expectedEnvironmentPairingRevision?: number + expectedTrackingGeneration: number +} + +export type VisibilityResumeMissing = { + environmentId: string + inventoryReceivedFrame: number + trackedWorktree: TrackedWebSessionTabsWorktree + snapshot: RuntimeMobileSessionTabsRemovedResult +} + +export type VisibilityResumeBatch = { + visibilityGeneration: number + environments: Map + pendingInventoryCount: number + pendingMissingByWorktree: Map> + deferredRepairWorktrees: Set + trackedWorktreeIds: ReadonlySet + reapplyableSnapshotsByKey: Map< + string, + { snapshot: RuntimeMobileSessionTabsResult; receivedFrame: number } + > +} + +export type MirroredRuntimeEnvironment = { + environmentId: string + expectedEnvironmentConnectionGeneration: number + expectedEnvironmentPairingRevision?: number +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/visibility-types.ts b/src/renderer/src/runtime/web-session-tabs-sync/visibility-types.ts new file mode 100644 index 00000000000..468725f7df4 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/visibility-types.ts @@ -0,0 +1,6 @@ +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' + +/** The floating terminal is local-only and never belongs to a host mirror. */ +export function isHostMirroredWorktree(worktreeId: string): boolean { + return worktreeId !== FLOATING_TERMINAL_WORKTREE_ID +} diff --git a/src/renderer/src/store/slices/agent-status-authority-actions.ts b/src/renderer/src/store/slices/agent-status-authority-actions.ts new file mode 100644 index 00000000000..cbd3b991a67 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-authority-actions.ts @@ -0,0 +1,218 @@ +import type { AgentStatusSlice } from './agent-status-slice-contract' +import type { AgentStatusRuntime } from './agent-status-runtime' +import { rendererAgentStatusObservations } from '../../lib/renderer-agent-status-observations' +import { + resolveAgentPaneAuthorityKey, + retireAgentPaneAuthorityAliases, + transferAgentPaneAuthorityAlias +} from './agent-pane-authority' +import { + boundRecentlyRetiredAgentStatusPaneKeys, + movePaneKeyedRecord, + removePaneKeys +} from './agent-status-map-helpers' +import { + getLeafIdFromPaneKey, + getTabIdFromPaneKey, + isRecentlyClosedAgentStatusTab +} from './agent-status-pane-helpers' + +export function createAgentStatusAuthorityActions( + runtime: AgentStatusRuntime +): Pick< + AgentStatusSlice, + | 'scheduleAgentStatusFreshness' + | 'retireAgentPaneAuthority' + | 'restoreAgentPaneAuthority' + | 'transferAgentPaneAuthority' +> { + const { get, set, freshness } = runtime + return { + scheduleAgentStatusFreshness: () => freshness.schedule(), + + retireAgentPaneAuthority: (paneKey, options) => { + const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey) + const retiredPaneKeys = retireAgentPaneAuthorityAliases(paneKey) + const retiredPaneKeySet = new Set(retiredPaneKeys) + for (const key of retiredPaneKeys) { + rendererAgentStatusObservations.forget(key) + } + let hadLive = false + set((s) => { + const retiredLivePaneKeys = retiredPaneKeys.filter((key) => key in s.agentStatusByPaneKey) + hadLive = retiredLivePaneKeys.length > 0 + let nextRetentionSuppressedPaneKeys = removePaneKeys( + s.retentionSuppressedPaneKeys, + retiredPaneKeySet + ) + if ( + retiredLivePaneKeys.length > 0 && + nextRetentionSuppressedPaneKeys === s.retentionSuppressedPaneKeys + ) { + nextRetentionSuppressedPaneKeys = { ...nextRetentionSuppressedPaneKeys } + } + for (const key of retiredLivePaneKeys) { + nextRetentionSuppressedPaneKeys[key] = true + } + return { + agentStatusByPaneKey: removePaneKeys(s.agentStatusByPaneKey, retiredPaneKeySet), + runtimeAgentOrchestrationByPaneKey: removePaneKeys( + s.runtimeAgentOrchestrationByPaneKey, + retiredPaneKeySet + ), + retainedAgentsByPaneKey: removePaneKeys(s.retainedAgentsByPaneKey, retiredPaneKeySet), + sleepingAgentSessionsByPaneKey: options?.preserveSleepingAgentSession + ? s.sleepingAgentSessionsByPaneKey + : removePaneKeys(s.sleepingAgentSessionsByPaneKey, retiredPaneKeySet), + agentLaunchConfigByPaneKey: removePaneKeys( + s.agentLaunchConfigByPaneKey, + retiredPaneKeySet + ), + acknowledgedAgentsByPaneKey: removePaneKeys( + s.acknowledgedAgentsByPaneKey, + retiredPaneKeySet + ), + paneForegroundAgentByPaneKey: removePaneKeys( + s.paneForegroundAgentByPaneKey, + retiredPaneKeySet + ), + unreadTerminalPanes: removePaneKeys(s.unreadTerminalPanes, retiredPaneKeySet), + unreadAgentCompletionPanes: removePaneKeys( + s.unreadAgentCompletionPanes, + retiredPaneKeySet + ), + lastTerminalInputAtByPaneKey: removePaneKeys( + s.lastTerminalInputAtByPaneKey, + retiredPaneKeySet + ), + cacheTimerByKey: removePaneKeys(s.cacheTimerByKey, retiredPaneKeySet), + retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys, + recentlyRetiredAgentStatusPaneKeys: boundRecentlyRetiredAgentStatusPaneKeys( + s.recentlyRetiredAgentStatusPaneKeys, + retiredPaneKeys + ), + agentStatusEpoch: hadLive ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, + sortEpoch: hadLive ? s.sortEpoch + 1 : s.sortEpoch + } + }) + if (hadLive) { + freshness.scheduleDeferred() + } + if (typeof window !== 'undefined') { + window.api?.agentStatus?.retirePaneAuthority?.(ownerPaneKey) + } + }, + + // Why: the tombstone claims this pane is gone; a live PTY binding to it proves + // otherwise. Lift the fence on that proof rather than on the next hook event — + // a pane re-attached mid-turn or while idle emits no new-turn event, so a + // turn-triggered revival leaves exactly the reported permanent suppression + // (STA-4114). This deliberately does NOT restore the rows retirement dropped; + // those are genuinely stale. It only re-opens the pane to future status. + restoreAgentPaneAuthority: (paneKey) => { + const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey) + // Why: a closed tab is a stronger, separate claim — re-attach must not undo it. + if ( + isRecentlyClosedAgentStatusTab( + get().recentlyClosedAgentStatusTabIds, + getTabIdFromPaneKey(ownerPaneKey) + ) + ) { + return + } + set((s) => { + const restorable = [paneKey, ownerPaneKey].filter( + (key) => key in s.recentlyRetiredAgentStatusPaneKeys + ) + if (restorable.length === 0) { + return s + } + const next = { ...s.recentlyRetiredAgentStatusPaneKeys } + for (const key of restorable) { + delete next[key] + } + return { recentlyRetiredAgentStatusPaneKeys: next } + }) + // Why: deliberately OUTSIDE the guard above, and not gated on having cleared + // anything here. This map is not a mirror of main's — main fences panes the + // renderer never hears about (retirePtyAgentLaunchAuthority on command-finished + // and PTY exit calls the hook server directly, and nothing pushes that back), and + // this map is per-window and non-persisted, so a renderer reload empties it while + // main's survives. Gating the send on a local tombstone reintroduces STA-4114 for + // exactly those panes. The send is idempotent and main refuses closed tabs itself. + if (typeof window !== 'undefined') { + window.api?.agentStatus?.restorePaneAuthority?.(ownerPaneKey) + } + }, + transferAgentPaneAuthority: ({ fromPaneKey, toPaneKey, ptyId }) => { + const transfer = transferAgentPaneAuthorityAlias({ fromPaneKey, toPaneKey, ptyId }) + if (!transfer || transfer.previousOwnerPaneKey === transfer.ownerPaneKey) { + return + } + const from = transfer.previousOwnerPaneKey + const to = transfer.ownerPaneKey + // Why: the moved row carries the observation stamped for its OLD key; renderer-authored + // observations for the new key must sort after it, not race it. + rendererAgentStatusObservations.forget(from) + rendererAgentStatusObservations.rebind(to) + const targetTabId = getTabIdFromPaneKey(to) ?? undefined + const targetLeafId = getLeafIdFromPaneKey(to) ?? undefined + set((s) => ({ + agentStatusByPaneKey: movePaneKeyedRecord(s.agentStatusByPaneKey, from, to, (entry) => ({ + ...entry, + paneKey: to, + tabId: targetTabId + })), + // Why: retention/sidebar consumers gate on the epoch; a moved live row is a + // pane-key change they must observe, not a silent remap. + ...(from in s.agentStatusByPaneKey + ? { agentStatusEpoch: s.agentStatusEpoch + 1, sortEpoch: s.sortEpoch + 1 } + : {}), + runtimeAgentOrchestrationByPaneKey: movePaneKeyedRecord( + s.runtimeAgentOrchestrationByPaneKey, + from, + to + ), + retainedAgentsByPaneKey: movePaneKeyedRecord( + s.retainedAgentsByPaneKey, + from, + to, + (retained) => ({ + ...retained, + entry: { ...retained.entry, paneKey: to, tabId: targetTabId }, + tab: targetTabId ? { ...retained.tab, id: targetTabId } : retained.tab + }) + ), + sleepingAgentSessionsByPaneKey: movePaneKeyedRecord( + s.sleepingAgentSessionsByPaneKey, + from, + to, + (record) => ({ ...record, paneKey: to, tabId: targetTabId }) + ), + agentLaunchConfigByPaneKey: movePaneKeyedRecord( + s.agentLaunchConfigByPaneKey, + from, + to, + (entry) => ({ + ...entry, + identity: { ...entry.identity, tabId: targetTabId, leafId: targetLeafId } + }) + ), + acknowledgedAgentsByPaneKey: movePaneKeyedRecord(s.acknowledgedAgentsByPaneKey, from, to), + paneForegroundAgentByPaneKey: movePaneKeyedRecord(s.paneForegroundAgentByPaneKey, from, to), + unreadTerminalPanes: movePaneKeyedRecord(s.unreadTerminalPanes, from, to), + unreadAgentCompletionPanes: movePaneKeyedRecord(s.unreadAgentCompletionPanes, from, to), + lastTerminalInputAtByPaneKey: movePaneKeyedRecord(s.lastTerminalInputAtByPaneKey, from, to), + cacheTimerByKey: movePaneKeyedRecord(s.cacheTimerByKey, from, to), + retentionSuppressedPaneKeys: movePaneKeyedRecord(s.retentionSuppressedPaneKeys, from, to) + })) + if (typeof window !== 'undefined') { + window.api?.agentStatus?.transferPaneAuthority?.({ + fromPaneKey: from, + toPaneKey: to, + ...(transfer.ptyId ? { ptyId: transfer.ptyId } : {}) + }) + } + } + } +} diff --git a/src/renderer/src/store/slices/agent-status-cleanup-actions.ts b/src/renderer/src/store/slices/agent-status-cleanup-actions.ts new file mode 100644 index 00000000000..78047c13560 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-cleanup-actions.ts @@ -0,0 +1,197 @@ +import type { AgentStatusSlice } from './agent-status-slice-contract' +import type { AgentStatusRuntime } from './agent-status-runtime' +import { + collectWorktreeIdsForConnection, + pruneMigrationUnsupportedEntries +} from './agent-status-map-helpers' + +/** Actions for removing transient rows and migration-era cache entries. */ +export function createAgentStatusCleanupActions( + runtime: AgentStatusRuntime +): Pick< + AgentStatusSlice, + | 'setMigrationUnsupportedPty' + | 'clearMigrationUnsupportedPty' + | 'removeAgentStatus' + | 'removeAgentStatusByTabPrefix' + | 'clearTransientAgentStatuses' +> { + const { get, set, freshness } = runtime + return { + setMigrationUnsupportedPty: (entry) => { + set((s) => { + const existing = s.migrationUnsupportedByPtyId[entry.ptyId] + if (existing && entry.updatedAt < existing.updatedAt) { + return s + } + return { + migrationUnsupportedByPtyId: { + ...s.migrationUnsupportedByPtyId, + [entry.ptyId]: entry + }, + agentStatusEpoch: s.agentStatusEpoch + 1, + sortEpoch: s.sortEpoch + 1 + } + }) + }, + + clearMigrationUnsupportedPty: (ptyId) => { + if (!(ptyId in get().migrationUnsupportedByPtyId)) { + return + } + set((s) => { + const next = { ...s.migrationUnsupportedByPtyId } + delete next[ptyId] + return { + migrationUnsupportedByPtyId: next, + agentStatusEpoch: s.agentStatusEpoch + 1, + sortEpoch: s.sortEpoch + 1 + } + }) + }, + + removeAgentStatus: (paneKey) => { + const current = get() + if ( + !(paneKey in current.agentStatusByPaneKey) && + !(paneKey in current.agentLaunchConfigByPaneKey) && + !Object.values(current.migrationUnsupportedByPtyId).some( + (entry) => entry.paneKey === paneKey + ) + ) { + return + } + set((s) => { + const hasLive = paneKey in s.agentStatusByPaneKey + const next = hasLive ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey + if (hasLive) { + delete next[paneKey] + } + const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey + const nextLaunchConfigs = hasLaunchConfig + ? { ...s.agentLaunchConfigByPaneKey } + : s.agentLaunchConfigByPaneKey + if (hasLaunchConfig) { + delete nextLaunchConfigs[paneKey] + } + const migrationUnsupported = pruneMigrationUnsupportedEntries( + s.migrationUnsupportedByPtyId, + (entry) => entry.paneKey === paneKey + ) + // Ack entries belong to the pane lifecycle; never let a reused key inherit one. + let nextAck = s.acknowledgedAgentsByPaneKey + if (paneKey in nextAck) { + nextAck = { ...nextAck } + delete nextAck[paneKey] + } + return { + agentStatusByPaneKey: next, + agentLaunchConfigByPaneKey: nextLaunchConfigs, + migrationUnsupportedByPtyId: migrationUnsupported.next, + ...(nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : {}), + agentStatusEpoch: s.agentStatusEpoch + 1, + sortEpoch: s.sortEpoch + 1 + } + }) + freshness.scheduleDeferred() + }, + + removeAgentStatusByTabPrefix: (tabIdPrefix) => { + const prefix = `${tabIdPrefix}:` + const current = get() + const toRemove = Object.keys(current.agentStatusByPaneKey).filter((key) => + key.startsWith(prefix) + ) + const launchConfigKeys = Object.keys(current.agentLaunchConfigByPaneKey).filter((key) => + key.startsWith(prefix) + ) + const hasMigrationUnsupported = Object.values(current.migrationUnsupportedByPtyId).some( + (entry) => entry.paneKey?.startsWith(prefix) + ) + if (toRemove.length === 0 && launchConfigKeys.length === 0 && !hasMigrationUnsupported) { + return + } + set((s) => { + const next = { ...s.agentStatusByPaneKey } + for (const key of toRemove) { + delete next[key] + } + const nextLaunchConfigs = { ...s.agentLaunchConfigByPaneKey } + for (const key of launchConfigKeys) { + delete nextLaunchConfigs[key] + } + const migrationUnsupported = pruneMigrationUnsupportedEntries( + s.migrationUnsupportedByPtyId, + (entry) => entry.paneKey?.startsWith(prefix) ?? false + ) + let nextAck = s.acknowledgedAgentsByPaneKey + const ackKeys = Object.keys(nextAck).filter((key) => key.startsWith(prefix)) + if (ackKeys.length > 0) { + nextAck = { ...nextAck } + for (const key of ackKeys) { + delete nextAck[key] + } + } + return { + agentStatusByPaneKey: next, + agentLaunchConfigByPaneKey: nextLaunchConfigs, + migrationUnsupportedByPtyId: migrationUnsupported.next, + ...(nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : {}), + agentStatusEpoch: s.agentStatusEpoch + 1, + sortEpoch: s.sortEpoch + 1 + } + }) + freshness.scheduleDeferred() + }, + + clearTransientAgentStatuses: (connectionId, clearedAt) => { + if (connectionId.length === 0 || !Number.isFinite(clearedAt)) { + return + } + let removed = false + set((s) => { + const worktreeIdsOnConnection = collectWorktreeIdsForConnection(s, connectionId) + let next: Record | null = null + for (const [paneKey, existing] of Object.entries(s.agentStatusByPaneKey)) { + if (existing.updatedAt > clearedAt) { + continue + } + const belongsToConnection = + existing.connectionId === connectionId || + (existing.connectionId === undefined && + existing.worktreeId !== undefined && + worktreeIdsOnConnection.has(existing.worktreeId)) + if (!belongsToConnection) { + continue + } + next ??= { ...s.agentStatusByPaneKey } + delete next[paneKey] + } + const alreadyBlocked = connectionId in s.transientClearedAgentStatusConnectionIds + if (!next && alreadyBlocked) { + return s + } + removed = next !== null + return { + ...(next + ? { + agentStatusByPaneKey: next, + agentStatusEpoch: s.agentStatusEpoch + 1, + sortEpoch: s.sortEpoch + 1 + } + : {}), + transientClearedAgentStatusConnectionIds: alreadyBlocked + ? s.transientClearedAgentStatusConnectionIds + : { ...s.transientClearedAgentStatusConnectionIds, [connectionId]: true } + } + }) + if (removed) { + freshness.scheduleDeferred() + } + } + } +} diff --git a/src/renderer/src/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts new file mode 100644 index 00000000000..457da539318 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-contract.ts @@ -0,0 +1,149 @@ +import type { AppState } from '../types' +import type { + AgentStateHistoryEntry, + AgentStatusEntry, + AgentStatusOrchestrationContext, + AgentType, + MigrationUnsupportedPtyEntry, + ParsedAgentStatusPayload +} from '../../../../shared/agent-status-types' +import type { AgentStatusObservation } from '../../../../shared/agent-status-observation' +import type { + AgentProviderSessionMetadata, + ResumableTuiAgent, + SleepingAgentLaunchConfig, + SleepingAgentSessionRecord +} from '../../../../shared/agent-session-resume' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' + +export type RetainedAgentEntry = { + entry: AgentStatusEntry + worktreeId: string + /** Snapshot of the tab at retention time; kept full (not just an id) because the tab may be gone from `tabsByWorktree` by render time. */ + tab: TerminalTab + agentType: AgentType + startedAt: number +} + +export type AgentStatusWorktreeShutdownReason = + | 'manual-sleep' + | 'remove-worktree' + | 'auto-hibernate-completed-agent' + +export type AllAgentSessionCaptureMode = 'periodic' | 'quit' + +export type DropAgentStatusByWorktreeOptions = { + shutdownReason?: AgentStatusWorktreeShutdownReason + sleepingPaneKeys?: readonly string[] | ReadonlySet + retainedCompletionEvidence?: readonly RetainedAgentEntry[] +} + +export type DropHibernatedAgentPaneOptions = { + retainedCompletionEvidence?: readonly RetainedAgentEntry[] +} + +export type DropAgentStatusByTabPrefixOptions = { + worktreeId?: string +} + +export type AgentLaunchConfigRegistrationMetadata = { + agentType?: AgentType + launchToken?: string + tabId?: string + leafId?: string + terminalHandle?: string + providerSession?: AgentProviderSessionMetadata +} + +export type AgentLaunchConfigStatusMetadata = { + paneKey: string + agentType?: AgentType + tabId?: string + terminalHandle?: string + launchToken?: string + providerSession?: AgentProviderSessionMetadata + existingProviderSession?: AgentProviderSessionMetadata + providerSessionChanged?: boolean +} + +export type AgentLaunchConfigRegistryEntry = { + launchConfig: SleepingAgentLaunchConfig + registeredAt: number + identity: AgentLaunchConfigRegistrationMetadata +} + +export type AgentStatusPayload = ParsedAgentStatusPayload & { + orchestration?: AgentStatusOrchestrationContext + promptInteractionKey?: string + restoredUnconfirmed?: boolean + /** Ingress provenance for this write (STA-4293). Read by nothing yet; a caller that omits + * it produces exactly the entry it produces today. See agent-status-observation.ts. */ + observation?: AgentStatusObservation +} + +export type AgentStatusTiming = { updatedAt?: number; stateStartedAt?: number } + +export type AgentStatusRouting = { + tabId?: string + worktreeId?: string + terminalHandle?: string + connectionId?: string | null +} + +export type AgentStatusMetadata = { + providerSession?: AgentProviderSessionMetadata + launchConfig?: SleepingAgentLaunchConfig + launchToken?: string + terminalResumeEligible?: false +} + +export type AgentStatusUpdate = { + kind?: 'status' + paneKey: string + payload: AgentStatusPayload + terminalTitle?: string + timing?: AgentStatusTiming + routing?: AgentStatusRouting + metadata?: AgentStatusMetadata +} + +export type AgentProviderSessionTiming = { updatedAt?: number } + +export type AgentProviderSessionRouting = { + tabId?: string + worktreeId?: string + connectionId?: string | null +} + +export type AgentProviderSessionRecordMetadata = { launchToken?: string } + +export type AgentProviderSessionUpdate = { + kind: 'providerSession' + paneKey: string + agent: ResumableTuiAgent + providerSession: AgentProviderSessionMetadata + timing?: AgentProviderSessionTiming + routing?: AgentProviderSessionRouting + metadata?: AgentProviderSessionRecordMetadata +} + +export type AgentStatusBatchUpdate = AgentStatusUpdate | AgentProviderSessionUpdate + +export type AgentStatusBatchTransaction = { + getState: () => AppState + apply: (update: AgentStatusBatchUpdate) => boolean + afterCommit: (effect: () => void) => void +} + +export type { + AgentStateHistoryEntry, + AgentStatusEntry, + AgentStatusOrchestrationContext, + AgentType, + MigrationUnsupportedPtyEntry, + ParsedAgentStatusPayload, + AgentProviderSessionMetadata, + ResumableTuiAgent, + SleepingAgentLaunchConfig, + SleepingAgentSessionRecord +} diff --git a/src/renderer/src/store/slices/agent-status-drop-actions.ts b/src/renderer/src/store/slices/agent-status-drop-actions.ts new file mode 100644 index 00000000000..fdaaa450b52 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-drop-actions.ts @@ -0,0 +1,225 @@ +import type { + RetainedAgentEntry, + DropAgentStatusByTabPrefixOptions, + DropHibernatedAgentPaneOptions +} from './agent-status-contract' +import type { AgentStatusSlice } from './agent-status-slice-contract' +import type { AgentStatusRuntime } from './agent-status-runtime' +import { buildAgentStatusTabPrefixDropPatch } from './agent-status-drop-reducer' +import { pruneMigrationUnsupportedEntries } from './agent-status-map-helpers' +import { + retainedAgentEntryFromLive, + shouldReplaceRetainedWithLive +} from './agent-status-pane-helpers' +import { retireAgentPaneAuthorityAliasesByOwnerTab } from './agent-pane-authority' + +function removeAcknowledgement( + acknowledgements: Record, + paneKey: string +): Record { + if (!(paneKey in acknowledgements)) { + return acknowledgements + } + const next = { ...acknowledgements } + delete next[paneKey] + return next +} + +export function createAgentStatusDropActions( + runtime: AgentStatusRuntime +): Pick< + AgentStatusSlice, + 'dropAgentStatus' | 'dropAgentStatusByTabPrefix' | 'dropHibernatedAgentStatusPane' +> { + const { set, freshness } = runtime + return { + dropAgentStatus: (paneKey) => { + let liveExisted = false + set((s) => { + const hasLive = paneKey in s.agentStatusByPaneKey + liveExisted = hasLive + const hasRetained = paneKey in s.retainedAgentsByPaneKey + const migrationUnsupported = pruneMigrationUnsupportedEntries( + s.migrationUnsupportedByPtyId, + (entry) => entry.paneKey === paneKey + ) + const nextAck = removeAcknowledgement(s.acknowledgedAgentsByPaneKey, paneKey) + const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey + const nextLaunchConfigs = hasLaunchConfig + ? { ...s.agentLaunchConfigByPaneKey } + : s.agentLaunchConfigByPaneKey + if (hasLaunchConfig) { + delete nextLaunchConfigs[paneKey] + } + if (!hasLive && !hasRetained && !migrationUnsupported.changed) { + if (hasLaunchConfig) { + return { + agentLaunchConfigByPaneKey: nextLaunchConfigs, + ...(nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : {}) + } + } + return nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : s + } + const nextLive = hasLive ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey + if (hasLive) { + delete nextLive[paneKey] + } + const nextRetained = hasRetained + ? { ...s.retainedAgentsByPaneKey } + : s.retainedAgentsByPaneKey + if (hasRetained) { + delete nextRetained[paneKey] + } + const needsSuppressor = hasLive && !(paneKey in s.retentionSuppressedPaneKeys) + return { + agentStatusByPaneKey: nextLive, + agentLaunchConfigByPaneKey: nextLaunchConfigs, + retainedAgentsByPaneKey: nextRetained, + migrationUnsupportedByPtyId: migrationUnsupported.next, + ...(nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : {}), + ...(needsSuppressor + ? { + retentionSuppressedPaneKeys: { + ...s.retentionSuppressedPaneKeys, + [paneKey]: true + } + } + : {}), + agentStatusEpoch: + hasLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, + sortEpoch: hasLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch + } + }) + if (liveExisted) { + freshness.scheduleDeferred() + } + if (typeof window !== 'undefined') { + window.api?.agentStatus?.drop?.(paneKey) + } + }, + + dropAgentStatusByTabPrefix: (tabIdPrefix, opts?: DropAgentStatusByTabPrefixOptions) => { + const retiredAliasPaneKeys = retireAgentPaneAuthorityAliasesByOwnerTab(tabIdPrefix) + let hadLive = false + set((s) => { + const dropped = buildAgentStatusTabPrefixDropPatch( + s, + tabIdPrefix, + retiredAliasPaneKeys, + opts + ) + hadLive = dropped.hadLive + return dropped.patch + }) + if (hadLive) { + freshness.scheduleDeferred() + } + if (typeof window !== 'undefined') { + window.api?.agentStatus?.dropByTabPrefix?.(tabIdPrefix) + } + }, + + dropHibernatedAgentStatusPane: (worktreeId, paneKey, opts?: DropHibernatedAgentPaneOptions) => { + let hadLive = false + set((s) => { + const liveEntry = s.agentStatusByPaneKey[paneKey] + const hasLive = liveEntry !== undefined + const hasRetained = paneKey in s.retainedAgentsByPaneKey + const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey + const migrationUnsupported = pruneMigrationUnsupportedEntries( + s.migrationUnsupportedByPtyId, + (entry) => entry.paneKey === paneKey + ) + const retainedEvidence = new Map() + for (const retained of opts?.retainedCompletionEvidence ?? []) { + if ( + retained.entry.paneKey === paneKey && + !liveEntry && + shouldReplaceRetainedWithLive(retainedEvidence.get(paneKey), retained) + ) { + retainedEvidence.set(paneKey, retained) + } + } + if ( + liveEntry?.state === 'done' && + liveEntry.agentType !== undefined && + liveEntry.interrupted !== true + ) { + retainedEvidence.set( + paneKey, + retainedAgentEntryFromLive(s, worktreeId, liveEntry, liveEntry.agentType) + ) + } + const keepsCompletionEvidence = retainedEvidence.has(paneKey) + const nextAck = !keepsCompletionEvidence + ? removeAcknowledgement(s.acknowledgedAgentsByPaneKey, paneKey) + : s.acknowledgedAgentsByPaneKey + if ( + !hasLive && + !hasRetained && + !hasLaunchConfig && + !migrationUnsupported.changed && + !keepsCompletionEvidence + ) { + return nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : s + } + hadLive = hasLive + const nextLive = hasLive ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey + if (hasLive) { + delete nextLive[paneKey] + } + const nextLaunchConfigs = hasLaunchConfig + ? { ...s.agentLaunchConfigByPaneKey } + : s.agentLaunchConfigByPaneKey + if (hasLaunchConfig) { + delete nextLaunchConfigs[paneKey] + } + const nextRetained = + hasRetained || keepsCompletionEvidence + ? { ...s.retainedAgentsByPaneKey } + : s.retainedAgentsByPaneKey + if (hasRetained && !keepsCompletionEvidence) { + delete nextRetained[paneKey] + } + for (const [key, retained] of retainedEvidence) { + if (shouldReplaceRetainedWithLive(nextRetained[key], retained)) { + nextRetained[key] = retained + } + } + const needsSuppressor = + hasLive && !keepsCompletionEvidence && !(paneKey in s.retentionSuppressedPaneKeys) + return { + agentStatusByPaneKey: nextLive, + agentLaunchConfigByPaneKey: nextLaunchConfigs, + retainedAgentsByPaneKey: nextRetained, + migrationUnsupportedByPtyId: migrationUnsupported.next, + ...(nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : {}), + ...(needsSuppressor + ? { + retentionSuppressedPaneKeys: { + ...s.retentionSuppressedPaneKeys, + [paneKey]: true + } + } + : {}), + agentStatusEpoch: + hasLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, + sortEpoch: hasLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch + } + }) + if (hadLive) { + freshness.scheduleDeferred() + } + } + } +} diff --git a/src/renderer/src/store/slices/agent-status-drop-reducer.ts b/src/renderer/src/store/slices/agent-status-drop-reducer.ts new file mode 100644 index 00000000000..fc24f06add8 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-drop-reducer.ts @@ -0,0 +1,159 @@ +import type { AppState } from '../types' +import type { DropAgentStatusByTabPrefixOptions } from './agent-status-contract' +import { + boundRecentlyClosedAgentStatusTabIds, + boundRecentlyRetiredAgentStatusPaneKeys, + pruneMigrationUnsupportedEntries +} from './agent-status-map-helpers' +import { findCompletedOrphanPaneKeysForTabClose } from './agent-status-pane-helpers' + +/** Slices that only the fold touched, so the batch commits as a MERGE. */ +export function buildAgentStatusBatchPatch( + initialState: AppState, + nextState: AppState +): Partial { + const patch: Record = {} + for (const key of Object.keys(nextState) as (keyof AppState)[]) { + if (!Object.is(nextState[key], initialState[key])) { + patch[key as string] = nextState[key] + } + } + return patch as Partial +} + +/** The slice of app state the tab-prefix drop reduces over — narrow so callers + * outside the store (the paired snapshot apply) can build the same patch. */ +export type AgentStatusTabPrefixDropState = Pick< + AppState, + | 'acknowledgedAgentsByPaneKey' + | 'agentLaunchConfigByPaneKey' + | 'agentStatusByPaneKey' + | 'agentStatusEpoch' + | 'migrationUnsupportedByPtyId' + | 'recentlyClosedAgentStatusTabIds' + | 'recentlyRetiredAgentStatusPaneKeys' + | 'retainedAgentsByPaneKey' + | 'retentionSuppressedPaneKeys' + | 'sortEpoch' + | 'tabsByWorktree' +> + +/** Pure form of the dropAgentStatusByTabPrefix reducer: the paired snapshot + * apply folds the same sweep into a patch it assembles itself, so the two + * paths cannot drift. `retiredAliasPaneKeys` comes from the caller because + * retiring pane-authority aliases is a registry side effect, not a reduction. */ +export function buildAgentStatusTabPrefixDropPatch( + s: AgentStatusTabPrefixDropState, + tabIdPrefix: string, + retiredAliasPaneKeys: readonly string[], + opts?: DropAgentStatusByTabPrefixOptions +): { patch: Partial; hadLive: boolean } { + const prefix = `${tabIdPrefix}:` + let hadLive = false + const buildPatch = (): Partial => { + const completedOrphanKeys = findCompletedOrphanPaneKeysForTabClose(s, opts?.worktreeId, prefix) + const completedOrphanKeySet = new Set(completedOrphanKeys) + const liveKeys = [ + ...Object.keys(s.agentStatusByPaneKey).filter((k) => k.startsWith(prefix)), + ...completedOrphanKeys + ] + const launchConfigKeys = Object.keys(s.agentLaunchConfigByPaneKey).filter( + (k) => k.startsWith(prefix) || completedOrphanKeySet.has(k) + ) + const retainedKeys = Object.keys(s.retainedAgentsByPaneKey).filter( + (k) => k.startsWith(prefix) || completedOrphanKeySet.has(k) + ) + const migrationUnsupported = pruneMigrationUnsupportedEntries( + s.migrationUnsupportedByPtyId, + (entry) => entry.paneKey?.startsWith(prefix) ?? false + ) + // See removeAgentStatus for ack-cleanup rationale; ack entries are owned by the pane lifecycle regardless of live/retained state. + let nextAck = s.acknowledgedAgentsByPaneKey + const ackKeys = Object.keys(nextAck).filter( + (k) => k.startsWith(prefix) || completedOrphanKeySet.has(k) + ) + if (ackKeys.length > 0) { + nextAck = { ...nextAck } + for (const k of ackKeys) { + delete nextAck[k] + } + } + const nextClosedTabs = boundRecentlyClosedAgentStatusTabIds( + s.recentlyClosedAgentStatusTabIds, + tabIdPrefix + ) + const nextRetiredPaneKeys = boundRecentlyRetiredAgentStatusPaneKeys( + s.recentlyRetiredAgentStatusPaneKeys, + retiredAliasPaneKeys + ) + + if ( + liveKeys.length === 0 && + launchConfigKeys.length === 0 && + retainedKeys.length === 0 && + !migrationUnsupported.changed + ) { + if (nextAck !== s.acknowledgedAgentsByPaneKey) { + return { + acknowledgedAgentsByPaneKey: nextAck, + recentlyClosedAgentStatusTabIds: nextClosedTabs, + recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys + } + } + return { + recentlyClosedAgentStatusTabIds: nextClosedTabs, + recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys + } + } + hadLive = liveKeys.length > 0 + + const nextLive = liveKeys.length > 0 ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey + for (const key of liveKeys) { + delete nextLive[key] + } + const nextLaunchConfigs = + launchConfigKeys.length > 0 + ? { ...s.agentLaunchConfigByPaneKey } + : s.agentLaunchConfigByPaneKey + for (const key of launchConfigKeys) { + delete nextLaunchConfigs[key] + } + + const nextRetained = + retainedKeys.length > 0 ? { ...s.retainedAgentsByPaneKey } : s.retainedAgentsByPaneKey + for (const key of retainedKeys) { + delete nextRetained[key] + } + + // Why: a suppressor is only consumed on a live→gone transition, so plant one only for live paneKeys and skip already-suppressed and completed-orphan keys — otherwise it leaks (mirrors dropAgentStatus). + const suppressorAdds = liveKeys.filter( + (k) => !completedOrphanKeySet.has(k) && !(k in s.retentionSuppressedPaneKeys) + ) + let nextRetentionSuppressedPaneKeys = s.retentionSuppressedPaneKeys + if (suppressorAdds.length > 0) { + nextRetentionSuppressedPaneKeys = { ...s.retentionSuppressedPaneKeys } + for (const key of suppressorAdds) { + nextRetentionSuppressedPaneKeys[key] = true + } + } + + return { + agentStatusByPaneKey: nextLive, + agentLaunchConfigByPaneKey: nextLaunchConfigs, + retainedAgentsByPaneKey: nextRetained, + migrationUnsupportedByPtyId: migrationUnsupported.next, + retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys, + recentlyClosedAgentStatusTabIds: nextClosedTabs, + recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys, + ...(nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : {}), + // Why: mirrors removeAgentStatusByTabPrefix — only bump epochs when the live map changed; retained-only sweeps don't affect sort/freshness. + agentStatusEpoch: + hadLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, + sortEpoch: hadLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch + } + } + const patch = buildPatch() + return { patch, hadLive } +} diff --git a/src/renderer/src/store/slices/agent-status-launch-actions.ts b/src/renderer/src/store/slices/agent-status-launch-actions.ts new file mode 100644 index 00000000000..67832edfab6 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-launch-actions.ts @@ -0,0 +1,110 @@ +import type { AgentStatusSlice } from './agent-status-slice-contract' +import type { AgentStatusRuntime } from './agent-status-runtime' +import type { AgentLaunchConfigRegistryEntry } from './agent-status-contract' +import { copyLaunchConfig, sleepingRecordFromEntry } from './agent-status-recovery-helpers' +import { + getLaunchConfigForEntry, + launchConfigRegistryEntriesEqual, + normalizeLaunchConfigRegistrationMetadata, + registryEntryMatchesStatus +} from './agent-status-launch-config' +import { getLaunchConfigForStatusMetadata } from './agent-status-map-helpers' +import { findAgentPaneWorktreeId, getTabIdFromPaneKey } from './agent-status-pane-helpers' + +export function createAgentStatusLaunchActions( + runtime: AgentStatusRuntime +): Pick< + AgentStatusSlice, + | 'registerAgentLaunchConfig' + | 'getAgentLaunchConfigForStatusEntry' + | 'getAgentLaunchConfigForStatusMetadata' + | 'clearAgentLaunchConfig' +> { + const { get, set } = runtime + return { + registerAgentLaunchConfig: (paneKey, launchConfig, metadata) => { + set((s) => { + const copiedLaunchConfig = copyLaunchConfig(launchConfig) + const nextRegistryEntry: AgentLaunchConfigRegistryEntry = { + launchConfig: copiedLaunchConfig, + registeredAt: Date.now(), + identity: normalizeLaunchConfigRegistrationMetadata(paneKey, metadata) + } + const existingRegistryEntry = s.agentLaunchConfigByPaneKey[paneKey] + const registryChanged = !launchConfigRegistryEntriesEqual( + existingRegistryEntry, + nextRegistryEntry + ) + const existingEntry = s.agentStatusByPaneKey[paneKey] + const entryMatchesRegistry = registryEntryMatchesStatus({ + entry: nextRegistryEntry, + paneKey, + agentType: existingEntry?.agentType, + tabId: existingEntry?.tabId ?? getTabIdFromPaneKey(paneKey) ?? undefined, + terminalHandle: existingEntry?.terminalHandle, + launchToken: metadata?.launchToken, + providerSession: existingEntry?.providerSession, + existingProviderSession: existingEntry?.providerSession, + providerSessionChanged: false + }) + const existingSleepingRecord = s.sleepingAgentSessionsByPaneKey[paneKey] + let nextSleepingAgentSessions = s.sleepingAgentSessionsByPaneKey + if (existingSleepingRecord && entryMatchesRegistry && existingEntry) { + const worktreeId = + existingEntry.worktreeId ?? + existingSleepingRecord.worktreeId ?? + findAgentPaneWorktreeId(s, paneKey) + const refreshedRecord = worktreeId + ? sleepingRecordFromEntry({ + state: s, + entry: existingEntry, + worktreeId, + capturedAt: existingSleepingRecord.capturedAt, + launchConfig: copiedLaunchConfig, + origin: existingSleepingRecord.origin + }) + : null + if (refreshedRecord) { + nextSleepingAgentSessions = { + ...s.sleepingAgentSessionsByPaneKey, + [paneKey]: { + ...refreshedRecord, + capturedAt: existingSleepingRecord.capturedAt + } + } + } + } + if (!registryChanged && nextSleepingAgentSessions === s.sleepingAgentSessionsByPaneKey) { + return s + } + return { + ...(registryChanged + ? { + agentLaunchConfigByPaneKey: { + ...s.agentLaunchConfigByPaneKey, + [paneKey]: nextRegistryEntry + } + } + : {}), + ...(nextSleepingAgentSessions !== s.sleepingAgentSessionsByPaneKey + ? { sleepingAgentSessionsByPaneKey: nextSleepingAgentSessions } + : {}) + } + }) + }, + getAgentLaunchConfigForStatusEntry: (entry) => getLaunchConfigForEntry(get(), entry), + getAgentLaunchConfigForStatusMetadata: (metadata) => + getLaunchConfigForStatusMetadata(get(), metadata), + + clearAgentLaunchConfig: (paneKey) => { + set((s) => { + if (!(paneKey in s.agentLaunchConfigByPaneKey)) { + return s + } + const nextLaunchConfigs = { ...s.agentLaunchConfigByPaneKey } + delete nextLaunchConfigs[paneKey] + return { agentLaunchConfigByPaneKey: nextLaunchConfigs } + }) + } + } +} diff --git a/src/renderer/src/store/slices/agent-status-launch-config.ts b/src/renderer/src/store/slices/agent-status-launch-config.ts new file mode 100644 index 00000000000..ff265bc3ff6 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-launch-config.ts @@ -0,0 +1,142 @@ +import type { AppState } from '../types' +import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' +import { + agentProviderSessionsEqual, + type AgentProviderSessionMetadata, + type SleepingAgentLaunchConfig +} from '../../../../shared/agent-session-resume' +import type { + AgentLaunchConfigRegistryEntry, + AgentLaunchConfigRegistrationMetadata +} from './agent-status-contract' +import { getLeafIdFromPaneKey, getTabIdFromPaneKey } from './agent-status-pane-helpers' +import { launchConfigsEqual } from './agent-status-recovery-equivalence' + +export function normalizeLaunchConfigRegistrationMetadata( + paneKey: string, + metadata: AgentLaunchConfigRegistrationMetadata | undefined +): AgentLaunchConfigRegistrationMetadata { + return { + ...(metadata?.agentType ? { agentType: metadata.agentType } : {}), + ...(metadata?.launchToken ? { launchToken: metadata.launchToken } : {}), + tabId: metadata?.tabId ?? getTabIdFromPaneKey(paneKey) ?? undefined, + leafId: metadata?.leafId ?? getLeafIdFromPaneKey(paneKey) ?? undefined, + ...(metadata?.terminalHandle ? { terminalHandle: metadata.terminalHandle } : {}), + ...(metadata?.providerSession ? { providerSession: metadata.providerSession } : {}) + } +} + +export function launchConfigRegistryEntriesEqual( + a: AgentLaunchConfigRegistryEntry | undefined, + b: AgentLaunchConfigRegistryEntry +): boolean { + return ( + a !== undefined && + launchConfigsEqual(a.launchConfig, b.launchConfig) && + a.identity.agentType === b.identity.agentType && + a.identity.launchToken === b.identity.launchToken && + a.identity.tabId === b.identity.tabId && + a.identity.leafId === b.identity.leafId && + a.identity.terminalHandle === b.identity.terminalHandle && + agentProviderSessionsEqual( + a.identity.agentType ?? b.identity.agentType, + a.identity.providerSession, + b.identity.providerSession + ) + ) +} + +export function registryEntryMatchesStatus(args: { + entry: AgentLaunchConfigRegistryEntry | undefined + paneKey: string + agentType: AgentType | undefined + tabId: string | undefined + terminalHandle: string | undefined + launchToken: string | undefined + providerSession: AgentProviderSessionMetadata | undefined + existingProviderSession: AgentProviderSessionMetadata | undefined + providerSessionChanged: boolean +}): boolean { + const entry = args.entry + if (!entry || args.providerSessionChanged) { + return false + } + const identity = entry.identity + if (identity.agentType !== undefined && identity.agentType !== args.agentType) { + return false + } + if (identity.tabId !== undefined && identity.tabId !== args.tabId) { + return false + } + if (identity.leafId !== undefined && identity.leafId !== getLeafIdFromPaneKey(args.paneKey)) { + return false + } + if ( + identity.terminalHandle !== undefined && + (args.terminalHandle === undefined || identity.terminalHandle !== args.terminalHandle) + ) { + return false + } + if ( + identity.launchToken !== undefined && + (args.launchToken === undefined || identity.launchToken !== args.launchToken) + ) { + // Why: a missing/mismatched launch token is stale proof even if a later manual/mixed Codex run reused the provider session id. + return false + } + if (identity.providerSession !== undefined) { + return agentProviderSessionsEqual( + args.agentType, + identity.providerSession, + args.providerSession + ) + } + if (identity.launchToken !== undefined) { + return true + } + if (identity.terminalHandle !== undefined) { + return true + } + if (args.existingProviderSession && args.providerSession) { + return agentProviderSessionsEqual( + args.agentType, + args.existingProviderSession, + args.providerSession + ) + } + return false +} + +export function getLaunchConfigForEntry( + state: AppState, + entry: AgentStatusEntry +): SleepingAgentLaunchConfig | undefined { + const registryEntry = state.agentLaunchConfigByPaneKey[entry.paneKey] + const registryLaunchConfig = registryEntryMatchesStatus({ + entry: registryEntry, + paneKey: entry.paneKey, + agentType: entry.agentType, + tabId: entry.tabId ?? getTabIdFromPaneKey(entry.paneKey) ?? undefined, + terminalHandle: entry.terminalHandle, + launchToken: undefined, + providerSession: entry.providerSession, + existingProviderSession: entry.providerSession, + providerSessionChanged: false + }) + ? registryEntry?.launchConfig + : undefined + if (registryLaunchConfig) { + return registryLaunchConfig + } + const sleepingRecord = state.sleepingAgentSessionsByPaneKey[entry.paneKey] + return sleepingRecord?.launchConfig && + sleepingRecord.agent === entry.agentType && + entry.providerSession && + agentProviderSessionsEqual( + entry.agentType, + sleepingRecord.providerSession, + entry.providerSession + ) + ? sleepingRecord.launchConfig + : undefined +} diff --git a/src/renderer/src/store/slices/agent-status-live-actions.ts b/src/renderer/src/store/slices/agent-status-live-actions.ts new file mode 100644 index 00000000000..f534c5efb54 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-live-actions.ts @@ -0,0 +1,120 @@ +import type { AgentStatusSlice } from './agent-status-slice-contract' +import type { AgentStatusRuntime } from './agent-status-runtime' +import type { + AgentStatusMetadata, + AgentStatusPayload, + AgentStatusRouting, + AgentStatusTiming +} from './agent-status-contract' +import { resolveAgentPaneAuthorityKey } from './agent-pane-authority' +import { + buildAgentStatusLiveEntry, + type AgentStatusLiveEntryBuild +} from './agent-status-live-entry-builder' +import { reduceAgentStatusLiveUpdate } from './agent-status-live-reducer' +import { + agentStatusTabAlreadyHasProtectedOrGeneratedTitle, + getTabIdFromPaneKey, + isRecentlyClosedAgentStatusTab +} from './agent-status-pane-helpers' +import { + getAgentRowGeneratedTitleText, + getOrcaDispatchTaskId, + isOrcaDispatchPrompt, + orchestrationLabelsMatchLiveDispatch +} from '@/lib/agent-row-primary-text' + +export function createAgentStatusLiveActions( + runtime: AgentStatusRuntime +): Pick { + const { get, set, applyGeneratedTabTitleUpdate, requestFreshness, transactAgentStatuses } = + runtime + const setAgentStatus = ( + rawPaneKey: string, + payload: AgentStatusPayload, + terminalTitle?: string, + timing?: AgentStatusTiming, + routing?: AgentStatusRouting, + metadata?: AgentStatusMetadata + ): void => { + const paneKey = resolveAgentPaneAuthorityKey(rawPaneKey) + const updatedAt = timing?.updatedAt ?? Date.now() + const current = get() + if ( + paneKey in current.recentlyRetiredAgentStatusPaneKeys || + isRecentlyClosedAgentStatusTab( + current.recentlyClosedAgentStatusTabIds, + getTabIdFromPaneKey(paneKey) + ) + ) { + return + } + let built: AgentStatusLiveEntryBuild | null = null + set((state) => { + built = buildAgentStatusLiveEntry({ + state, + paneKey, + payload, + terminalTitle, + timing, + routing, + metadata, + updatedAt + }) + return built ? reduceAgentStatusLiveUpdate(state, built, updatedAt) : state + }) + // Zustand's updater runs synchronously, but TypeScript cannot observe the closure assignment. + const builtResult = built as AgentStatusLiveEntryBuild | null + if (!builtResult) { + // Keep standalone calls' deferred freshness contract even when a stale + // event is rejected by the reducer. + requestFreshness(false) + return + } + const { entry } = builtResult + // Sticky orchestration titles are replaced only when they still describe this dispatch. + const hasMatchingOrchestrationLabels = Boolean( + (entry.orchestration?.displayName?.trim() || entry.orchestration?.taskTitle?.trim()) && + orchestrationLabelsMatchLiveDispatch(entry) + ) + const liveIsDispatchPrompt = isOrcaDispatchPrompt(entry.prompt) + const liveDispatchTaskId = liveIsDispatchPrompt ? getOrcaDispatchTaskId(entry.prompt) : null + const stickyOrchestrationTaskId = entry.orchestration?.taskId?.trim() || null + const isNewDispatchAgainstStickyOrchestration = Boolean( + liveDispatchTaskId && + stickyOrchestrationTaskId && + liveDispatchTaskId !== stickyOrchestrationTaskId + ) + const shouldReplaceGeneratedTitle = + hasMatchingOrchestrationLabels || isNewDispatchAgainstStickyOrchestration + const mayWriteGeneratedTitle = + get().settings?.tabAutoGenerateTitle === true && + (shouldReplaceGeneratedTitle || + !agentStatusTabAlreadyHasProtectedOrGeneratedTitle( + get(), + entry.tabId ?? getTabIdFromPaneKey(paneKey), + entry.worktreeId + )) + const generatedTitlePrompt = + liveIsDispatchPrompt && mayWriteGeneratedTitle + ? getAgentRowGeneratedTitleText(entry) + : entry.prompt + applyGeneratedTabTitleUpdate({ + paneKey, + prompt: generatedTitlePrompt, + ...(shouldReplaceGeneratedTitle ? { options: { replaceExistingGeneratedTitle: true } } : {}) + }) + requestFreshness(true) + if (builtResult.completionRefreshWorktreeId) { + const worktreeId = builtResult.completionRefreshWorktreeId + queueMicrotask(() => get().refreshGitHubForWorktreeIfStale(worktreeId)) + } + } + + const setAgentStatuses = (updates: Parameters[0]) => + updates.length === 0 + ? [] + : transactAgentStatuses((transaction) => updates.map(transaction.apply)) + + return { setAgentStatus, setAgentStatuses, transactAgentStatuses } +} diff --git a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts new file mode 100644 index 00000000000..d391efcd9fb --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts @@ -0,0 +1,290 @@ +import type { AppState } from '../types' +import { + AGENT_STATE_HISTORY_MAX, + agentSubagentsEqual, + type MigrationUnsupportedPtyEntry, + type AgentStateHistoryEntry, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import { + agentProviderSessionsEqual, + getAgentResumeArgv, + isResumableTuiAgent, + type SleepingAgentLaunchConfig, + type SleepingAgentSessionRecord +} from '../../../../shared/agent-session-resume' +import { + resolveAgentStatusIdentity, + shouldSuppressInheritedTerminalStatus +} from '../../../../shared/agent-status-identity' +import { isCommandCodeNewTurnWhileWorking } from '../../../../shared/command-code-turn-boundary' +import type { + AgentStatusMetadata, + AgentStatusPayload, + AgentStatusRouting, + AgentStatusTiming +} from './agent-status-contract' +import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' +import { registryEntryMatchesStatus } from './agent-status-launch-config' +import { findAgentPaneWorktreeId, getTabIdFromPaneKey } from './agent-status-pane-helpers' +import { mergeCurrentOrchestrationContext } from './agent-status-map-helpers' +import { deriveAgentStatusLiveFacts } from './agent-status-live-facts' + +export type AgentStatusLiveEntryBuild = { + entry: AgentStatusEntry + existing: AgentStatusEntry | undefined + existingSleepingRecord: SleepingAgentSessionRecord | undefined + liveRecoveryRecord: SleepingAgentSessionRecord | null + launchConfigSource: SleepingAgentLaunchConfig | undefined + registryEntry: AppState['agentLaunchConfigByPaneKey'][string] | undefined + registryMatched: boolean + providerSession: AgentProviderSessionMetadata | undefined + providerSessionChanged: boolean + retainsResumableRecoveryIdentity: boolean + migrationUnsupported: { + next: Record + changed: boolean + } + commandCodeNewTurn: boolean + sortRelevantChange: boolean + retentionRelevantChange: boolean + completionRefreshWorktreeId: string | null + boundaryResolved: boolean +} + +export type AgentStatusLiveEntryArgs = { + state: AppState + paneKey: string + payload: AgentStatusPayload + terminalTitle?: string + timing?: AgentStatusTiming + routing?: AgentStatusRouting + metadata?: AgentStatusMetadata + updatedAt: number +} + +/** Build one accepted live row and the derived map-update facts. */ +export function buildAgentStatusLiveEntry( + args: AgentStatusLiveEntryArgs +): AgentStatusLiveEntryBuild | null { + const { state, paneKey, payload, terminalTitle, timing, routing, metadata, updatedAt } = args + const existing = state.agentStatusByPaneKey[paneKey] + if (existing && updatedAt < existing.updatedAt) { + return null + } + const effectiveTitle = terminalTitle ?? existing?.terminalTitle + let history: AgentStateHistoryEntry[] = existing?.stateHistory ?? [] + let lastCompletedAssistantMessage = existing?.lastCompletedAssistantMessage + const boundaryLandsOnRealDone = + existing?.state === 'done' && + existing.sessionBoundary !== true && + payload.state === 'done' && + payload.sessionBoundary === true + if ( + existing && + (existing.state !== payload.state || boundaryLandsOnRealDone) && + !(existing.state === 'done' && existing.sessionBoundary === true) + ) { + history = [ + ...history, + { + state: existing.state, + prompt: existing.prompt, + startedAt: existing.stateStartedAt, + interrupted: existing.interrupted + } + ] + if (history.length > AGENT_STATE_HISTORY_MAX) { + history = history.slice(history.length - AGENT_STATE_HISTORY_MAX) + } + if (existing.state === 'done') { + lastCompletedAssistantMessage = existing.lastAssistantMessage + } + } + const identity = resolveAgentStatusIdentity({ + existing: existing + ? { + agentType: existing.agentType, + state: existing.state, + updatedAt: existing.updatedAt, + restoredUnconfirmed: existing.restoredUnconfirmed + } + : undefined, + incoming: payload.agentType, + now: updatedAt + }) + const commandCodeNewTurn = + existing !== undefined && + isCommandCodeNewTurnWhileWorking({ + agentType: identity.agentType, + previousState: existing.state, + incomingState: payload.state, + previousPrompt: existing.prompt, + incomingPrompt: payload.prompt, + previousPromptInteractionKey: existing.promptInteractionKey, + incomingPromptInteractionKey: payload.promptInteractionKey + }) + const promptInteractionKey = + payload.promptInteractionKey ?? + (payload.prompt === existing?.prompt ? existing?.promptInteractionKey : undefined) + const stateStartedAt = + timing?.stateStartedAt ?? + (commandCodeNewTurn + ? updatedAt + : existing && existing.state === payload.state + ? existing.stateStartedAt + : updatedAt) + if ( + existing && + shouldSuppressInheritedTerminalStatus({ + inheritedFromActivePane: identity.inheritedFromActivePane, + incomingState: payload.state + }) + ) { + return null + } + const runtimeOrchestration = state.runtimeAgentOrchestrationByPaneKey[paneKey] + const runtimeMergedOrchestration = runtimeOrchestration + ? mergeCurrentOrchestrationContext(existing?.orchestration, runtimeOrchestration) + : undefined + const payloadMergedOrchestration = payload.orchestration + ? mergeCurrentOrchestrationContext( + runtimeMergedOrchestration ?? existing?.orchestration, + payload.orchestration + ) + : undefined + const orchestration = + payloadMergedOrchestration ?? + runtimeMergedOrchestration ?? + (payload.state === 'done' ? existing?.orchestration : undefined) + const canReuseExistingProviderSession = + existing?.agentType === identity.agentType && + (existing.state !== 'done' || payload.state === 'done') + const providerSession = + metadata?.providerSession ?? + (canReuseExistingProviderSession ? existing.providerSession : undefined) + const existingProviderSession = canReuseExistingProviderSession + ? existing.providerSession + : undefined + const providerSessionChanged = + Boolean(metadata?.providerSession && existingProviderSession) && + !agentProviderSessionsEqual( + identity.agentType, + metadata?.providerSession, + existingProviderSession + ) + const statusTabId = routing?.tabId ?? existing?.tabId ?? getTabIdFromPaneKey(paneKey) ?? undefined + const statusTerminalHandle = routing?.terminalHandle ?? existing?.terminalHandle + const registryEntry = state.agentLaunchConfigByPaneKey[paneKey] + const registryMatched = registryEntryMatchesStatus({ + entry: registryEntry, + paneKey, + agentType: identity.agentType, + tabId: statusTabId, + terminalHandle: statusTerminalHandle, + launchToken: metadata?.launchToken, + providerSession, + existingProviderSession, + providerSessionChanged + }) + const matchedRegistryLaunchConfig = registryMatched ? registryEntry?.launchConfig : undefined + const existingSleepingRecord = state.sleepingAgentSessionsByPaneKey[paneKey] + const retainsResumableRecoveryIdentity = + payload.state === 'done' && + isResumableTuiAgent(identity.agentType) && + providerSession !== undefined && + getAgentResumeArgv(identity.agentType, providerSession) !== null + const matchedSleepingLaunchConfig = + (payload.state !== 'done' || retainsResumableRecoveryIdentity) && + existingSleepingRecord?.launchConfig && + existingSleepingRecord.agent === identity.agentType && + providerSession && + agentProviderSessionsEqual( + identity.agentType, + existingSleepingRecord.providerSession, + providerSession + ) + ? existingSleepingRecord.launchConfig + : undefined + const launchConfigSource = + (payload.state !== 'done' && !providerSessionChanged && metadata?.launchToken + ? metadata?.launchConfig + : undefined) ?? + matchedRegistryLaunchConfig ?? + matchedSleepingLaunchConfig + const entry: AgentStatusEntry = { + state: payload.state, + workingMode: payload.workingMode, + prompt: payload.prompt, + updatedAt, + stateStartedAt, + agentType: identity.agentType, + model: + payload.model ?? (existing?.agentType === identity.agentType ? existing.model : undefined), + paneKey, + terminalHandle: statusTerminalHandle, + worktreeId: + routing?.worktreeId ?? + existing?.worktreeId ?? + findAgentPaneWorktreeId(state, paneKey) ?? + undefined, + ...(routing?.connectionId !== undefined + ? { connectionId: routing.connectionId } + : existing?.connectionId !== undefined + ? { connectionId: existing.connectionId } + : state.sleepingAgentSessionsByPaneKey[paneKey]?.connectionId !== undefined + ? { connectionId: state.sleepingAgentSessionsByPaneKey[paneKey].connectionId } + : {}), + tabId: statusTabId, + terminalTitle: effectiveTitle, + stateHistory: history, + toolName: payload.toolName, + toolInput: payload.toolInput, + interactivePrompt: payload.interactivePrompt, + lastAssistantMessage: payload.lastAssistantMessage, + ...(lastCompletedAssistantMessage ? { lastCompletedAssistantMessage } : {}), + orchestration, + subagents: agentSubagentsEqual(existing?.subagents, payload.subagents) + ? existing?.subagents + : payload.subagents, + ...(providerSession ? { providerSession } : {}), + ...(metadata?.terminalResumeEligible === false + ? { terminalResumeEligible: false as const } + : {}), + ...(promptInteractionKey ? { promptInteractionKey } : {}), + ...(payload.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), + acceptedStatusSeq: (existing?.acceptedStatusSeq ?? 0) + 1, + ...(payload.observation ? { observation: payload.observation } : {}), + interrupted: payload.interrupted, + sessionBoundary: + payload.sessionBoundary ?? + (existing?.state === 'done' && + payload.state === 'done' && + payload.lastAssistantMessage === undefined && + payload.prompt === existing.prompt + ? existing.sessionBoundary + : undefined) + } + const facts = deriveAgentStatusLiveFacts({ + state, + paneKey, + entry, + existing, + launchConfigSource, + retainsResumableRecoveryIdentity, + commandCodeNewTurn, + updatedAt + }) + return { + entry, + existing, + ...facts, + launchConfigSource, + providerSession, + providerSessionChanged, + registryEntry, + registryMatched, + retainsResumableRecoveryIdentity, + commandCodeNewTurn + } +} diff --git a/src/renderer/src/store/slices/agent-status-live-facts.ts b/src/renderer/src/store/slices/agent-status-live-facts.ts new file mode 100644 index 00000000000..7cabeb91822 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-live-facts.ts @@ -0,0 +1,130 @@ +import type { AppState } from '../types' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import type { + SleepingAgentLaunchConfig, + SleepingAgentSessionRecord +} from '../../../../shared/agent-session-resume' +import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time' +import { isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { recordHibernationBoundaryResolved } from '@/lib/agent-hibernation-pane-age' +import { findAgentPaneWorktreeId, isAgentCompletionState } from './agent-status-pane-helpers' +import { pruneMigrationUnsupportedEntries } from './agent-status-map-helpers' +import { sleepingRecordFromEntry } from './agent-status-recovery-helpers' + +export type AgentStatusLiveFacts = { + existingSleepingRecord: SleepingAgentSessionRecord | undefined + liveRecoveryRecord: SleepingAgentSessionRecord | null + migrationUnsupported: ReturnType + completionRefreshWorktreeId: string | null + sortRelevantChange: boolean + retentionRelevantChange: boolean + boundaryResolved: boolean +} + +export type AgentStatusLiveFactsArgs = { + state: AppState + paneKey: string + entry: AgentStatusEntry + existing: AgentStatusEntry | undefined + launchConfigSource: SleepingAgentLaunchConfig | undefined + retainsResumableRecoveryIdentity: boolean + commandCodeNewTurn: boolean + updatedAt: number +} + +export function deriveAgentStatusLiveFacts(args: AgentStatusLiveFactsArgs): AgentStatusLiveFacts { + const { + state, + paneKey, + entry, + existing, + launchConfigSource, + retainsResumableRecoveryIdentity, + commandCodeNewTurn, + updatedAt + } = args + const boundaryResolved = + entry.state === 'done' && entry.sessionBoundary !== true && existing?.sessionBoundary === true + if (boundaryResolved) { + recordHibernationBoundaryResolved(paneKey, updatedAt) + } + const completionRefreshWorktreeId = + isAgentCompletionState(entry.state) && + entry.sessionBoundary !== true && + existing !== undefined && + !isAgentCompletionState(existing.state) + ? (entry.worktreeId ?? findAgentPaneWorktreeId(state, paneKey)) + : null + const wasFresh = + !!existing && isExplicitAgentStatusFresh(existing, updatedAt, AGENT_STATUS_STALE_AFTER_MS) + const attributionChanged = + existing?.worktreeId !== entry.worktreeId || existing?.tabId !== entry.tabId + const sameStateStateStartedAtChanged = + !!existing && existing.state === entry.state && entry.stateStartedAt !== existing.stateStartedAt + const sameStateDoneAttentionChanged = + existing?.state === 'done' && + entry.state === 'done' && + agentEntryCompletionAt(existing) !== agentEntryCompletionAt(entry) + const sortRelevantChange = + !existing || + existing.state !== entry.state || + !wasFresh || + attributionChanged || + commandCodeNewTurn || + sameStateStateStartedAtChanged || + sameStateDoneAttentionChanged + const doneRetentionFieldsChanged = + existing?.state === 'done' && + entry.state === 'done' && + (entry.prompt !== existing.prompt || + entry.updatedAt !== existing.updatedAt || + entry.stateStartedAt !== existing.stateStartedAt || + entry.agentType !== existing.agentType || + entry.model !== existing.model || + entry.terminalTitle !== existing.terminalTitle || + entry.toolName !== existing.toolName || + entry.toolInput !== existing.toolInput || + entry.lastAssistantMessage !== existing.lastAssistantMessage || + entry.orchestration !== existing.orchestration || + entry.subagents !== existing.subagents || + entry.providerSession !== existing.providerSession || + entry.interrupted !== existing.interrupted) + const retentionRelevantChange = + sortRelevantChange || + attributionChanged || + existing?.workingMode !== entry.workingMode || + doneRetentionFieldsChanged + const existingSleepingRecord = state.sleepingAgentSessionsByPaneKey[paneKey] + const liveRecoveryWorktreeId = + entry.state === 'done' && !retainsResumableRecoveryIdentity + ? null + : (entry.worktreeId ?? findAgentPaneWorktreeId(state, paneKey)) + const liveRecoveryRecord = liveRecoveryWorktreeId + ? sleepingRecordFromEntry({ + state, + entry: retainsResumableRecoveryIdentity + ? { ...entry, prompt: '', lastAssistantMessage: undefined } + : entry, + worktreeId: liveRecoveryWorktreeId, + capturedAt: updatedAt, + launchConfig: launchConfigSource, + origin: 'live' + }) + : null + const migrationUnsupported = pruneMigrationUnsupportedEntries( + state.migrationUnsupportedByPtyId, + (migrationEntry) => migrationEntry.paneKey === paneKey + ) + return { + existingSleepingRecord, + liveRecoveryRecord, + migrationUnsupported, + completionRefreshWorktreeId, + sortRelevantChange, + retentionRelevantChange, + boundaryResolved + } +} diff --git a/src/renderer/src/store/slices/agent-status-live-reducer.ts b/src/renderer/src/store/slices/agent-status-live-reducer.ts new file mode 100644 index 00000000000..2bae1cb0db0 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-live-reducer.ts @@ -0,0 +1,104 @@ +import type { AppState } from '../types' +import { capLiveAgentStatusesInPlace, classifyPaneKeyLiveness } from './agent-status-pane-helpers' +import { removePaneKeys } from './agent-status-map-helpers' +import { recoveryRecordMatches } from './agent-status-recovery-equivalence' +import type { AgentStatusLiveEntryBuild } from './agent-status-live-entry-builder' +import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' + +/** Apply the map changes associated with an accepted live status row. */ +export function reduceAgentStatusLiveUpdate( + state: AppState, + build: AgentStatusLiveEntryBuild, + updatedAt: number +): Partial { + const { + entry, + existingSleepingRecord, + liveRecoveryRecord, + registryEntry, + registryMatched, + providerSession, + providerSessionChanged, + migrationUnsupported, + retentionRelevantChange, + sortRelevantChange + } = build + const paneKey = entry.paneKey + let nextRetentionSuppressedPaneKeys = state.retentionSuppressedPaneKeys + if (paneKey in nextRetentionSuppressedPaneKeys) { + nextRetentionSuppressedPaneKeys = { ...nextRetentionSuppressedPaneKeys } + delete nextRetentionSuppressedPaneKeys[paneKey] + } + const nextRetainedAgents = + paneKey in state.retainedAgentsByPaneKey + ? { ...state.retainedAgentsByPaneKey } + : state.retainedAgentsByPaneKey + if (nextRetainedAgents !== state.retainedAgentsByPaneKey) { + delete nextRetainedAgents[paneKey] + } + let nextSleepingAgentSessions = state.sleepingAgentSessionsByPaneKey + let nextLaunchConfigs = state.agentLaunchConfigByPaneKey + if ( + registryMatched && + registryEntry && + providerSession && + !agentProviderSessionsEqual( + entry.agentType, + registryEntry.identity.providerSession, + providerSession + ) + ) { + nextLaunchConfigs = { + ...nextLaunchConfigs, + [paneKey]: { ...registryEntry, identity: { ...registryEntry.identity, providerSession } } + } + } + // Launch tokens authorize only the session they started; a completed turn or changed provider id consumes them. + if ( + (providerSessionChanged || (entry.state === 'done' && entry.sessionBoundary !== true)) && + paneKey in state.agentLaunchConfigByPaneKey + ) { + nextLaunchConfigs = { ...state.agentLaunchConfigByPaneKey } + delete nextLaunchConfigs[paneKey] + } + if (liveRecoveryRecord) { + if (!recoveryRecordMatches(existingSleepingRecord, liveRecoveryRecord)) { + nextSleepingAgentSessions = { + ...state.sleepingAgentSessionsByPaneKey, + [paneKey]: liveRecoveryRecord + } + } + } else if (existingSleepingRecord) { + nextSleepingAgentSessions = { ...state.sleepingAgentSessionsByPaneKey } + delete nextSleepingAgentSessions[paneKey] + } + const nextLive = { ...state.agentStatusByPaneKey, [paneKey]: entry } + const evictedPaneKeys = capLiveAgentStatusesInPlace( + nextLive, + paneKey, + () => classifyPaneKeyLiveness(state), + updatedAt + ) + const evictedOrphans = evictedPaneKeys.length > 0 + if (evictedOrphans) { + const evicted = new Set(evictedPaneKeys) + nextSleepingAgentSessions = removePaneKeys(nextSleepingAgentSessions, evicted) + nextLaunchConfigs = removePaneKeys(nextLaunchConfigs, evicted) + } + return { + agentStatusByPaneKey: nextLive, + retainedAgentsByPaneKey: nextRetainedAgents, + sleepingAgentSessionsByPaneKey: nextSleepingAgentSessions, + agentLaunchConfigByPaneKey: nextLaunchConfigs, + migrationUnsupportedByPtyId: migrationUnsupported.next, + retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys, + agentStatusEpoch: + retentionRelevantChange || migrationUnsupported.changed || evictedOrphans + ? state.agentStatusEpoch + 1 + : state.agentStatusEpoch, + sortEpoch: + sortRelevantChange || migrationUnsupported.changed || evictedOrphans + ? state.sortEpoch + 1 + : state.sortEpoch + } +} diff --git a/src/renderer/src/store/slices/agent-status-map-helpers.ts b/src/renderer/src/store/slices/agent-status-map-helpers.ts new file mode 100644 index 00000000000..7ebf55bbad0 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-map-helpers.ts @@ -0,0 +1,210 @@ +import type { AppState } from '../types' +import type { + AgentStatusOrchestrationContext, + MigrationUnsupportedPtyEntry +} from '../../../../shared/agent-status-types' +import { + getRepoExecutionHostId, + getWorktreeExecutionHostId +} from '../../../../shared/execution-host' +import { getTabIdFromPaneKey } from './agent-status-pane-helpers' +import { registryEntryMatchesStatus } from './agent-status-launch-config' +import type { AgentLaunchConfigStatusMetadata } from './agent-status-contract' +import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' + +export const RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 +export const RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX = 1024 + +// delete-then-set for LRU recency, then evict oldest keys past the cap (Record iterates +// insertion order); safe because a status for a tab closed >MAX tabs ago cannot still arrive. +export function boundRecentlyClosedAgentStatusTabIds( + existing: Record, + tabId: string +): Record { + const next: Record = {} + for (const key of Object.keys(existing)) { + if (key !== tabId) { + next[key] = true + } + } + next[tabId] = true + const keys = Object.keys(next) + if (keys.length > RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX) { + for (const stale of keys.slice(0, keys.length - RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX)) { + delete next[stale] + } + } + return next +} + +export function boundRecentlyRetiredAgentStatusPaneKeys( + existing: Record, + paneKeys: readonly string[] +): Record { + const additions = new Set(paneKeys) + const next: Record = {} + for (const key of Object.keys(existing)) { + if (!additions.has(key)) { + next[key] = true + } + } + for (const paneKey of additions) { + next[paneKey] = true + } + const keys = Object.keys(next) + for (const stale of keys.slice(0, -RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX)) { + delete next[stale] + } + return next +} + +export function movePaneKeyedRecord( + record: Record, + fromPaneKey: string, + toPaneKey: string, + transform: (value: T) => T = (value) => value +): Record { + const value = record[fromPaneKey] + if (value === undefined || fromPaneKey === toPaneKey) { + return record + } + const next = { ...record } + delete next[fromPaneKey] + next[toPaneKey] = transform(value) + return next +} + +export function removePaneKeys( + record: Record, + paneKeys: ReadonlySet +): Record { + const matchingKeys = Object.keys(record).filter((key) => paneKeys.has(key)) + if (matchingKeys.length === 0) { + return record + } + const next = { ...record } + for (const key of matchingKeys) { + delete next[key] + } + return next +} + +export function getLaunchConfigForStatusMetadata( + state: AppState, + metadata: AgentLaunchConfigStatusMetadata +): SleepingAgentLaunchConfig | undefined { + const registryEntry = state.agentLaunchConfigByPaneKey[metadata.paneKey] + return registryEntryMatchesStatus({ + entry: registryEntry, + paneKey: metadata.paneKey, + agentType: metadata.agentType, + tabId: metadata.tabId ?? getTabIdFromPaneKey(metadata.paneKey) ?? undefined, + terminalHandle: metadata.terminalHandle, + launchToken: metadata.launchToken, + providerSession: metadata.providerSession, + existingProviderSession: metadata.existingProviderSession, + providerSessionChanged: metadata.providerSessionChanged ?? false + }) + ? registryEntry?.launchConfig + : undefined +} + +export function pruneMigrationUnsupportedEntries( + entries: Record, + predicate: (entry: MigrationUnsupportedPtyEntry) => boolean +): { next: Record; changed: boolean } { + let changed = false + const next: Record = {} + for (const [ptyId, entry] of Object.entries(entries)) { + if (predicate(entry)) { + changed = true + continue + } + next[ptyId] = entry + } + return { next: changed ? next : entries, changed } +} + +export function orchestrationContextsEqual( + a: AgentStatusOrchestrationContext, + b: AgentStatusOrchestrationContext +): boolean { + return ( + a.taskId === b.taskId && + a.dispatchId === b.dispatchId && + a.dispatchStatus === b.dispatchStatus && + a.taskTitle === b.taskTitle && + a.displayName === b.displayName && + a.parentTerminalHandle === b.parentTerminalHandle && + a.parentPaneKey === b.parentPaneKey && + a.coordinatorHandle === b.coordinatorHandle && + a.orchestrationRunId === b.orchestrationRunId + ) +} + +export function orchestrationMapsEqual( + a: Record, + b: Record +): boolean { + const aKeys = Object.keys(a) + const bKeys = Object.keys(b) + if (aKeys.length !== bKeys.length) { + return false + } + return aKeys.every((key) => b[key] !== undefined && orchestrationContextsEqual(a[key]!, b[key]!)) +} + +export function mergeCurrentOrchestrationContext( + existing: AgentStatusOrchestrationContext | undefined, + current: AgentStatusOrchestrationContext +): AgentStatusOrchestrationContext { + if (!existing) { + return current + } + const sameDispatch = + existing.taskId === current.taskId && existing.dispatchId === current.dispatchId + if (!sameDispatch) { + return current + } + if (current.dispatchStatus !== undefined) { + return orchestrationContextsEqual(existing, current) ? existing : current + } + const merged = { ...existing, ...current } + return orchestrationContextsEqual(existing, merged) ? existing : merged +} + +// Why: relay/daemon teardown drops main's rows, but renderer entries whose connectionId stamp never +// matched (unstamped over SSH) survive and stay "fresh" 30 min (#9030). Resolve each worktree's host +// via the canonical hostId-first precedence and keep only ids UNAMBIGUOUSLY on this connection — a +// worktree id is `${repoId}::${path}` (no host component), so the same project mirrored at the same +// path on two hosts yields one shared id that must not clear another host's live rows. +export function collectWorktreeIdsForConnection( + state: AppState, + connectionId: string +): Set { + const hostIdsOnConnection = new Set( + state.repos + .filter((repo) => repo.connectionId === connectionId) + .map((repo) => getRepoExecutionHostId(repo)) + ) + if (hostIdsOnConnection.size === 0) { + return new Set() + } + const repoById = new Map(state.repos.map((repo) => [repo.id, repo] as const)) + const onConnection = new Set() + const onOtherHost = new Set() + for (const [repoId, worktrees] of Object.entries(state.worktreesByRepo)) { + const repo = repoById.get(repoId) + for (const worktree of worktrees) { + const bucket = hostIdsOnConnection.has(getWorktreeExecutionHostId(worktree, repo)) + ? onConnection + : onOtherHost + bucket.add(worktree.id) + } + } + // A worktree id that also lives on another host is ambiguous — leave it rather than hide a live row. + for (const id of onOtherHost) { + onConnection.delete(id) + } + return onConnection +} diff --git a/src/renderer/src/store/slices/agent-status-orchestration-actions.ts b/src/renderer/src/store/slices/agent-status-orchestration-actions.ts new file mode 100644 index 00000000000..2d038ebf29e --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-orchestration-actions.ts @@ -0,0 +1,103 @@ +import type { + AgentStatusEntry, + AgentStatusOrchestrationContext +} from '../../../../shared/agent-status-types' +import type { AgentStatusSlice } from './agent-status-slice-contract' +import type { AgentStatusRuntime } from './agent-status-runtime' +import { + getAgentRowGeneratedTitleText, + orchestrationLabelsMatchLiveDispatch +} from '@/lib/agent-row-primary-text' +import { + mergeCurrentOrchestrationContext, + orchestrationMapsEqual +} from './agent-status-map-helpers' + +export function createAgentStatusOrchestrationActions( + runtime: AgentStatusRuntime +): Pick { + const { get, set } = runtime + return { + setRuntimeAgentOrchestrationByPaneKey: ( + entries: Record + ) => { + const generatedTitleUpdates: AgentStatusEntry[] = [] + set((s) => { + const runtimeMapChanged = !orchestrationMapsEqual( + s.runtimeAgentOrchestrationByPaneKey, + entries + ) + let nextLive = s.agentStatusByPaneKey + let liveChanged = false + let nextRetained = s.retainedAgentsByPaneKey + let retainedChanged = false + + for (const [paneKey, runtimeOrchestration] of Object.entries(entries)) { + const liveEntry = nextLive[paneKey] + if (liveEntry) { + const merged = mergeCurrentOrchestrationContext( + liveEntry.orchestration, + runtimeOrchestration + ) + if (merged !== liveEntry.orchestration) { + if (!liveChanged) { + nextLive = { ...nextLive } + liveChanged = true + } + const nextEntry = { ...liveEntry, orchestration: merged } + nextLive[paneKey] = nextEntry + // Why: only replace titles when labels match the live dispatch taskId; sticky completed context must not rename a later turn. + if ( + (merged.displayName?.trim() || merged.taskTitle?.trim()) && + orchestrationLabelsMatchLiveDispatch({ + prompt: nextEntry.prompt, + orchestration: merged + }) + ) { + generatedTitleUpdates.push(nextEntry) + } + } + } + + const retainedEntry = nextRetained[paneKey] + if (retainedEntry) { + const merged = mergeCurrentOrchestrationContext( + retainedEntry.entry.orchestration, + runtimeOrchestration + ) + if (merged !== retainedEntry.entry.orchestration) { + if (!retainedChanged) { + nextRetained = { ...nextRetained } + retainedChanged = true + } + nextRetained[paneKey] = { + ...retainedEntry, + entry: { ...retainedEntry.entry, orchestration: merged } + } + } + } + } + + if (!runtimeMapChanged && !liveChanged && !retainedChanged) { + return s + } + + return { + ...(runtimeMapChanged ? { runtimeAgentOrchestrationByPaneKey: entries } : {}), + ...(liveChanged ? { agentStatusByPaneKey: nextLive } : {}), + ...(retainedChanged ? { retainedAgentsByPaneKey: nextRetained } : {}), + ...(liveChanged ? { agentStatusEpoch: s.agentStatusEpoch + 1 } : {}) + } + }) + for (const entry of generatedTitleUpdates) { + get().setGeneratedTabTitleFromAgentPrompt( + entry.paneKey, + getAgentRowGeneratedTitleText(entry), + { + replaceExistingGeneratedTitle: true + } + ) + } + } + } +} diff --git a/src/renderer/src/store/slices/agent-status-pane-helpers.ts b/src/renderer/src/store/slices/agent-status-pane-helpers.ts new file mode 100644 index 00000000000..49e0fc53af6 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-pane-helpers.ts @@ -0,0 +1,277 @@ +import type { AppState } from '../types' +import type { + AgentStatusEntry, + AgentType, + ParsedAgentStatusPayload +} from '../../../../shared/agent-status-types' +import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { DropAgentStatusByWorktreeOptions, RetainedAgentEntry } from './agent-status-contract' +import type { AgentStatusTabPrefixDropState } from './agent-status-drop-reducer' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' + +const MAX_RETAINED_AGENTS = 500 + +export function capRetainedAgents( + retained: Record, + maxEntries = MAX_RETAINED_AGENTS +): Record { + const keys = Object.keys(retained) + if (keys.length <= maxEntries) { + return retained + } + const capped: Record = {} + for (const key of keys.slice(keys.length - maxEntries)) { + capped[key] = retained[key] + } + return capped +} + +// Why: missed pane teardown can leak heavy live rows in any state and amplify every status-map copy (#9872). +export const MAX_LIVE_AGENT_STATUSES = 500 + +type PaneLiveness = 'live' | 'dead' | 'unprovable' + +// Why: only a rooted tab proves which leaves are mounted; rootless and headless rows may still be live (#2962). +export function classifyPaneKeyLiveness(state: AppState): (paneKey: string) => PaneLiveness { + const rootedLeafKeys = new Set() + const rootedTabIds = new Set() + for (const [tabId, layout] of Object.entries(state.terminalLayoutsByTabId)) { + if (!layout?.root) { + continue + } + rootedTabIds.add(tabId) + const stack: TerminalPaneLayoutNode[] = [layout.root] + while (stack.length > 0) { + const node = stack.pop()! + if (node.type === 'leaf') { + rootedLeafKeys.add(`${tabId}:${node.leafId}`) + } else { + stack.push(node.first, node.second) + } + } + } + return (paneKey) => { + if (rootedLeafKeys.has(paneKey)) { + return 'live' + } + const tabId = getTabIdFromPaneKey(paneKey) + return tabId !== null && rootedTabIds.has(tabId) ? 'dead' : 'unprovable' + } +} + +// Why: mutate the caller-owned spread so eviction does not allocate another heavy-map copy. +export function capLiveAgentStatusesInPlace( + freshLive: Record, + protectedPaneKey: string, + buildClassifier: () => (paneKey: string) => PaneLiveness, + now: number, + maxEntries = MAX_LIVE_AGENT_STATUSES +): string[] { + const keys = Object.keys(freshLive) + let overflow = keys.length - maxEntries + if (overflow <= 0) { + return [] + } + const classify = buildClassifier() + const evictedPaneKeys: string[] = [] + const sweep = (canEvict: (liveness: PaneLiveness, entry: AgentStatusEntry) => boolean): void => { + for (const key of keys) { + if (overflow <= 0) { + break + } + if (key === protectedPaneKey || !(key in freshLive)) { + continue + } + const liveness = classify(key) + if (liveness === 'live' || !canEvict(liveness, freshLive[key])) { + continue + } + delete freshLive[key] + overflow -= 1 + evictedPaneKeys.push(key) + } + } + // Prefer rows that are provably dead or too stale to represent a live agent. + sweep( + (liveness, entry) => liveness === 'dead' || now - entry.updatedAt > AGENT_STATUS_STALE_AFTER_MS + ) + // Shed fresh unprovable rows only when needed; rooted live panes make this a soft cap. + if (overflow > 0) { + sweep(() => true) + } + return evictedPaneKeys +} + +export function paneKeyMatchesAnyTabPrefix(paneKey: string, tabPrefixes: string[]): boolean { + for (const prefix of tabPrefixes) { + if (paneKey.startsWith(prefix)) { + return true + } + } + return false +} + +export function isAgentCompletionState(state: ParsedAgentStatusPayload['state']): boolean { + return state === 'done' || state === 'waiting' || state === 'blocked' +} + +export function getTabIdFromPaneKey(paneKey: string): string | null { + const separator = paneKey.indexOf(':') + if (separator <= 0 || separator !== paneKey.lastIndexOf(':')) { + return null + } + return paneKey.slice(0, separator) +} + +/** True when auto-title generation would no-op without replace (custom/quick/generated). */ +export function agentStatusTabAlreadyHasProtectedOrGeneratedTitle( + state: AppState, + tabId: string | null, + worktreeId?: string | null +): boolean { + if (!tabId) { + return false + } + const ownerTabs = worktreeId ? state.tabsByWorktree[worktreeId] : undefined + if (ownerTabs) { + const tab = ownerTabs.find((candidate) => candidate.id === tabId) + return Boolean( + tab?.customTitle?.trim() || tab?.quickCommandLabel?.trim() || tab?.generatedTitle?.trim() + ) + } + for (const tabs of Object.values(state.tabsByWorktree)) { + const tab = tabs.find((candidate) => candidate.id === tabId) + if (!tab) { + continue + } + return Boolean( + tab.customTitle?.trim() || tab.quickCommandLabel?.trim() || tab.generatedTitle?.trim() + ) + } + return false +} + +export function getLeafIdFromPaneKey(paneKey: string): string | null { + const separator = paneKey.indexOf(':') + if (separator <= 0 || separator !== paneKey.lastIndexOf(':')) { + return null + } + const leafId = paneKey.slice(separator + 1) + return leafId.length > 0 ? leafId : null +} + +export function findCompletedOrphanPaneKeysForTabClose( + state: AgentStatusTabPrefixDropState, + worktreeId: string | undefined, + prefix: string +): string[] { + if (!worktreeId) { + return [] + } + const openTabIds = new Set((state.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)) + const paneKeys: string[] = [] + for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { + if (paneKey.startsWith(prefix) || entry.state !== 'done' || entry.worktreeId !== worktreeId) { + continue + } + const tabId = getTabIdFromPaneKey(paneKey) + if (!tabId || openTabIds.has(tabId)) { + continue + } + paneKeys.push(paneKey) + } + return paneKeys +} + +export function isRecentlyClosedAgentStatusTab( + closedTabs: Record, + tabId: string | null +): boolean { + if (!tabId) { + return false + } + return closedTabs[tabId] === true +} + +export function findAgentPaneWorktreeId(state: AppState, paneKey: string): string | null { + const tabId = getTabIdFromPaneKey(paneKey) + if (!tabId) { + return null + } + for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { + if (tabs.some((tab) => tab.id === tabId)) { + return worktreeId + } + } + return null +} + +export function findTabForAgentEntry( + state: AppState, + worktreeId: string, + entry: AgentStatusEntry +): TerminalTab | undefined { + const tabId = entry.tabId ?? getTabIdFromPaneKey(entry.paneKey) + if (!tabId) { + return undefined + } + return (state.tabsByWorktree[worktreeId] ?? []).find((tab) => tab.id === tabId) +} + +export function getRetainedFallbackTab(entry: AgentStatusEntry, worktreeId: string): TerminalTab { + const tabId = entry.tabId ?? getTabIdFromPaneKey(entry.paneKey) ?? entry.paneKey + return { + id: tabId, + ptyId: null, + worktreeId, + title: entry.terminalTitle ?? 'Agent', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: entry.stateStartedAt + } +} + +export function retainedAgentEntryFromLive( + state: AppState, + worktreeId: string, + entry: AgentStatusEntry, + agentType: AgentType +): RetainedAgentEntry { + const tab = + findTabForAgentEntry(state, worktreeId, entry) ?? getRetainedFallbackTab(entry, worktreeId) + return { + entry, + worktreeId, + tab, + agentType, + startedAt: entry.stateHistory[0]?.startedAt ?? entry.stateStartedAt + } +} + +export function shouldReplaceRetainedWithLive( + retained: RetainedAgentEntry | undefined, + live: RetainedAgentEntry +): boolean { + if (!retained) { + return true + } + if (live.startedAt !== retained.startedAt) { + return live.startedAt > retained.startedAt + } + const retainedSessionId = retained.entry.providerSession?.id + const liveSessionId = live.entry.providerSession?.id + if (retainedSessionId && liveSessionId && retainedSessionId !== liveSessionId) { + return live.entry.updatedAt >= retained.entry.updatedAt + } + return live.entry.updatedAt > retained.entry.updatedAt +} + +export function normalizePaneKeySet( + paneKeys: DropAgentStatusByWorktreeOptions['sleepingPaneKeys'] +): ReadonlySet | null { + if (!paneKeys) { + return null + } + return paneKeys instanceof Set ? paneKeys : new Set(paneKeys) +} diff --git a/src/renderer/src/store/slices/agent-status-provider-session-actions.ts b/src/renderer/src/store/slices/agent-status-provider-session-actions.ts new file mode 100644 index 00000000000..36e1f71ab09 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-provider-session-actions.ts @@ -0,0 +1,177 @@ +import type { AgentStatusSlice } from './agent-status-slice-contract' +import type { AgentStatusRuntime } from './agent-status-runtime' +import type { + AgentProviderSessionRecordMetadata, + AgentProviderSessionRouting, + AgentProviderSessionTiming +} from './agent-status-contract' +import { + agentProviderSessionsEqual, + getAgentResumeArgv, + type ResumableTuiAgent, + type AgentProviderSessionMetadata, + type SleepingAgentSessionRecord +} from '../../../../shared/agent-session-resume' +import { resolveAgentPaneAuthorityKey } from './agent-pane-authority' +import { + findAgentPaneWorktreeId, + getTabIdFromPaneKey, + isRecentlyClosedAgentStatusTab +} from './agent-status-pane-helpers' +import { removePaneKeys } from './agent-status-map-helpers' +import { registryEntryMatchesStatus } from './agent-status-launch-config' +import { copyLaunchConfig } from './agent-status-recovery-helpers' + +export function createAgentStatusProviderSessionActions( + runtime: AgentStatusRuntime +): Pick { + const { get, set, requestFreshness } = runtime + return { + recordAgentProviderSession: ( + paneKey: string, + agent: ResumableTuiAgent, + providerSession: AgentProviderSessionMetadata, + timing?: AgentProviderSessionTiming, + routing?: AgentProviderSessionRouting, + metadata?: AgentProviderSessionRecordMetadata + ) => { + paneKey = resolveAgentPaneAuthorityKey(paneKey) + const updatedAt = timing?.updatedAt ?? Date.now() + if ( + paneKey in get().recentlyRetiredAgentStatusPaneKeys || + isRecentlyClosedAgentStatusTab( + get().recentlyClosedAgentStatusTabIds, + getTabIdFromPaneKey(paneKey) + ) || + !getAgentResumeArgv(agent, providerSession) + ) { + return + } + let removedLiveStatus = false + set((s) => { + const existingStatus = s.agentStatusByPaneKey[paneKey] + const existingRecord = s.sleepingAgentSessionsByPaneKey[paneKey] + if ( + (existingStatus && updatedAt < existingStatus.updatedAt) || + (existingRecord && updatedAt < existingRecord.updatedAt) + ) { + return s + } + const tabId = routing?.tabId ?? getTabIdFromPaneKey(paneKey) ?? existingRecord?.tabId + const worktreeId = + routing?.worktreeId ?? + existingStatus?.worktreeId ?? + existingRecord?.worktreeId ?? + findAgentPaneWorktreeId(s, paneKey) + if (!worktreeId) { + return s + } + const registryEntry = s.agentLaunchConfigByPaneKey[paneKey] + const registryMatches = registryEntryMatchesStatus({ + entry: registryEntry, + paneKey, + agentType: agent, + tabId, + terminalHandle: undefined, + launchToken: metadata?.launchToken, + providerSession, + existingProviderSession: existingRecord?.providerSession, + providerSessionChanged: false + }) + const existingRecordMatchesProviderSession = + existingRecord?.agent === agent && + agentProviderSessionsEqual(agent, existingRecord.providerSession, providerSession) + // Why: provider-session heartbeats can arrive after the turn is complete; preserve the + // completed checkpoint so a late heartbeat cannot make it eligible for ghost resume. + const preservesCompletedRecoveryRecord = + existingRecordMatchesProviderSession && existingRecord?.state === 'done' + // Why: an explicit quit capture must remain the resume handle until a new provider session replaces it. + const preservesQuitOrigin = + existingRecordMatchesProviderSession && existingRecord?.origin === 'quit' + const launchConfig = + (registryMatches ? registryEntry?.launchConfig : undefined) ?? + (existingRecordMatchesProviderSession ? existingRecord.launchConfig : undefined) + const record: SleepingAgentSessionRecord = { + paneKey, + ...(tabId ? { tabId } : {}), + worktreeId, + agent, + providerSession, + prompt: '', + // Why: durable process/session identity, not visible turn state; a non-done value keeps cold restore eligible. + state: preservesCompletedRecoveryRecord ? 'done' : 'working', + capturedAt: updatedAt, + updatedAt, + ...(existingStatus?.terminalTitle + ? { terminalTitle: existingStatus.terminalTitle } + : existingRecord?.terminalTitle + ? { terminalTitle: existingRecord.terminalTitle } + : {}), + ...(routing?.connectionId !== undefined + ? { connectionId: routing.connectionId } + : existingRecord?.connectionId !== undefined + ? { connectionId: existingRecord.connectionId } + : {}), + ...(launchConfig ? { launchConfig: copyLaunchConfig(launchConfig) } : {}), + ...(existingRecordMatchesProviderSession && + existingRecord.automaticResumeBlockedBy === 'legacy-orchestration-worker' + ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' } + : {}), + ...(preservesCompletedRecoveryRecord && existingRecord.interrupted !== undefined + ? { interrupted: existingRecord.interrupted } + : {}), + origin: preservesQuitOrigin ? 'quit' : 'live' + } + removedLiveStatus = existingStatus !== undefined + const nextLive = removedLiveStatus ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey + if (removedLiveStatus) { + delete nextLive[paneKey] + } + const nextRetained = + paneKey in s.retainedAgentsByPaneKey + ? { ...s.retainedAgentsByPaneKey } + : s.retainedAgentsByPaneKey + if (nextRetained !== s.retainedAgentsByPaneKey) { + delete nextRetained[paneKey] + } + // Why: on identity mismatch the sleeping record drops its launch config, so clear the stale + // registry entry too, else a later return to the old identity reuses stale args/env. + let nextLaunchConfigs = s.agentLaunchConfigByPaneKey + if (registryMatches && registryEntry) { + nextLaunchConfigs = { + ...nextLaunchConfigs, + [paneKey]: { + ...registryEntry, + identity: { ...registryEntry.identity, providerSession } + } + } + } else if (registryEntry) { + nextLaunchConfigs = { ...nextLaunchConfigs } + delete nextLaunchConfigs[paneKey] + } + return { + agentStatusByPaneKey: nextLive, + retainedAgentsByPaneKey: nextRetained, + sleepingAgentSessionsByPaneKey: { + ...s.sleepingAgentSessionsByPaneKey, + [paneKey]: record + }, + agentLaunchConfigByPaneKey: nextLaunchConfigs, + acknowledgedAgentsByPaneKey: removePaneKeys( + s.acknowledgedAgentsByPaneKey, + new Set([paneKey]) + ), + unreadAgentCompletionPanes: removePaneKeys( + s.unreadAgentCompletionPanes, + new Set([paneKey]) + ), + agentStatusEpoch: removedLiveStatus ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, + sortEpoch: removedLiveStatus ? s.sortEpoch + 1 : s.sortEpoch + } + }) + if (removedLiveStatus) { + requestFreshness(true) + } + } + } +} diff --git a/src/renderer/src/store/slices/agent-status-recovery-actions.ts b/src/renderer/src/store/slices/agent-status-recovery-actions.ts new file mode 100644 index 00000000000..d3109b3cb88 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-recovery-actions.ts @@ -0,0 +1,198 @@ +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import type { AgentStatusSlice } from './agent-status-slice-contract' +import type { AgentStatusRuntime } from './agent-status-runtime' +import { collectSleepingAgentSessionRecordsForWorktree } from './agent-status-recovery-collection' +import { + removeSleepingRecordsReplacedByManualWorktreeSleep, + sleepingRecordFromEntry +} from './agent-status-recovery-helpers' +import { + recoveryRecordTargetsSameSession, + sleepingRecordsEquivalentIgnoringCaptureTime +} from './agent-status-recovery-equivalence' +import { getLaunchConfigForEntry } from './agent-status-launch-config' +import { findAgentPaneWorktreeId } from './agent-status-pane-helpers' +import { isCompletedPiCompatibleAgentWithLiveRecoveryRecord } from '@/lib/live-resume-anchor-record' + +export function createAgentStatusRecoveryActions( + runtime: AgentStatusRuntime +): Pick< + AgentStatusSlice, + | 'captureSleepingAgentSessionsByWorktree' + | 'captureAllSleepingAgentSessions' + | 'clearSleepingAgentSession' + | 'clearSleepingAgentSessionsByPaneKey' + | 'setSleepingAgentAutomaticResumeBlocked' + | 'clearSleepingAgentSessionsByWorktree' + | 'pruneSleepingAgentSessions' +> { + const { set, clearSleepingAgentSessionsByPaneKey } = runtime + return { + captureSleepingAgentSessionsByWorktree: (worktreeId, paneKeys) => { + set((s) => { + const records = collectSleepingAgentSessionRecordsForWorktree(s, worktreeId, { + paneKeys, + captureMode: 'manual-worktree-sleep' + }) + const replaced = removeSleepingRecordsReplacedByManualWorktreeSleep( + s.sleepingAgentSessionsByPaneKey, + worktreeId, + paneKeys, + records + ) + let next = { ...replaced.records } + let changed = replaced.changed + for (const record of Object.values(records)) { + if (next[record.paneKey] !== record) { + next[record.paneKey] = record + changed = true + } + } + return changed ? { sleepingAgentSessionsByPaneKey: next } : s + }) + }, + + captureAllSleepingAgentSessions: (mode) => { + set((s) => { + const capturedAt = Date.now() + const origin = mode === 'quit' ? ('quit' as const) : ('live' as const) + const next: Record = { + ...s.sleepingAgentSessionsByPaneKey + } + let changed = false + for (const entry of Object.values(s.agentStatusByPaneKey)) { + if (entry.state === 'done') { + const existing = next[entry.paneKey] + if ( + !isCompletedPiCompatibleAgentWithLiveRecoveryRecord(entry, existing) || + mode === 'periodic' + ) { + continue + } + const record = { ...existing, capturedAt, origin } + if (!sleepingRecordsEquivalentIgnoringCaptureTime(existing, record)) { + next[entry.paneKey] = record + changed = true + } + continue + } + const worktreeId = entry.worktreeId ?? findAgentPaneWorktreeId(s, entry.paneKey) + if (!worktreeId) { + continue + } + const record = sleepingRecordFromEntry({ + state: s, + entry, + worktreeId, + capturedAt, + launchConfig: getLaunchConfigForEntry(s, entry), + origin + }) + const existing = next[entry.paneKey] + if ( + mode === 'periodic' && + existing?.origin === 'quit' && + record && + recoveryRecordTargetsSameSession(existing, record) + ) { + continue + } + if (record && !sleepingRecordsEquivalentIgnoringCaptureTime(existing, record)) { + next[record.paneKey] = record + changed = true + } + } + return changed ? { sleepingAgentSessionsByPaneKey: next } : s + }) + }, + + clearSleepingAgentSession: (paneKey) => clearSleepingAgentSessionsByPaneKey([paneKey]), + clearSleepingAgentSessionsByPaneKey, + + setSleepingAgentAutomaticResumeBlocked: (paneKey, blocked) => { + set((s) => { + const current = s.sleepingAgentSessionsByPaneKey[paneKey] + if ( + !current || + (blocked + ? current.automaticResumeBlockedBy === 'legacy-orchestration-worker' + : current.automaticResumeBlockedBy === undefined) + ) { + return s + } + const next = { ...current } + if (blocked) { + next.automaticResumeBlockedBy = 'legacy-orchestration-worker' + } else { + delete next.automaticResumeBlockedBy + } + return { + sleepingAgentSessionsByPaneKey: { + ...s.sleepingAgentSessionsByPaneKey, + [paneKey]: next + } + } + }) + }, + + clearSleepingAgentSessionsByWorktree: (worktreeId) => { + set((s) => { + let changed = false + const next: Record = {} + const removed: string[] = [] + for (const [paneKey, record] of Object.entries(s.sleepingAgentSessionsByPaneKey)) { + if (record.worktreeId === worktreeId) { + changed = true + removed.push(paneKey) + } else { + next[paneKey] = record + } + } + if (!changed) { + return s + } + const nextLaunch = + removed.length > 0 ? { ...s.agentLaunchConfigByPaneKey } : s.agentLaunchConfigByPaneKey + for (const paneKey of removed) { + delete nextLaunch[paneKey] + } + return { + sleepingAgentSessionsByPaneKey: next, + ...(nextLaunch !== s.agentLaunchConfigByPaneKey + ? { agentLaunchConfigByPaneKey: nextLaunch } + : {}) + } + }) + }, + + pruneSleepingAgentSessions: (validWorktreeIds) => { + set((s) => { + let changed = false + const next: Record = {} + const removed: string[] = [] + for (const [paneKey, record] of Object.entries(s.sleepingAgentSessionsByPaneKey)) { + if (!validWorktreeIds.has(record.worktreeId)) { + changed = true + removed.push(paneKey) + } else { + next[paneKey] = record + } + } + if (!changed) { + return s + } + const nextLaunch = + removed.length > 0 ? { ...s.agentLaunchConfigByPaneKey } : s.agentLaunchConfigByPaneKey + for (const paneKey of removed) { + delete nextLaunch[paneKey] + } + return { + sleepingAgentSessionsByPaneKey: next, + ...(nextLaunch !== s.agentLaunchConfigByPaneKey + ? { agentLaunchConfigByPaneKey: nextLaunch } + : {}) + } + }) + } + } +} diff --git a/src/renderer/src/store/slices/agent-status-recovery-collection.ts b/src/renderer/src/store/slices/agent-status-recovery-collection.ts new file mode 100644 index 00000000000..d5c0b1cd37b --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-recovery-collection.ts @@ -0,0 +1,174 @@ +import type { AppState } from '../types' +import { + getAgentResumeArgv, + type SleepingAgentSessionRecord +} from '../../../../shared/agent-session-resume' +import type { RetainedAgentEntry } from './agent-status-contract' +import { + normalizePaneKeySet, + paneKeyMatchesAnyTabPrefix, + retainedAgentEntryFromLive +} from './agent-status-pane-helpers' +import { + carryOverAutomaticResumeBlock, + isValidCompletedAgentHibernationEntry, + manualSleepCaptureEntry, + markManualSleepLazyRestore, + normalizeSleepingAgentSessionCollectOptions, + sleepingRecordFromEntry, + type CollectSleepingAgentSessionRecordsOptions +} from './agent-status-recovery-helpers' +import { getLaunchConfigForEntry } from './agent-status-launch-config' +import { isCompletedPiCompatibleAgentWithLiveRecoveryRecord } from '@/lib/live-resume-anchor-record' + +export function collectSleepingAgentSessionRecordsForWorktree( + state: AppState, + worktreeId: string, + options?: readonly string[] | CollectSleepingAgentSessionRecordsOptions +): Record { + const capturedAt = Date.now() + const collectOptions = normalizeSleepingAgentSessionCollectOptions(options) + const allowedPaneKeys = collectOptions.paneKeys ? new Set(collectOptions.paneKeys) : null + const isManualWorktreeSleep = collectOptions.captureMode === 'manual-worktree-sleep' + const isCompletedAgentHibernation = collectOptions.captureMode === 'completed-agent-hibernation' + const isWorktreeOwnedCapture = isManualWorktreeSleep || isCompletedAgentHibernation + // Why: hibernated completions are intentional worktree-owned records; wake treats + // originless completed records as ambiguous legacy captures. + const origin: SleepingAgentSessionRecord['origin'] | undefined = isWorktreeOwnedCapture + ? 'worktree-sleep' + : undefined + const tabPrefixes = (state.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) + const records: Record = {} + const promotedLiveRecoveryPaneKeys = new Set() + + if (isManualWorktreeSleep) { + for (const existing of Object.values(state.sleepingAgentSessionsByPaneKey)) { + const liveEntry = state.agentStatusByPaneKey[existing.paneKey] + if ( + existing.worktreeId !== worktreeId || + existing.origin !== 'live' || + (liveEntry !== undefined && + !isCompletedPiCompatibleAgentWithLiveRecoveryRecord(liveEntry, existing)) || + (allowedPaneKeys && !allowedPaneKeys.has(existing.paneKey)) || + !getAgentResumeArgv(existing.agent, existing.providerSession) + ) { + continue + } + // Why: Pi identity is resumable with no turn row and while idle after done, so manual + // sleep must promote both instead of deleting the checkpoint. + records[existing.paneKey] = { + ...existing, + state: 'working', + capturedAt, + updatedAt: capturedAt, + origin: 'worktree-sleep' + } + promotedLiveRecoveryPaneKeys.add(existing.paneKey) + } + } + + for (const retained of Object.values(state.retainedAgentsByPaneKey)) { + if (isCompletedAgentHibernation) { + continue + } + if (allowedPaneKeys && !allowedPaneKeys.has(retained.entry.paneKey)) { + continue + } + if (retained.worktreeId !== worktreeId) { + continue + } + // Why: the promoted checkpoint carries recovery identity (transcript, connection) a retained + // turn row lacks, so it must not be overwritten by a re-derived record. + if (promotedLiveRecoveryPaneKeys.has(retained.entry.paneKey)) { + continue + } + const record = sleepingRecordFromEntry({ + state, + entry: isManualWorktreeSleep + ? manualSleepCaptureEntry(retained.entry, capturedAt) + : retained.entry, + worktreeId, + tab: retained.tab, + capturedAt, + launchConfig: getLaunchConfigForEntry(state, retained.entry), + origin + }) + if (record) { + if (isManualWorktreeSleep) { + markManualSleepLazyRestore(record) + carryOverAutomaticResumeBlock( + record, + state.sleepingAgentSessionsByPaneKey[retained.entry.paneKey] + ) + } + records[record.paneKey] = record + } + } + + for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { + if (allowedPaneKeys && !allowedPaneKeys.has(paneKey)) { + continue + } + // Why: the promoted checkpoint carries recovery identity (transcript, connection) the live + // turn row lacks, so it must not be overwritten by a re-derived record. + if (promotedLiveRecoveryPaneKeys.has(paneKey)) { + continue + } + const belongsToWorktree = + entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) + if (!belongsToWorktree) { + continue + } + if (isCompletedAgentHibernation && !isValidCompletedAgentHibernationEntry(entry)) { + continue + } + const record = sleepingRecordFromEntry({ + state, + entry: isManualWorktreeSleep ? manualSleepCaptureEntry(entry, capturedAt) : entry, + worktreeId, + capturedAt, + launchConfig: getLaunchConfigForEntry(state, entry), + origin + }) + if (record) { + if (isManualWorktreeSleep) { + markManualSleepLazyRestore(record) + carryOverAutomaticResumeBlock(record, state.sleepingAgentSessionsByPaneKey[paneKey]) + } + records[record.paneKey] = record + } + } + + return records +} + +export function collectHibernatedCompletionEvidenceForWorktree( + state: AppState, + worktreeId: string, + paneKeys?: readonly string[] +): RetainedAgentEntry[] { + const allowedPaneKeys = normalizePaneKeySet(paneKeys) + if (!allowedPaneKeys || allowedPaneKeys.size === 0) { + return [] + } + const tabPrefixes = (state.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) + const retained: RetainedAgentEntry[] = [] + for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { + const agentType = entry.agentType + if ( + !allowedPaneKeys.has(paneKey) || + entry.state !== 'done' || + agentType === undefined || + entry.interrupted === true + ) { + continue + } + const belongsToWorktree = + entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) + if (!belongsToWorktree) { + continue + } + retained.push(retainedAgentEntryFromLive(state, worktreeId, entry, agentType)) + } + return retained +} diff --git a/src/renderer/src/store/slices/agent-status-recovery-equivalence.ts b/src/renderer/src/store/slices/agent-status-recovery-equivalence.ts new file mode 100644 index 00000000000..660d104abf6 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-recovery-equivalence.ts @@ -0,0 +1,83 @@ +import type { + SleepingAgentSessionRecord, + SleepingAgentLaunchConfig +} from '../../../../shared/agent-session-resume' +import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' + +export function launchConfigsEqual( + a: SleepingAgentLaunchConfig | undefined, + b: SleepingAgentLaunchConfig | undefined +): boolean { + if (a === undefined || b === undefined) { + return a === b + } + if ( + a.agentCommand !== b.agentCommand || + a.agentArgs !== b.agentArgs || + a.ompResumeFilePath !== b.ompResumeFilePath + ) { + return false + } + const aKeys = Object.keys(a.agentEnv) + const bKeys = Object.keys(b.agentEnv) + return aKeys.length === bKeys.length && aKeys.every((key) => a.agentEnv[key] === b.agentEnv[key]) +} + +export function sleepingRecordsEquivalentIgnoringCaptureTime( + existing: SleepingAgentSessionRecord | undefined, + next: SleepingAgentSessionRecord +): boolean { + if (!existing) { + return false + } + return ( + existing.paneKey === next.paneKey && + existing.tabId === next.tabId && + existing.worktreeId === next.worktreeId && + existing.agent === next.agent && + agentProviderSessionsEqual(existing.agent, existing.providerSession, next.providerSession) && + existing.prompt === next.prompt && + existing.state === next.state && + existing.updatedAt === next.updatedAt && + existing.terminalTitle === next.terminalTitle && + existing.lastAssistantMessage === next.lastAssistantMessage && + existing.interrupted === next.interrupted && + existing.origin === next.origin && + launchConfigsEqual(existing.launchConfig, next.launchConfig) + ) +} + +export function recoveryRecordMatches( + existing: SleepingAgentSessionRecord | undefined, + next: SleepingAgentSessionRecord +): boolean { + if (!existing) { + return false + } + // Why: completion or interruption must replace a pre-status working checkpoint. + return ( + existing.origin === next.origin && + existing.agent === next.agent && + existing.worktreeId === next.worktreeId && + existing.tabId === next.tabId && + existing.state === next.state && + existing.interrupted === next.interrupted && + agentProviderSessionsEqual(existing.agent, existing.providerSession, next.providerSession) && + launchConfigsEqual(existing.launchConfig, next.launchConfig) + ) +} + +export function recoveryRecordTargetsSameSession( + existing: SleepingAgentSessionRecord | undefined, + next: SleepingAgentSessionRecord +): boolean { + if (!existing) { + return false + } + return ( + existing.agent === next.agent && + existing.worktreeId === next.worktreeId && + existing.tabId === next.tabId && + agentProviderSessionsEqual(existing.agent, existing.providerSession, next.providerSession) + ) +} diff --git a/src/renderer/src/store/slices/agent-status-recovery-helpers.ts b/src/renderer/src/store/slices/agent-status-recovery-helpers.ts new file mode 100644 index 00000000000..9b7a07c4471 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-recovery-helpers.ts @@ -0,0 +1,152 @@ +import type { AppState } from '../types' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { + agentProviderSessionsEqual, + getAgentResumeArgv, + isResumableTuiAgent, + type SleepingAgentLaunchConfig, + type SleepingAgentSessionRecord +} from '../../../../shared/agent-session-resume' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { findTabForAgentEntry } from './agent-status-pane-helpers' + +export function copyLaunchConfig(config: SleepingAgentLaunchConfig): SleepingAgentLaunchConfig { + return { + ...(config.agentCommand ? { agentCommand: config.agentCommand } : {}), + agentArgs: config.agentArgs, + agentEnv: { ...config.agentEnv }, + ...(config.ompResumeFilePath ? { ompResumeFilePath: config.ompResumeFilePath } : {}) + } +} + +export function sleepingRecordFromEntry(args: { + state: AppState + entry: AgentStatusEntry + worktreeId: string + tab?: TerminalTab + capturedAt: number + launchConfig?: SleepingAgentLaunchConfig + origin?: SleepingAgentSessionRecord['origin'] +}): SleepingAgentSessionRecord | null { + const agent = args.entry.agentType + if ( + args.entry.terminalResumeEligible === false || + !isResumableTuiAgent(agent) || + !args.entry.providerSession + ) { + return null + } + if (!getAgentResumeArgv(agent, args.entry.providerSession)) { + return null + } + const tab = args.tab ?? findTabForAgentEntry(args.state, args.worktreeId, args.entry) + return { + paneKey: args.entry.paneKey, + ...(tab ? { tabId: tab.id } : {}), + worktreeId: args.worktreeId, + agent, + providerSession: args.entry.providerSession, + ...(args.entry.connectionId !== undefined ? { connectionId: args.entry.connectionId } : {}), + prompt: args.entry.prompt, + state: args.entry.state, + capturedAt: args.capturedAt, + updatedAt: args.entry.updatedAt, + ...((args.entry.terminalTitle ?? tab?.title) + ? { terminalTitle: (args.entry.terminalTitle ?? tab?.title)! } + : {}), + ...(args.entry.lastAssistantMessage + ? { lastAssistantMessage: args.entry.lastAssistantMessage } + : {}), + ...(args.launchConfig ? { launchConfig: copyLaunchConfig(args.launchConfig) } : {}), + ...(args.entry.interrupted ? { interrupted: true } : {}), + ...(args.origin ? { origin: args.origin } : {}) + } +} + +export type CollectSleepingAgentSessionRecordsOptions = { + paneKeys?: readonly string[] + captureMode?: 'manual-worktree-sleep' | 'completed-agent-hibernation' +} + +export function normalizeSleepingAgentSessionCollectOptions( + options: readonly string[] | CollectSleepingAgentSessionRecordsOptions | undefined +): CollectSleepingAgentSessionRecordsOptions { + if (!options) { + return {} + } + return Array.isArray(options) + ? { paneKeys: options } + : (options as CollectSleepingAgentSessionRecordsOptions) +} + +export function isValidCompletedAgentHibernationEntry(entry: AgentStatusEntry): boolean { + return entry.state === 'done' && entry.interrupted !== true +} + +// Why: a finished pane is passive wake evidence, and a mobile wake background-mounts every passive +// record's tab. Sleeping a workspace must not become "one phone tap respawns all of it" — the pane +// issues its own `--resume` cold restore when its tab is opened instead (#11598). +export function markManualSleepLazyRestore(record: SleepingAgentSessionRecord): void { + if (record.state === 'done') { + record.restoreOnTabOpenOnly = true + } +} + +// Why: `live`/legacy rows are provisional checkpoints a fresh capture supersedes; an explicit +// sleep or quit capture is the pane's only resume handle once its live row is gone. +export function isDurableSleepingCapture(record: SleepingAgentSessionRecord): boolean { + return record.origin === 'worktree-sleep' || record.origin === 'quit' +} + +// Why: manual sleep kills the pty either way, so the record carries resume identity, not the dead +// turn's interrupt flag — and an explicitly slept workspace is never stale at wake, so a row the +// user is deliberately sleeping must not trip the wake-side staleness discard. `state` is preserved +// so a done pane wakes lazily in place instead of spawning a new tab. +export function manualSleepCaptureEntry( + entry: AgentStatusEntry, + capturedAt: number +): AgentStatusEntry { + return { ...entry, updatedAt: capturedAt, interrupted: false } +} + +// Why: capture recreates a record the manual-sleep wipe would otherwise remove, so a deliberately +// blocked worker must not become auto-resumable at wake. +export function carryOverAutomaticResumeBlock( + record: SleepingAgentSessionRecord, + previous: SleepingAgentSessionRecord | undefined +): void { + if ( + previous?.automaticResumeBlockedBy === 'legacy-orchestration-worker' && + previous.agent === record.agent && + agentProviderSessionsEqual(record.agent, previous.providerSession, record.providerSession) + ) { + record.automaticResumeBlockedBy = previous.automaticResumeBlockedBy + } +} + +export function removeSleepingRecordsReplacedByManualWorktreeSleep( + records: Record, + worktreeId: string, + paneKeys?: readonly string[], + replacements?: Readonly> +): { records: Record; changed: boolean } { + const allowedPaneKeys = paneKeys ? new Set(paneKeys) : null + let next = records + let changed = false + for (const [paneKey, record] of Object.entries(records)) { + if (record.worktreeId !== worktreeId || (allowedPaneKeys && !allowedPaneKeys.has(paneKey))) { + continue + } + // Why: a repeat sleep must not delete a durable record this capture cannot re-derive — the + // pane was never woken, so it has no live status row to rebuild it from (#11598). + if (!replacements?.[paneKey] && isDurableSleepingCapture(record)) { + continue + } + if (next === records) { + next = { ...records } + } + delete next[paneKey] + changed = true + } + return { records: next, changed } +} diff --git a/src/renderer/src/store/slices/agent-status-retention-actions.ts b/src/renderer/src/store/slices/agent-status-retention-actions.ts new file mode 100644 index 00000000000..884a4ca6fa4 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-retention-actions.ts @@ -0,0 +1,141 @@ +import type { RetainedAgentEntry } from './agent-status-contract' +import type { AgentStatusSlice } from './agent-status-slice-contract' +import type { AgentStatusRuntime } from './agent-status-runtime' +import { mergeCurrentOrchestrationContext } from './agent-status-map-helpers' +import { capRetainedAgents } from './agent-status-pane-helpers' + +export function createAgentStatusRetentionActions( + runtime: AgentStatusRuntime +): Pick< + AgentStatusSlice, + | 'retainAgents' + | 'dismissRetainedAgent' + | 'dismissRetainedAgentsByWorktree' + | 'pruneRetainedAgents' + | 'clearRetentionSuppressedPaneKeys' +> { + const { set } = runtime + return { + retainAgents: (entries: RetainedAgentEntry[]) => { + if (entries.length === 0) { + return + } + set((s) => { + let changed = false + for (const retained of entries) { + if (s.retainedAgentsByPaneKey[retained.entry.paneKey] !== retained) { + changed = true + break + } + } + if (!changed) { + return s + } + const next = { ...s.retainedAgentsByPaneKey } + for (const retained of entries) { + const runtimeOrchestration = s.runtimeAgentOrchestrationByPaneKey[retained.entry.paneKey] + const mergedOrchestration = runtimeOrchestration + ? mergeCurrentOrchestrationContext(retained.entry.orchestration, runtimeOrchestration) + : retained.entry.orchestration + const entry = + mergedOrchestration !== retained.entry.orchestration + ? { ...retained.entry, orchestration: mergedOrchestration } + : retained.entry + next[retained.entry.paneKey] = + entry === retained.entry ? retained : { ...retained, entry } + } + return { retainedAgentsByPaneKey: capRetainedAgents(next) } + }) + }, + + dismissRetainedAgent: (paneKey) => { + set((s) => { + if (!(paneKey in s.retainedAgentsByPaneKey)) { + return s + } + const next = { ...s.retainedAgentsByPaneKey } + delete next[paneKey] + const hasLive = paneKey in s.agentStatusByPaneKey + if (!hasLive || paneKey in s.retentionSuppressedPaneKeys) { + return { retainedAgentsByPaneKey: next } + } + return { + retainedAgentsByPaneKey: next, + retentionSuppressedPaneKeys: { + ...s.retentionSuppressedPaneKeys, + [paneKey]: true + } + } + }) + }, + + dismissRetainedAgentsByWorktree: (worktreeId) => { + const dismissedPaneKeys: string[] = [] + set((s) => { + let changed = false + const next: Record = {} + const toSuppress: string[] = [] + for (const [key, retained] of Object.entries(s.retainedAgentsByPaneKey)) { + if (retained.worktreeId === worktreeId) { + changed = true + dismissedPaneKeys.push(key) + if (key in s.agentStatusByPaneKey && !(key in s.retentionSuppressedPaneKeys)) { + toSuppress.push(key) + } + } else { + next[key] = retained + } + } + if (!changed) { + return s + } + if (toSuppress.length === 0) { + return { retainedAgentsByPaneKey: next } + } + const nextSuppressed = { ...s.retentionSuppressedPaneKeys } + for (const key of toSuppress) { + nextSuppressed[key] = true + } + return { + retainedAgentsByPaneKey: next, + retentionSuppressedPaneKeys: nextSuppressed + } + }) + if (typeof window !== 'undefined') { + for (const paneKey of dismissedPaneKeys) { + window.api?.agentStatus?.drop?.(paneKey) + } + } + }, + + pruneRetainedAgents: (validWorktreeIds) => { + set((s) => { + let changed = false + const next: Record = {} + for (const [key, retained] of Object.entries(s.retainedAgentsByPaneKey)) { + if (!validWorktreeIds.has(retained.worktreeId)) { + changed = true + } else { + next[key] = retained + } + } + return changed ? { retainedAgentsByPaneKey: next } : s + }) + }, + + clearRetentionSuppressedPaneKeys: (paneKeys) => { + set((s) => { + let changed = false + const next = { ...s.retentionSuppressedPaneKeys } + for (const paneKey of paneKeys) { + if (!(paneKey in next)) { + continue + } + delete next[paneKey] + changed = true + } + return changed ? { retentionSuppressedPaneKeys: next } : s + }) + } + } +} diff --git a/src/renderer/src/store/slices/agent-status-runtime.ts b/src/renderer/src/store/slices/agent-status-runtime.ts new file mode 100644 index 00000000000..64dc343f122 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-runtime.ts @@ -0,0 +1,224 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { AgentStatusBatchTransaction, AgentStatusBatchUpdate } from './agent-status-contract' +import type { AgentStatusSlice } from './agent-status-slice-contract' +import type { GeneratedTabTitleUpdate } from './terminal-tab-title-batch' +import { createFreshnessScheduler } from './agent-status-freshness-scheduler' + +export type AgentStatusStateUpdate = + | AppState + | Partial + | ((state: AppState) => AppState | Partial) + +export type AgentStatusRuntime = { + get: () => AppState + set: (update: AgentStatusStateUpdate) => void + runAfterCommit: (effect: () => void) => void + applyGeneratedTabTitleUpdate: (update: GeneratedTabTitleUpdate) => void + requestFreshness: (acceptedInBatch: boolean) => void + freshness: ReturnType + transactAgentStatuses: ( + operation: (transaction: AgentStatusBatchTransaction) => Result + ) => Result + clearSleepingAgentSessionsByPaneKey: (paneKeys: readonly string[]) => void +} + +export function createAgentStatusRuntime( + storeSet: Parameters>[0], + storeGet: Parameters>[1], + getActions: () => Pick +): AgentStatusRuntime { + let batchedAgentStatusState: AppState | null = null + let batchedAgentStatusEffects: (() => void)[] | null = null + let batchedGeneratedTabTitleUpdates: GeneratedTabTitleUpdate[] | null = null + let batchedAgentStatusFreshnessRequested = false + const get = (): AppState => batchedAgentStatusState ?? storeGet() + + // Deliberately narrower than zustand's `set`: no `replace` parameter, so no call site in + // this slice can compile into a REPLACE the batch commit is unable to express. + const set = (update: AgentStatusStateUpdate): void => { + if (batchedAgentStatusState === null) { + storeSet(update, false) + return + } + const nextState = typeof update === 'function' ? update(batchedAgentStatusState) : update + if (Object.is(nextState, batchedAgentStatusState)) { + return + } + batchedAgentStatusState = Object.assign({}, batchedAgentStatusState, nextState) + } + + const runAfterCommit = (effect: () => void): void => { + if (batchedAgentStatusEffects) { + batchedAgentStatusEffects.push(effect) + return + } + effect() + } + + const applyGeneratedTabTitleUpdate = (update: GeneratedTabTitleUpdate): void => { + if (batchedGeneratedTabTitleUpdates) { + batchedGeneratedTabTitleUpdates.push(update) + return + } + if (update.options) { + get().setGeneratedTabTitleFromAgentPrompt(update.paneKey, update.prompt, update.options) + } else { + get().setGeneratedTabTitleFromAgentPrompt(update.paneKey, update.prompt) + } + } + + const requestFreshness = (acceptedInBatch: boolean): void => { + if (batchedAgentStatusState !== null) { + batchedAgentStatusFreshnessRequested ||= acceptedInBatch + return + } + freshness.scheduleDeferred() + } + + const applyBatchedAgentStatusUpdate = (update: AgentStatusBatchUpdate): boolean => { + const stateBeforeUpdate = batchedAgentStatusState + if (!stateBeforeUpdate) { + return false + } + const actions = getActions() + if (update.kind === 'providerSession') { + actions.recordAgentProviderSession( + update.paneKey, + update.agent, + update.providerSession, + update.timing, + update.routing, + update.metadata + ) + } else { + actions.setAgentStatus( + update.paneKey, + update.payload, + update.terminalTitle, + update.timing, + update.routing, + update.metadata + ) + } + return batchedAgentStatusState !== stateBeforeUpdate + } + + const batchTransaction: AgentStatusBatchTransaction = { + getState: get, + apply: applyBatchedAgentStatusUpdate, + afterCommit: runAfterCommit + } + + const transactAgentStatuses = ( + operation: (transaction: AgentStatusBatchTransaction) => Result + ): Result => { + if (batchedAgentStatusState) { + return operation(batchTransaction) + } + const initialState = storeGet() + batchedAgentStatusState = initialState + batchedAgentStatusEffects = [] + batchedGeneratedTabTitleUpdates = [] + try { + const result = operation(batchTransaction) + const nextState = batchedAgentStatusState + const effects = batchedAgentStatusEffects + const generatedTabTitleUpdates = batchedGeneratedTabTitleUpdates + const freshnessRequested = batchedAgentStatusFreshnessRequested + batchedAgentStatusState = null + batchedAgentStatusEffects = null + batchedGeneratedTabTitleUpdates = null + batchedAgentStatusFreshnessRequested = false + if (nextState !== initialState) { + storeSet(buildAgentStatusBatchPatch(initialState, nextState), false) + } + if (generatedTabTitleUpdates.length > 0) { + storeGet().setGeneratedTabTitlesFromAgentPrompts(generatedTabTitleUpdates) + } + if (freshnessRequested) { + freshness.scheduleDeferred() + } + for (const effect of effects) { + effect() + } + return result + } finally { + batchedAgentStatusState = null + batchedAgentStatusEffects = null + batchedGeneratedTabTitleUpdates = null + batchedAgentStatusFreshnessRequested = false + } + } + + const freshness = createFreshnessScheduler({ + getEntries: () => Object.values(get().agentStatusByPaneKey), + bumpEpochs: () => { + // Why: freshness is time-based — bump both epochs at the stale boundary to force selector + // recompute and re-sort even with no new output, since staleness can change worktree ordering. + set((s) => ({ + agentStatusEpoch: s.agentStatusEpoch + 1, + sortEpoch: s.sortEpoch + 1 + })) + } + }) + + const clearSleepingAgentSessionsByPaneKey = (paneKeys: readonly string[]): void => { + if (paneKeys.length === 0) { + return + } + const uniquePaneKeys = new Set(paneKeys) + set((s) => { + let nextSleeping = s.sleepingAgentSessionsByPaneKey + let nextLaunchConfigs = s.agentLaunchConfigByPaneKey + for (const paneKey of uniquePaneKeys) { + if (paneKey in nextSleeping) { + if (nextSleeping === s.sleepingAgentSessionsByPaneKey) { + nextSleeping = { ...nextSleeping } + } + delete nextSleeping[paneKey] + } + if (paneKey in nextLaunchConfigs) { + if (nextLaunchConfigs === s.agentLaunchConfigByPaneKey) { + nextLaunchConfigs = { ...nextLaunchConfigs } + } + delete nextLaunchConfigs[paneKey] + } + } + if ( + nextSleeping === s.sleepingAgentSessionsByPaneKey && + nextLaunchConfigs === s.agentLaunchConfigByPaneKey + ) { + return s + } + return { + sleepingAgentSessionsByPaneKey: nextSleeping, + agentLaunchConfigByPaneKey: nextLaunchConfigs + } + }) + } + + return { + get, + set, + runAfterCommit, + applyGeneratedTabTitleUpdate, + requestFreshness, + freshness, + transactAgentStatuses, + clearSleepingAgentSessionsByPaneKey + } +} + +function buildAgentStatusBatchPatch( + initialState: AppState, + nextState: AppState +): Partial { + const patch: Record = {} + for (const key of Object.keys(nextState) as (keyof AppState)[]) { + if (!Object.is(nextState[key], initialState[key])) { + patch[key as string] = nextState[key] + } + } + return patch as Partial +} diff --git a/src/renderer/src/store/slices/agent-status-slice-contract.ts b/src/renderer/src/store/slices/agent-status-slice-contract.ts new file mode 100644 index 00000000000..c1598f77a5a --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-slice-contract.ts @@ -0,0 +1,178 @@ +import type { + AgentLaunchConfigRegistryEntry, + AgentLaunchConfigRegistrationMetadata, + AgentLaunchConfigStatusMetadata, + AgentProviderSessionRecordMetadata, + AgentProviderSessionRouting, + AgentProviderSessionTiming, + AgentStatusBatchTransaction, + AgentStatusBatchUpdate, + AgentStatusMetadata, + AgentStatusPayload, + AgentStatusRouting, + AgentStatusTiming, + AgentProviderSessionMetadata, + DropAgentStatusByTabPrefixOptions, + DropAgentStatusByWorktreeOptions, + DropHibernatedAgentPaneOptions, + RetainedAgentEntry, + AllAgentSessionCaptureMode +} from './agent-status-contract' +import type { + AgentStatusEntry, + AgentStatusOrchestrationContext, + MigrationUnsupportedPtyEntry +} from '../../../../shared/agent-status-types' +import type { + ResumableTuiAgent, + SleepingAgentLaunchConfig, + SleepingAgentSessionRecord +} from '../../../../shared/agent-session-resume' + +export type AgentStatusSlice = { + /** Explicit agent status entries keyed by `${tabId}:${leafId}`; real-time only, not persisted. */ + agentStatusByPaneKey: Record + /** Main-synced dispatch metadata for live panes that may only have title-derived status in the renderer. */ + runtimeAgentOrchestrationByPaneKey: Record + /** PTYs still reporting legacy numeric pane keys but with registry-backed UUID proof; stored separately from normal hook-reported status. */ + migrationUnsupportedByPtyId: Record + /** Monotonic tick that advances when agent-status freshness boundaries pass. */ + agentStatusEpoch: number + /** SSH connections whose transient rows were cleared and must reject renderer callbacks + * until a later reconnect establishes a new connection lifecycle. */ + transientClearedAgentStatusConnectionIds: Record + /** Arm the shared freshness timer after an external mirror writes live rows. */ + scheduleAgentStatusFreshness: () => void + + /** Retained "done" snapshots of agents gone from `agentStatusByPaneKey`, keyed by paneKey so pane re-appearance overwrites; shared by dashboard and sidebar hover. */ + retainedAgentsByPaneKey: Record + + /** Durable agent sessions captured on sleep (not live rows); power the one-click CLI resume on wake. */ + sleepingAgentSessionsByPaneKey: Record + + /** Ephemeral launch snapshots keyed by pane; hook payloads lack Orca launch settings, so the renderer supplies them from startup. */ + agentLaunchConfigByPaneKey: Record + + /** Pane keys explicitly torn down, forbidden from re-retention on next disappearance; a one-shot suppressor consumed by the retention sync. */ + retentionSuppressedPaneKeys: Record + + /** Terminal tabs explicitly closed this session; used to drop late in-flight IPC statuses and stale main-cache replays. */ + recentlyClosedAgentStatusTabIds: Record + + /** Exact pane authorities retired while sibling panes in the tab stay live. */ + recentlyRetiredAgentStatusPaneKeys: Record + + retireAgentPaneAuthority: ( + paneKey: string, + options?: { preserveSleepingAgentSession?: boolean } + ) => void + /** Lift a pane's retirement fence once a live PTY re-attaches to it. Closed tabs stay retired. */ + restoreAgentPaneAuthority: (paneKey: string) => void + transferAgentPaneAuthority: (args: { + fromPaneKey: string + toPaneKey: string + ptyId?: string | null + }) => void + + /** Update or insert an agent status entry from a status payload. */ + setAgentStatus: ( + paneKey: string, + payload: AgentStatusPayload, + terminalTitle?: string, + timing?: AgentStatusTiming, + routing?: AgentStatusRouting, + metadata?: AgentStatusMetadata + ) => void + + /** Apply ordered status updates as one status publication (generated titles and tab + * titles still publish after it — three total, not 2N). */ + setAgentStatuses: (updates: readonly AgentStatusBatchUpdate[]) => boolean[] + + /** Fold caller-derived updates against exact staged state, committing one status publication. */ + transactAgentStatuses: ( + operation: (transaction: AgentStatusBatchTransaction) => Result + ) => Result + + /** Record resume identity without creating a visible turn-status row. */ + recordAgentProviderSession: ( + paneKey: string, + agent: ResumableTuiAgent, + providerSession: AgentProviderSessionMetadata, + timing?: AgentProviderSessionTiming, + routing?: AgentProviderSessionRouting, + metadata?: AgentProviderSessionRecordMetadata + ) => void + + registerAgentLaunchConfig: ( + paneKey: string, + launchConfig: SleepingAgentLaunchConfig, + metadata?: AgentLaunchConfigRegistrationMetadata + ) => void + getAgentLaunchConfigForStatusEntry: ( + entry: AgentStatusEntry + ) => SleepingAgentLaunchConfig | undefined + getAgentLaunchConfigForStatusMetadata: ( + metadata: AgentLaunchConfigStatusMetadata + ) => SleepingAgentLaunchConfig | undefined + clearAgentLaunchConfig: (paneKey: string) => void + + setRuntimeAgentOrchestrationByPaneKey: ( + entries: Record + ) => void + + setMigrationUnsupportedPty: (entry: MigrationUnsupportedPtyEntry) => void + clearMigrationUnsupportedPty: (ptyId: string) => void + + /** Remove a single entry (e.g., when a pane's terminal exits). */ + removeAgentStatus: (paneKey: string) => void + + /** Remove all entries whose paneKey starts with the given prefix (tab close prefix-sweep). */ + removeAgentStatusByTabPrefix: (tabIdPrefix: string) => void + + /** Remove stale live rows while preserving pane launch and resume identity. */ + clearTransientAgentStatuses: (connectionId: string, clearedAt: number) => void + + /** Remove a single entry AND suppress re-retention on its next disappearance (user-initiated teardown: X button, pane close). */ + dropAgentStatus: (paneKey: string) => void + + /** Remove all entries under a tab AND suppress re-retention for each (tab close — no rows may reappear). */ + dropAgentStatusByTabPrefix: ( + tabIdPrefix: string, + opts?: DropAgentStatusByTabPrefixOptions + ) => void + + /** Remove one auto-hibernated completed-agent pane while preserving sibling live/retained rows in the same worktree. */ + dropHibernatedAgentStatusPane: ( + worktreeId: string, + paneKey: string, + opts?: DropHibernatedAgentPaneOptions + ) => void + + /** Remove all entries for a worktree AND suppress re-retention for live rows (worktree sleep/remove). + * Sweeps live rows by tab prefix and by main-stamped worktree attribution so worker rows that arrive before their tab don't survive. */ + dropAgentStatusByWorktree: (worktreeId: string, opts?: DropAgentStatusByWorktreeOptions) => void + + captureSleepingAgentSessionsByWorktree: (worktreeId: string, paneKeys?: string[]) => void + /** Capture resumable agent sessions across every worktree for crash recovery or quit; mode sets live/quit precedence. */ + captureAllSleepingAgentSessions: (mode: AllAgentSessionCaptureMode) => void + clearSleepingAgentSession: (paneKey: string) => void + clearSleepingAgentSessionsByPaneKey: (paneKeys: readonly string[]) => void + setSleepingAgentAutomaticResumeBlocked: (paneKey: string, blocked: boolean) => void + clearSleepingAgentSessionsByWorktree: (worktreeId: string) => void + pruneSleepingAgentSessions: (validWorktreeIds: Set) => void + + /** Retain agent snapshots. Accepts an array so simultaneous disappearances produce a single set() with no mid-loop intermediate states. */ + retainAgents: (entries: RetainedAgentEntry[]) => void + + /** Dismiss a retained entry by its paneKey. */ + dismissRetainedAgent: (paneKey: string) => void + + /** Dismiss all retained entries belonging to a worktree. */ + dismissRetainedAgentsByWorktree: (worktreeId: string) => void + + /** Prune retained entries whose worktreeId is not in the given set. */ + pruneRetainedAgents: (validWorktreeIds: Set) => void + + /** Clear one-shot teardown suppressors after the retention sync declines to retain the row. */ + clearRetentionSuppressedPaneKeys: (paneKeys: string[]) => void +} diff --git a/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts b/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts new file mode 100644 index 00000000000..f6820296735 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts @@ -0,0 +1,157 @@ +import type { DropAgentStatusByWorktreeOptions, RetainedAgentEntry } from './agent-status-contract' +import type { AgentStatusSlice } from './agent-status-slice-contract' +import type { AgentStatusRuntime } from './agent-status-runtime' +import { pruneMigrationUnsupportedEntries } from './agent-status-map-helpers' +import { + normalizePaneKeySet, + paneKeyMatchesAnyTabPrefix, + retainedAgentEntryFromLive, + shouldReplaceRetainedWithLive +} from './agent-status-pane-helpers' + +export function createAgentStatusWorktreeDropActions( + runtime: AgentStatusRuntime +): Pick { + const { set, freshness } = runtime + return { + dropAgentStatusByWorktree: (worktreeId, opts?: DropAgentStatusByWorktreeOptions) => { + let hadLive = false + set((s) => { + const tabPrefixes = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) + const liveEntries = Object.entries(s.agentStatusByPaneKey).filter( + ([paneKey, entry]) => + entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) + ) + const liveKeys = liveEntries.map(([paneKey]) => paneKey) + const liveKeySet = new Set(liveKeys) + const launchConfigKeys = Object.keys(s.agentLaunchConfigByPaneKey).filter( + (paneKey) => paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) || liveKeySet.has(paneKey) + ) + const retainedKeys = Object.entries(s.retainedAgentsByPaneKey) + .filter( + ([paneKey, retained]) => + retained.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) + ) + .map(([paneKey]) => paneKey) + const retainedKeySet = new Set(retainedKeys) + const migrationUnsupported = pruneMigrationUnsupportedEntries( + s.migrationUnsupportedByPtyId, + (entry) => + entry.worktreeId === worktreeId || + (entry.paneKey ? paneKeyMatchesAnyTabPrefix(entry.paneKey, tabPrefixes) : false) + ) + const allowedPaneKeys = normalizePaneKeySet(opts?.sleepingPaneKeys) + const preserveHibernatedEvidence = + opts?.shutdownReason === 'auto-hibernate-completed-agent' && + allowedPaneKeys !== null && + allowedPaneKeys.size > 0 + const liveEntryByPaneKey = new Map(liveEntries) + const retainedEvidence = new Map() + if (preserveHibernatedEvidence) { + for (const retained of opts?.retainedCompletionEvidence ?? []) { + if ( + allowedPaneKeys.has(retained.entry.paneKey) && + !liveEntryByPaneKey.has(retained.entry.paneKey) && + shouldReplaceRetainedWithLive(retainedEvidence.get(retained.entry.paneKey), retained) + ) { + retainedEvidence.set(retained.entry.paneKey, retained) + } + } + for (const [paneKey, entry] of liveEntries) { + if ( + allowedPaneKeys.has(paneKey) && + entry.state === 'done' && + entry.agentType !== undefined && + entry.interrupted !== true + ) { + retainedEvidence.set( + paneKey, + retainedAgentEntryFromLive(s, worktreeId, entry, entry.agentType) + ) + } + } + } + const retainedEvidenceKeys = new Set(retainedEvidence.keys()) + // Keep acknowledgement for completion evidence so a slept card does not turn bold again. + let nextAck = s.acknowledgedAgentsByPaneKey + const ackKeys = Object.keys(nextAck).filter( + (key) => + !retainedEvidenceKeys.has(key) && + (paneKeyMatchesAnyTabPrefix(key, tabPrefixes) || + liveKeySet.has(key) || + retainedKeySet.has(key)) + ) + if (ackKeys.length > 0) { + nextAck = { ...nextAck } + for (const key of ackKeys) { + delete nextAck[key] + } + } + if ( + liveKeys.length === 0 && + launchConfigKeys.length === 0 && + retainedKeys.length === 0 && + retainedEvidence.size === 0 && + !migrationUnsupported.changed + ) { + return nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : s + } + hadLive = liveKeys.length > 0 + const nextLive = + liveKeys.length > 0 ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey + for (const key of liveKeys) { + delete nextLive[key] + } + const nextLaunchConfigs = + launchConfigKeys.length > 0 + ? { ...s.agentLaunchConfigByPaneKey } + : s.agentLaunchConfigByPaneKey + for (const key of launchConfigKeys) { + delete nextLaunchConfigs[key] + } + const nextRetained = + retainedKeys.length > 0 || retainedEvidence.size > 0 + ? { ...s.retainedAgentsByPaneKey } + : s.retainedAgentsByPaneKey + for (const key of retainedKeys) { + if (!retainedEvidenceKeys.has(key)) { + delete nextRetained[key] + } + } + for (const [paneKey, retained] of retainedEvidence) { + if (shouldReplaceRetainedWithLive(nextRetained[paneKey], retained)) { + nextRetained[paneKey] = retained + } + } + const suppressorAdds = liveKeys.filter( + (key) => !retainedEvidenceKeys.has(key) && !(key in s.retentionSuppressedPaneKeys) + ) + let nextRetentionSuppressedPaneKeys = s.retentionSuppressedPaneKeys + if (suppressorAdds.length > 0) { + nextRetentionSuppressedPaneKeys = { ...s.retentionSuppressedPaneKeys } + for (const key of suppressorAdds) { + nextRetentionSuppressedPaneKeys[key] = true + } + } + return { + agentStatusByPaneKey: nextLive, + agentLaunchConfigByPaneKey: nextLaunchConfigs, + retainedAgentsByPaneKey: nextRetained, + migrationUnsupportedByPtyId: migrationUnsupported.next, + retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys, + ...(nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : {}), + agentStatusEpoch: + hadLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, + sortEpoch: hadLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch + } + }) + if (hadLive) { + freshness.scheduleDeferred() + } + } + } +} diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 5a6293ae5a4..8093c2be805 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -1,1621 +1,98 @@ -/* eslint-disable max-lines -- Why: the agent-status slice co-locates live map, retained snapshots, retention-suppression, and tab-prefix sweep so the teardown contract stays readable end-to-end. Splitting across files would scatter the drop/remove/retain interactions that must stay in lockstep. */ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import { - agentSubagentsEqual, - AGENT_STATUS_STALE_AFTER_MS, - AGENT_STATE_HISTORY_MAX, - type AgentStateHistoryEntry, - type AgentStatusEntry, - type AgentStatusOrchestrationContext, - type AgentType, - type MigrationUnsupportedPtyEntry, - type ParsedAgentStatusPayload -} from '../../../../shared/agent-status-types' -import type { AgentStatusObservation } from '../../../../shared/agent-status-observation' -import { rendererAgentStatusObservations } from '../../lib/renderer-agent-status-observations' -import { - agentProviderSessionsEqual, - getAgentResumeArgv, - isResumableTuiAgent, - type AgentProviderSessionMetadata, - type ResumableTuiAgent, - type SleepingAgentLaunchConfig, - type SleepingAgentSessionRecord -} from '../../../../shared/agent-session-resume' -import { - resolveAgentStatusIdentity, - shouldSuppressInheritedTerminalStatus -} from '../../../../shared/agent-status-identity' -import { isCommandCodeNewTurnWhileWorking } from '../../../../shared/command-code-turn-boundary' -import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time' -import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/terminal-tab-types' -import { - getRepoExecutionHostId, - getWorktreeExecutionHostId -} from '../../../../shared/execution-host' -import { isExplicitAgentStatusFresh } from '@/lib/agent-status' -import { - getAgentRowGeneratedTitleText, - getOrcaDispatchTaskId, - isOrcaDispatchPrompt, - orchestrationLabelsMatchLiveDispatch -} from '@/lib/agent-row-primary-text' -import { isCompletedPiCompatibleAgentWithLiveRecoveryRecord } from '@/lib/live-resume-anchor-record' -import { recordHibernationBoundaryResolved } from '@/lib/agent-hibernation-pane-age' -import { - resolveAgentPaneAuthorityKey, - retireAgentPaneAuthorityAliases, - retireAgentPaneAuthorityAliasesByOwnerTab, - transferAgentPaneAuthorityAlias -} from './agent-pane-authority' -import { createFreshnessScheduler } from './agent-status-freshness-scheduler' -import type { GeneratedTabTitleUpdate } from './terminal-tab-title-batch' - -/** Snapshot of a finished/vanished agent status entry, kept so the dashboard and sidebar hover - * keep showing the completion until the user clicks the worktree. `worktreeId` is stamped at - * retention time so the row's home is known even after its tab/pty is gone. */ -export type RetainedAgentEntry = { - entry: AgentStatusEntry - worktreeId: string - /** Snapshot of the tab at retention time; kept full (not just an id) because the tab may be gone from `tabsByWorktree` by render time. */ - tab: TerminalTab - agentType: AgentType - startedAt: number -} - -export type AgentStatusWorktreeShutdownReason = - | 'manual-sleep' - | 'remove-worktree' - | 'auto-hibernate-completed-agent' - -type AllAgentSessionCaptureMode = 'periodic' | 'quit' - -type DropAgentStatusByWorktreeOptions = { - shutdownReason?: AgentStatusWorktreeShutdownReason - sleepingPaneKeys?: readonly string[] | ReadonlySet - retainedCompletionEvidence?: readonly RetainedAgentEntry[] -} - -type DropHibernatedAgentPaneOptions = { - retainedCompletionEvidence?: readonly RetainedAgentEntry[] -} - -type DropAgentStatusByTabPrefixOptions = { - worktreeId?: string -} - -type AgentLaunchConfigRegistrationMetadata = { - agentType?: AgentType - launchToken?: string - tabId?: string - leafId?: string - terminalHandle?: string - providerSession?: AgentProviderSessionMetadata -} - -type AgentLaunchConfigStatusMetadata = { - paneKey: string - agentType?: AgentType - tabId?: string - terminalHandle?: string - launchToken?: string - providerSession?: AgentProviderSessionMetadata - existingProviderSession?: AgentProviderSessionMetadata - providerSessionChanged?: boolean -} - -type AgentLaunchConfigRegistryEntry = { - launchConfig: SleepingAgentLaunchConfig - registeredAt: number - identity: AgentLaunchConfigRegistrationMetadata -} - -export type AgentStatusPayload = ParsedAgentStatusPayload & { - orchestration?: AgentStatusOrchestrationContext - promptInteractionKey?: string - restoredUnconfirmed?: boolean - /** Ingress provenance for this write (STA-4293). Read by nothing yet; a caller that omits - * it produces exactly the entry it produces today. See agent-status-observation.ts. */ - observation?: AgentStatusObservation -} - -export type AgentStatusTiming = { updatedAt?: number; stateStartedAt?: number } - -export type AgentStatusRouting = { - tabId?: string - worktreeId?: string - terminalHandle?: string - connectionId?: string | null -} - -export type AgentStatusMetadata = { - providerSession?: AgentProviderSessionMetadata - launchConfig?: SleepingAgentLaunchConfig - launchToken?: string - terminalResumeEligible?: false -} - -export type AgentStatusUpdate = { - kind?: 'status' - paneKey: string - payload: AgentStatusPayload - terminalTitle?: string - timing?: AgentStatusTiming - routing?: AgentStatusRouting - metadata?: AgentStatusMetadata -} - -export type AgentProviderSessionTiming = { updatedAt?: number } - -export type AgentProviderSessionRouting = { - tabId?: string - worktreeId?: string - connectionId?: string | null -} - -export type AgentProviderSessionRecordMetadata = { launchToken?: string } - -export type AgentProviderSessionUpdate = { - kind: 'providerSession' - paneKey: string - agent: ResumableTuiAgent - providerSession: AgentProviderSessionMetadata - timing?: AgentProviderSessionTiming - routing?: AgentProviderSessionRouting - metadata?: AgentProviderSessionRecordMetadata -} - -export type AgentStatusBatchUpdate = AgentStatusUpdate | AgentProviderSessionUpdate - -export type AgentStatusBatchTransaction = { - getState: () => AppState - apply: (update: AgentStatusBatchUpdate) => boolean - afterCommit: (effect: () => void) => void -} - -export type AgentStatusSlice = { - /** Explicit agent status entries keyed by `${tabId}:${leafId}`; real-time only, not persisted. */ - agentStatusByPaneKey: Record - /** Main-synced dispatch metadata for live panes that may only have title-derived status in the renderer. */ - runtimeAgentOrchestrationByPaneKey: Record - /** PTYs still reporting legacy numeric pane keys but with registry-backed UUID proof; stored separately from normal hook-reported status. */ - migrationUnsupportedByPtyId: Record - /** Monotonic tick that advances when agent-status freshness boundaries pass. */ - agentStatusEpoch: number - /** SSH connections whose transient rows were cleared and must reject renderer callbacks - * until a later reconnect establishes a new connection lifecycle. */ - transientClearedAgentStatusConnectionIds: Record - /** Arm the shared freshness timer after an external mirror writes live rows. */ - scheduleAgentStatusFreshness: () => void - - /** Retained "done" snapshots of agents gone from `agentStatusByPaneKey`, keyed by paneKey so pane re-appearance overwrites; shared by dashboard and sidebar hover. */ - retainedAgentsByPaneKey: Record - - /** Durable agent sessions captured on sleep (not live rows); power the one-click CLI resume on wake. */ - sleepingAgentSessionsByPaneKey: Record - - /** Ephemeral launch snapshots keyed by pane; hook payloads lack Orca launch settings, so the renderer supplies them from startup. */ - agentLaunchConfigByPaneKey: Record - - /** Pane keys explicitly torn down, forbidden from re-retention on next disappearance; a one-shot suppressor consumed by the retention sync. */ - retentionSuppressedPaneKeys: Record - - /** Terminal tabs explicitly closed this session; used to drop late in-flight IPC statuses and stale main-cache replays. */ - recentlyClosedAgentStatusTabIds: Record - - /** Exact pane authorities retired while sibling panes in the tab stay live. */ - recentlyRetiredAgentStatusPaneKeys: Record - - retireAgentPaneAuthority: ( - paneKey: string, - options?: { preserveSleepingAgentSession?: boolean } - ) => void - /** Lift a pane's retirement fence once a live PTY re-attaches to it. Closed tabs stay retired. */ - restoreAgentPaneAuthority: (paneKey: string) => void - transferAgentPaneAuthority: (args: { - fromPaneKey: string - toPaneKey: string - ptyId?: string | null - }) => void - - /** Update or insert an agent status entry from a status payload. */ - setAgentStatus: ( - paneKey: string, - payload: AgentStatusPayload, - terminalTitle?: string, - timing?: AgentStatusTiming, - routing?: AgentStatusRouting, - metadata?: AgentStatusMetadata - ) => void - - /** Apply ordered status updates as one status publication (generated titles and tab - * titles still publish after it — three total, not 2N). */ - setAgentStatuses: (updates: readonly AgentStatusBatchUpdate[]) => boolean[] - - /** Fold caller-derived updates against exact staged state, committing one status publication. */ - transactAgentStatuses: ( - operation: (transaction: AgentStatusBatchTransaction) => Result - ) => Result - - /** Record resume identity without creating a visible turn-status row. */ - recordAgentProviderSession: ( - paneKey: string, - agent: ResumableTuiAgent, - providerSession: AgentProviderSessionMetadata, - timing?: AgentProviderSessionTiming, - routing?: AgentProviderSessionRouting, - metadata?: AgentProviderSessionRecordMetadata - ) => void - - registerAgentLaunchConfig: ( - paneKey: string, - launchConfig: SleepingAgentLaunchConfig, - metadata?: AgentLaunchConfigRegistrationMetadata - ) => void - getAgentLaunchConfigForStatusEntry: ( - entry: AgentStatusEntry - ) => SleepingAgentLaunchConfig | undefined - getAgentLaunchConfigForStatusMetadata: ( - metadata: AgentLaunchConfigStatusMetadata - ) => SleepingAgentLaunchConfig | undefined - clearAgentLaunchConfig: (paneKey: string) => void - - setRuntimeAgentOrchestrationByPaneKey: ( - entries: Record - ) => void - - setMigrationUnsupportedPty: (entry: MigrationUnsupportedPtyEntry) => void - clearMigrationUnsupportedPty: (ptyId: string) => void - - /** Remove a single entry (e.g., when a pane's terminal exits). */ - removeAgentStatus: (paneKey: string) => void - - /** Remove all entries whose paneKey starts with the given prefix (tab close prefix-sweep). */ - removeAgentStatusByTabPrefix: (tabIdPrefix: string) => void - - /** Remove stale live rows while preserving pane launch and resume identity. */ - clearTransientAgentStatuses: (connectionId: string, clearedAt: number) => void - - /** Remove a single entry AND suppress re-retention on its next disappearance (user-initiated teardown: X button, pane close). */ - dropAgentStatus: (paneKey: string) => void - - /** Remove all entries under a tab AND suppress re-retention for each (tab close — no rows may reappear). */ - dropAgentStatusByTabPrefix: ( - tabIdPrefix: string, - opts?: DropAgentStatusByTabPrefixOptions - ) => void - - /** Remove one auto-hibernated completed-agent pane while preserving sibling live/retained rows in the same worktree. */ - dropHibernatedAgentStatusPane: ( - worktreeId: string, - paneKey: string, - opts?: DropHibernatedAgentPaneOptions - ) => void - - /** Remove all entries for a worktree AND suppress re-retention for live rows (worktree sleep/remove). - * Sweeps live rows by tab prefix and by main-stamped worktree attribution so worker rows that arrive before their tab don't survive. */ - dropAgentStatusByWorktree: (worktreeId: string, opts?: DropAgentStatusByWorktreeOptions) => void - - captureSleepingAgentSessionsByWorktree: (worktreeId: string, paneKeys?: string[]) => void - /** Capture resumable agent sessions across every worktree for crash recovery or quit; mode sets live/quit precedence. */ - captureAllSleepingAgentSessions: (mode: AllAgentSessionCaptureMode) => void - clearSleepingAgentSession: (paneKey: string) => void - clearSleepingAgentSessionsByPaneKey: (paneKeys: readonly string[]) => void - setSleepingAgentAutomaticResumeBlocked: (paneKey: string, blocked: boolean) => void - clearSleepingAgentSessionsByWorktree: (worktreeId: string) => void - pruneSleepingAgentSessions: (validWorktreeIds: Set) => void - - /** Retain agent snapshots. Accepts an array so simultaneous disappearances produce a single set() with no mid-loop intermediate states. */ - retainAgents: (entries: RetainedAgentEntry[]) => void - - /** Dismiss a retained entry by its paneKey. */ - dismissRetainedAgent: (paneKey: string) => void - - /** Dismiss all retained entries belonging to a worktree. */ - dismissRetainedAgentsByWorktree: (worktreeId: string) => void - - /** Prune retained entries whose worktreeId is not in the given set. */ - pruneRetainedAgents: (validWorktreeIds: Set) => void - - /** Clear one-shot teardown suppressors after the retention sync declines to retain the row. */ - clearRetentionSuppressedPaneKeys: (paneKeys: string[]) => void -} - -// Why: retained entries are heavy (~24KB) and grow unbounded on busy worktrees (dominant renderer OOM); cap, evicting oldest completions first. -const MAX_RETAINED_AGENTS = 500 - -function capRetainedAgents( - retained: Record, - maxEntries = MAX_RETAINED_AGENTS -): Record { - const keys = Object.keys(retained) - if (keys.length <= maxEntries) { - return retained - } - const capped: Record = {} - for (const key of keys.slice(keys.length - maxEntries)) { - capped[key] = retained[key] - } - return capped -} - -// Why: missed pane teardown can leak heavy live rows in any state and amplify every status-map copy (#9872). -export const MAX_LIVE_AGENT_STATUSES = 500 - -type PaneLiveness = 'live' | 'dead' | 'unprovable' - -// Why: only a rooted tab proves which leaves are mounted; rootless and headless rows may still be live (#2962). -function classifyPaneKeyLiveness(state: AppState): (paneKey: string) => PaneLiveness { - const rootedLeafKeys = new Set() - const rootedTabIds = new Set() - for (const [tabId, layout] of Object.entries(state.terminalLayoutsByTabId)) { - if (!layout?.root) { - continue - } - rootedTabIds.add(tabId) - const stack: TerminalPaneLayoutNode[] = [layout.root] - while (stack.length > 0) { - const node = stack.pop()! - if (node.type === 'leaf') { - rootedLeafKeys.add(`${tabId}:${node.leafId}`) - } else { - stack.push(node.first, node.second) - } - } - } - return (paneKey) => { - if (rootedLeafKeys.has(paneKey)) { - return 'live' - } - const tabId = getTabIdFromPaneKey(paneKey) - return tabId !== null && rootedTabIds.has(tabId) ? 'dead' : 'unprovable' - } -} - -// Why: mutate the caller-owned spread so eviction does not allocate another heavy-map copy. -function capLiveAgentStatusesInPlace( - freshLive: Record, - protectedPaneKey: string, - buildClassifier: () => (paneKey: string) => PaneLiveness, - now: number, - maxEntries = MAX_LIVE_AGENT_STATUSES -): string[] { - const keys = Object.keys(freshLive) - let overflow = keys.length - maxEntries - if (overflow <= 0) { - return [] - } - const classify = buildClassifier() - const evictedPaneKeys: string[] = [] - const sweep = (canEvict: (liveness: PaneLiveness, entry: AgentStatusEntry) => boolean): void => { - for (const key of keys) { - if (overflow <= 0) { - break - } - if (key === protectedPaneKey || !(key in freshLive)) { - continue - } - const liveness = classify(key) - if (liveness === 'live' || !canEvict(liveness, freshLive[key])) { - continue - } - delete freshLive[key] - overflow -= 1 - evictedPaneKeys.push(key) - } - } - // Prefer rows that are provably dead or too stale to represent a live agent. - sweep( - (liveness, entry) => liveness === 'dead' || now - entry.updatedAt > AGENT_STATUS_STALE_AFTER_MS - ) - // Shed fresh unprovable rows only when needed; rooted live panes make this a soft cap. - if (overflow > 0) { - sweep(() => true) - } - return evictedPaneKeys -} - -function paneKeyMatchesAnyTabPrefix(paneKey: string, tabPrefixes: string[]): boolean { - for (const prefix of tabPrefixes) { - if (paneKey.startsWith(prefix)) { - return true - } - } - return false -} - -function isAgentCompletionState(state: ParsedAgentStatusPayload['state']): boolean { - return state === 'done' || state === 'waiting' || state === 'blocked' -} - -function getTabIdFromPaneKey(paneKey: string): string | null { - const separator = paneKey.indexOf(':') - if (separator <= 0 || separator !== paneKey.lastIndexOf(':')) { - return null - } - return paneKey.slice(0, separator) -} - -/** True when auto-title generation would no-op without replace (custom/quick/generated). */ -function agentStatusTabAlreadyHasProtectedOrGeneratedTitle( - state: AppState, - tabId: string | null, - worktreeId?: string | null -): boolean { - if (!tabId) { - return false - } - const ownerTabs = worktreeId ? state.tabsByWorktree[worktreeId] : undefined - if (ownerTabs) { - const tab = ownerTabs.find((candidate) => candidate.id === tabId) - return Boolean( - tab?.customTitle?.trim() || tab?.quickCommandLabel?.trim() || tab?.generatedTitle?.trim() - ) - } - for (const tabs of Object.values(state.tabsByWorktree)) { - const tab = tabs.find((candidate) => candidate.id === tabId) - if (!tab) { - continue - } - return Boolean( - tab.customTitle?.trim() || tab.quickCommandLabel?.trim() || tab.generatedTitle?.trim() - ) - } - return false -} - -function getLeafIdFromPaneKey(paneKey: string): string | null { - const separator = paneKey.indexOf(':') - if (separator <= 0 || separator !== paneKey.lastIndexOf(':')) { - return null - } - const leafId = paneKey.slice(separator + 1) - return leafId.length > 0 ? leafId : null -} - -function findCompletedOrphanPaneKeysForTabClose( - state: AgentStatusTabPrefixDropState, - worktreeId: string | undefined, - prefix: string -): string[] { - if (!worktreeId) { - return [] - } - const openTabIds = new Set((state.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)) - const paneKeys: string[] = [] - for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { - if (paneKey.startsWith(prefix) || entry.state !== 'done' || entry.worktreeId !== worktreeId) { - continue - } - const tabId = getTabIdFromPaneKey(paneKey) - if (!tabId || openTabIds.has(tabId)) { - continue - } - paneKeys.push(paneKey) - } - return paneKeys -} - -function isRecentlyClosedAgentStatusTab( - closedTabs: Record, - tabId: string | null -): boolean { - if (!tabId) { - return false - } - return closedTabs[tabId] === true -} - -function findAgentPaneWorktreeId(state: AppState, paneKey: string): string | null { - const tabId = getTabIdFromPaneKey(paneKey) - if (!tabId) { - return null - } - for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { - if (tabs.some((tab) => tab.id === tabId)) { - return worktreeId - } - } - return null -} - -function findTabForAgentEntry( - state: AppState, - worktreeId: string, - entry: AgentStatusEntry -): TerminalTab | undefined { - const tabId = entry.tabId ?? getTabIdFromPaneKey(entry.paneKey) - if (!tabId) { - return undefined - } - return (state.tabsByWorktree[worktreeId] ?? []).find((tab) => tab.id === tabId) -} - -function getRetainedFallbackTab(entry: AgentStatusEntry, worktreeId: string): TerminalTab { - const tabId = entry.tabId ?? getTabIdFromPaneKey(entry.paneKey) ?? entry.paneKey - return { - id: tabId, - ptyId: null, - worktreeId, - title: entry.terminalTitle ?? 'Agent', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: entry.stateStartedAt - } -} - -function retainedAgentEntryFromLive( - state: AppState, - worktreeId: string, - entry: AgentStatusEntry, - agentType: AgentType -): RetainedAgentEntry { - const tab = - findTabForAgentEntry(state, worktreeId, entry) ?? getRetainedFallbackTab(entry, worktreeId) - return { - entry, - worktreeId, - tab, - agentType, - startedAt: entry.stateHistory[0]?.startedAt ?? entry.stateStartedAt - } -} - -function shouldReplaceRetainedWithLive( - retained: RetainedAgentEntry | undefined, - live: RetainedAgentEntry -): boolean { - if (!retained) { - return true - } - if (live.startedAt !== retained.startedAt) { - return live.startedAt > retained.startedAt - } - const retainedSessionId = retained.entry.providerSession?.id - const liveSessionId = live.entry.providerSession?.id - if (retainedSessionId && liveSessionId && retainedSessionId !== liveSessionId) { - return live.entry.updatedAt >= retained.entry.updatedAt - } - return live.entry.updatedAt > retained.entry.updatedAt -} - -function normalizePaneKeySet( - paneKeys: DropAgentStatusByWorktreeOptions['sleepingPaneKeys'] -): ReadonlySet | null { - if (!paneKeys) { - return null - } - return paneKeys instanceof Set ? paneKeys : new Set(paneKeys) -} - -function sleepingRecordFromEntry(args: { - state: AppState - entry: AgentStatusEntry - worktreeId: string - tab?: TerminalTab - capturedAt: number - launchConfig?: SleepingAgentLaunchConfig - origin?: SleepingAgentSessionRecord['origin'] -}): SleepingAgentSessionRecord | null { - const agent = args.entry.agentType - if ( - args.entry.terminalResumeEligible === false || - !isResumableTuiAgent(agent) || - !args.entry.providerSession - ) { - return null - } - if (!getAgentResumeArgv(agent, args.entry.providerSession)) { - return null - } - const tab = args.tab ?? findTabForAgentEntry(args.state, args.worktreeId, args.entry) - return { - paneKey: args.entry.paneKey, - ...(tab ? { tabId: tab.id } : {}), - worktreeId: args.worktreeId, - agent, - providerSession: args.entry.providerSession, - ...(args.entry.connectionId !== undefined ? { connectionId: args.entry.connectionId } : {}), - prompt: args.entry.prompt, - state: args.entry.state, - capturedAt: args.capturedAt, - updatedAt: args.entry.updatedAt, - ...((args.entry.terminalTitle ?? tab?.title) - ? { terminalTitle: (args.entry.terminalTitle ?? tab?.title)! } - : {}), - ...(args.entry.lastAssistantMessage - ? { lastAssistantMessage: args.entry.lastAssistantMessage } - : {}), - ...(args.launchConfig ? { launchConfig: copyLaunchConfig(args.launchConfig) } : {}), - ...(args.entry.interrupted ? { interrupted: true } : {}), - ...(args.origin ? { origin: args.origin } : {}) - } -} - -type CollectSleepingAgentSessionRecordsOptions = { - paneKeys?: readonly string[] - captureMode?: 'manual-worktree-sleep' | 'completed-agent-hibernation' -} - -function normalizeSleepingAgentSessionCollectOptions( - options: readonly string[] | CollectSleepingAgentSessionRecordsOptions | undefined -): CollectSleepingAgentSessionRecordsOptions { - if (!options) { - return {} - } - return Array.isArray(options) - ? { paneKeys: options } - : (options as CollectSleepingAgentSessionRecordsOptions) -} - -function isValidCompletedAgentHibernationEntry(entry: AgentStatusEntry): boolean { - return entry.state === 'done' && entry.interrupted !== true -} - -// Why: a finished pane is passive wake evidence, and a mobile wake background-mounts every passive -// record's tab. Sleeping a workspace must not become "one phone tap respawns all of it" — the pane -// issues its own `--resume` cold restore when its tab is opened instead (#11598). -function markManualSleepLazyRestore(record: SleepingAgentSessionRecord): void { - if (record.state === 'done') { - record.restoreOnTabOpenOnly = true - } -} - -// Why: `live`/legacy rows are provisional checkpoints a fresh capture supersedes; an explicit -// sleep or quit capture is the pane's only resume handle once its live row is gone. -function isDurableSleepingCapture(record: SleepingAgentSessionRecord): boolean { - return record.origin === 'worktree-sleep' || record.origin === 'quit' -} - -// Why: manual sleep kills the pty either way, so the record carries resume identity, not the dead -// turn's interrupt flag — and an explicitly slept workspace is never stale at wake, so a row the -// user is deliberately sleeping must not trip the wake-side staleness discard. `state` is preserved -// so a done pane wakes lazily in place instead of spawning a new tab. -function manualSleepCaptureEntry(entry: AgentStatusEntry, capturedAt: number): AgentStatusEntry { - return { ...entry, updatedAt: capturedAt, interrupted: false } -} - -// Why: capture recreates a record the manual-sleep wipe would otherwise remove, so a deliberately -// blocked worker must not become auto-resumable at wake. -function carryOverAutomaticResumeBlock( - record: SleepingAgentSessionRecord, - previous: SleepingAgentSessionRecord | undefined -): void { - if ( - previous?.automaticResumeBlockedBy === 'legacy-orchestration-worker' && - previous.agent === record.agent && - agentProviderSessionsEqual(record.agent, previous.providerSession, record.providerSession) - ) { - record.automaticResumeBlockedBy = previous.automaticResumeBlockedBy - } -} - -export function removeSleepingRecordsReplacedByManualWorktreeSleep( - records: Record, - worktreeId: string, - paneKeys?: readonly string[], - replacements?: Readonly> -): { records: Record; changed: boolean } { - const allowedPaneKeys = paneKeys ? new Set(paneKeys) : null - let next = records - let changed = false - for (const [paneKey, record] of Object.entries(records)) { - if (record.worktreeId !== worktreeId || (allowedPaneKeys && !allowedPaneKeys.has(paneKey))) { - continue - } - // Why: a repeat sleep must not delete a durable record this capture cannot re-derive — the - // pane was never woken, so it has no live status row to rebuild it from (#11598). - if (!replacements?.[paneKey] && isDurableSleepingCapture(record)) { - continue - } - if (next === records) { - next = { ...records } - } - delete next[paneKey] - changed = true - } - return { records: next, changed } -} - -export function collectSleepingAgentSessionRecordsForWorktree( - state: AppState, - worktreeId: string, - options?: readonly string[] | CollectSleepingAgentSessionRecordsOptions -): Record { - const capturedAt = Date.now() - const collectOptions = normalizeSleepingAgentSessionCollectOptions(options) - const allowedPaneKeys = collectOptions.paneKeys ? new Set(collectOptions.paneKeys) : null - const isManualWorktreeSleep = collectOptions.captureMode === 'manual-worktree-sleep' - const isCompletedAgentHibernation = collectOptions.captureMode === 'completed-agent-hibernation' - const isWorktreeOwnedCapture = isManualWorktreeSleep || isCompletedAgentHibernation - // Why: hibernated completions are intentional worktree-owned records; wake treats - // originless completed records as ambiguous legacy captures. - const origin: SleepingAgentSessionRecord['origin'] | undefined = isWorktreeOwnedCapture - ? 'worktree-sleep' - : undefined - const tabPrefixes = (state.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) - const records: Record = {} - const promotedLiveRecoveryPaneKeys = new Set() - - if (isManualWorktreeSleep) { - for (const existing of Object.values(state.sleepingAgentSessionsByPaneKey)) { - const liveEntry = state.agentStatusByPaneKey[existing.paneKey] - if ( - existing.worktreeId !== worktreeId || - existing.origin !== 'live' || - (liveEntry !== undefined && - !isCompletedPiCompatibleAgentWithLiveRecoveryRecord(liveEntry, existing)) || - (allowedPaneKeys && !allowedPaneKeys.has(existing.paneKey)) || - !getAgentResumeArgv(existing.agent, existing.providerSession) - ) { - continue - } - // Why: Pi identity is resumable with no turn row and while idle after done, so manual - // sleep must promote both instead of deleting the checkpoint. - records[existing.paneKey] = { - ...existing, - state: 'working', - capturedAt, - updatedAt: capturedAt, - origin: 'worktree-sleep' - } - promotedLiveRecoveryPaneKeys.add(existing.paneKey) - } - } - - for (const retained of Object.values(state.retainedAgentsByPaneKey)) { - if (isCompletedAgentHibernation) { - continue - } - if (allowedPaneKeys && !allowedPaneKeys.has(retained.entry.paneKey)) { - continue - } - if (retained.worktreeId !== worktreeId) { - continue - } - // Why: the promoted checkpoint carries recovery identity (transcript, connection) a retained - // turn row lacks, so it must not be overwritten by a re-derived record. - if (promotedLiveRecoveryPaneKeys.has(retained.entry.paneKey)) { - continue - } - const record = sleepingRecordFromEntry({ - state, - entry: isManualWorktreeSleep - ? manualSleepCaptureEntry(retained.entry, capturedAt) - : retained.entry, - worktreeId, - tab: retained.tab, - capturedAt, - launchConfig: getLaunchConfigForEntry(state, retained.entry), - origin - }) - if (record) { - if (isManualWorktreeSleep) { - markManualSleepLazyRestore(record) - carryOverAutomaticResumeBlock( - record, - state.sleepingAgentSessionsByPaneKey[retained.entry.paneKey] - ) - } - records[record.paneKey] = record - } - } - - for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { - if (allowedPaneKeys && !allowedPaneKeys.has(paneKey)) { - continue - } - // Why: the promoted checkpoint carries recovery identity (transcript, connection) the live - // turn row lacks, so it must not be overwritten by a re-derived record. - if (promotedLiveRecoveryPaneKeys.has(paneKey)) { - continue - } - const belongsToWorktree = - entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) - if (!belongsToWorktree) { - continue - } - if (isCompletedAgentHibernation && !isValidCompletedAgentHibernationEntry(entry)) { - continue - } - const record = sleepingRecordFromEntry({ - state, - entry: isManualWorktreeSleep ? manualSleepCaptureEntry(entry, capturedAt) : entry, - worktreeId, - capturedAt, - launchConfig: getLaunchConfigForEntry(state, entry), - origin - }) - if (record) { - if (isManualWorktreeSleep) { - markManualSleepLazyRestore(record) - carryOverAutomaticResumeBlock(record, state.sleepingAgentSessionsByPaneKey[paneKey]) - } - records[record.paneKey] = record - } - } - - return records -} - -export function collectHibernatedCompletionEvidenceForWorktree( - state: AppState, - worktreeId: string, - paneKeys?: readonly string[] -): RetainedAgentEntry[] { - const allowedPaneKeys = normalizePaneKeySet(paneKeys) - if (!allowedPaneKeys || allowedPaneKeys.size === 0) { - return [] - } - const tabPrefixes = (state.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) - const retained: RetainedAgentEntry[] = [] - for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { - const agentType = entry.agentType - if ( - !allowedPaneKeys.has(paneKey) || - entry.state !== 'done' || - agentType === undefined || - entry.interrupted === true - ) { - continue - } - const belongsToWorktree = - entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) - if (!belongsToWorktree) { - continue - } - retained.push(retainedAgentEntryFromLive(state, worktreeId, entry, agentType)) - } - return retained -} - -// Why: comparing all fields except capturedAt lets an unchanged agent skip the store write, -// so periodic idle re-captures never dirty session persistence. -function sleepingRecordsEquivalentIgnoringCaptureTime( - existing: SleepingAgentSessionRecord | undefined, - next: SleepingAgentSessionRecord -): boolean { - if (!existing) { - return false - } - return ( - existing.paneKey === next.paneKey && - existing.tabId === next.tabId && - existing.worktreeId === next.worktreeId && - existing.agent === next.agent && - agentProviderSessionsEqual(existing.agent, existing.providerSession, next.providerSession) && - existing.prompt === next.prompt && - existing.state === next.state && - existing.updatedAt === next.updatedAt && - existing.terminalTitle === next.terminalTitle && - existing.lastAssistantMessage === next.lastAssistantMessage && - existing.interrupted === next.interrupted && - existing.origin === next.origin && - launchConfigsEqual(existing.launchConfig, next.launchConfig) - ) -} - -function recoveryRecordMatches( - existing: SleepingAgentSessionRecord | undefined, - next: SleepingAgentSessionRecord -): boolean { - if (!existing) { - return false - } - // Why: completion or interruption must replace a pre-status working checkpoint. - return ( - existing.origin === next.origin && - existing.agent === next.agent && - existing.worktreeId === next.worktreeId && - existing.tabId === next.tabId && - existing.state === next.state && - existing.interrupted === next.interrupted && - agentProviderSessionsEqual(existing.agent, existing.providerSession, next.providerSession) && - launchConfigsEqual(existing.launchConfig, next.launchConfig) - ) -} - -function recoveryRecordTargetsSameSession( - existing: SleepingAgentSessionRecord | undefined, - next: SleepingAgentSessionRecord -): boolean { - if (!existing) { - return false - } - return ( - existing.agent === next.agent && - existing.worktreeId === next.worktreeId && - existing.tabId === next.tabId && - agentProviderSessionsEqual(existing.agent, existing.providerSession, next.providerSession) - ) -} - -function copyLaunchConfig(config: SleepingAgentLaunchConfig): SleepingAgentLaunchConfig { - return { - ...(config.agentCommand ? { agentCommand: config.agentCommand } : {}), - agentArgs: config.agentArgs, - agentEnv: { ...config.agentEnv }, - ...(config.ompResumeFilePath ? { ompResumeFilePath: config.ompResumeFilePath } : {}) - } -} - -function launchConfigsEqual( - a: SleepingAgentLaunchConfig | undefined, - b: SleepingAgentLaunchConfig | undefined -): boolean { - if (a === undefined || b === undefined) { - return a === b - } - if ( - a.agentCommand !== b.agentCommand || - a.agentArgs !== b.agentArgs || - a.ompResumeFilePath !== b.ompResumeFilePath - ) { - return false - } - const aKeys = Object.keys(a.agentEnv) - const bKeys = Object.keys(b.agentEnv) - return aKeys.length === bKeys.length && aKeys.every((key) => a.agentEnv[key] === b.agentEnv[key]) -} - -function normalizeLaunchConfigRegistrationMetadata( - paneKey: string, - metadata: AgentLaunchConfigRegistrationMetadata | undefined -): AgentLaunchConfigRegistrationMetadata { - return { - ...(metadata?.agentType ? { agentType: metadata.agentType } : {}), - ...(metadata?.launchToken ? { launchToken: metadata.launchToken } : {}), - tabId: metadata?.tabId ?? getTabIdFromPaneKey(paneKey) ?? undefined, - leafId: metadata?.leafId ?? getLeafIdFromPaneKey(paneKey) ?? undefined, - ...(metadata?.terminalHandle ? { terminalHandle: metadata.terminalHandle } : {}), - ...(metadata?.providerSession ? { providerSession: metadata.providerSession } : {}) - } -} - -function launchConfigRegistryEntriesEqual( - a: AgentLaunchConfigRegistryEntry | undefined, - b: AgentLaunchConfigRegistryEntry -): boolean { - return ( - a !== undefined && - launchConfigsEqual(a.launchConfig, b.launchConfig) && - a.identity.agentType === b.identity.agentType && - a.identity.launchToken === b.identity.launchToken && - a.identity.tabId === b.identity.tabId && - a.identity.leafId === b.identity.leafId && - a.identity.terminalHandle === b.identity.terminalHandle && - agentProviderSessionsEqual( - a.identity.agentType ?? b.identity.agentType, - a.identity.providerSession, - b.identity.providerSession - ) - ) -} - -function registryEntryMatchesStatus(args: { - entry: AgentLaunchConfigRegistryEntry | undefined - paneKey: string - agentType: AgentType | undefined - tabId: string | undefined - terminalHandle: string | undefined - launchToken: string | undefined - providerSession: AgentProviderSessionMetadata | undefined - existingProviderSession: AgentProviderSessionMetadata | undefined - providerSessionChanged: boolean -}): boolean { - const entry = args.entry - if (!entry || args.providerSessionChanged) { - return false - } - const identity = entry.identity - if (identity.agentType !== undefined && identity.agentType !== args.agentType) { - return false - } - if (identity.tabId !== undefined && identity.tabId !== args.tabId) { - return false - } - if (identity.leafId !== undefined && identity.leafId !== getLeafIdFromPaneKey(args.paneKey)) { - return false - } - if ( - identity.terminalHandle !== undefined && - (args.terminalHandle === undefined || identity.terminalHandle !== args.terminalHandle) - ) { - return false - } - if ( - identity.launchToken !== undefined && - (args.launchToken === undefined || identity.launchToken !== args.launchToken) - ) { - // Why: a missing/mismatched launch token is stale proof even if a later manual/mixed Codex run reused the provider session id. - return false - } - if (identity.providerSession !== undefined) { - return agentProviderSessionsEqual( - args.agentType, - identity.providerSession, - args.providerSession - ) - } - if (identity.launchToken !== undefined) { - return true - } - if (identity.terminalHandle !== undefined) { - return true - } - if (args.existingProviderSession && args.providerSession) { - return agentProviderSessionsEqual( - args.agentType, - args.existingProviderSession, - args.providerSession - ) - } - return false -} - -function getLaunchConfigForEntry( - state: AppState, - entry: AgentStatusEntry -): SleepingAgentLaunchConfig | undefined { - const registryEntry = state.agentLaunchConfigByPaneKey[entry.paneKey] - const registryLaunchConfig = registryEntryMatchesStatus({ - entry: registryEntry, - paneKey: entry.paneKey, - agentType: entry.agentType, - tabId: entry.tabId ?? getTabIdFromPaneKey(entry.paneKey) ?? undefined, - terminalHandle: entry.terminalHandle, - launchToken: undefined, - providerSession: entry.providerSession, - existingProviderSession: entry.providerSession, - providerSessionChanged: false - }) - ? registryEntry?.launchConfig - : undefined - if (registryLaunchConfig) { - return registryLaunchConfig - } - const sleepingRecord = state.sleepingAgentSessionsByPaneKey[entry.paneKey] - return sleepingRecord?.launchConfig && - sleepingRecord.agent === entry.agentType && - entry.providerSession && - agentProviderSessionsEqual( - entry.agentType, - sleepingRecord.providerSession, - entry.providerSession - ) - ? sleepingRecord.launchConfig - : undefined -} - -// Why: renderer twin of main's #7561 FIFO-capped closedAgentStatusTabIds — suppresses late -// events for a just-closed tab, but was add-only and grew unbounded, hence this cap. -export const RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 -export const RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX = 1024 - -// delete-then-set for LRU recency, then evict oldest keys past the cap (Record iterates -// insertion order); safe because a status for a tab closed >MAX tabs ago cannot still arrive. -function boundRecentlyClosedAgentStatusTabIds( - existing: Record, - tabId: string -): Record { - const next: Record = {} - for (const key of Object.keys(existing)) { - if (key !== tabId) { - next[key] = true - } - } - next[tabId] = true - const keys = Object.keys(next) - if (keys.length > RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX) { - for (const stale of keys.slice(0, keys.length - RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX)) { - delete next[stale] - } - } - return next -} - -function boundRecentlyRetiredAgentStatusPaneKeys( - existing: Record, - paneKeys: readonly string[] -): Record { - const additions = new Set(paneKeys) - const next: Record = {} - for (const key of Object.keys(existing)) { - if (!additions.has(key)) { - next[key] = true - } - } - for (const paneKey of additions) { - next[paneKey] = true - } - const keys = Object.keys(next) - for (const stale of keys.slice(0, -RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX)) { - delete next[stale] - } - return next -} - -function movePaneKeyedRecord( - record: Record, - fromPaneKey: string, - toPaneKey: string, - transform: (value: T) => T = (value) => value -): Record { - const value = record[fromPaneKey] - if (value === undefined || fromPaneKey === toPaneKey) { - return record - } - const next = { ...record } - delete next[fromPaneKey] - next[toPaneKey] = transform(value) - return next -} - -function removePaneKeys( - record: Record, - paneKeys: ReadonlySet -): Record { - const matchingKeys = Object.keys(record).filter((key) => paneKeys.has(key)) - if (matchingKeys.length === 0) { - return record - } - const next = { ...record } - for (const key of matchingKeys) { - delete next[key] - } - return next -} - -function getLaunchConfigForStatusMetadata( - state: AppState, - metadata: AgentLaunchConfigStatusMetadata -): SleepingAgentLaunchConfig | undefined { - const registryEntry = state.agentLaunchConfigByPaneKey[metadata.paneKey] - return registryEntryMatchesStatus({ - entry: registryEntry, - paneKey: metadata.paneKey, - agentType: metadata.agentType, - tabId: metadata.tabId ?? getTabIdFromPaneKey(metadata.paneKey) ?? undefined, - terminalHandle: metadata.terminalHandle, - launchToken: metadata.launchToken, - providerSession: metadata.providerSession, - existingProviderSession: metadata.existingProviderSession, - providerSessionChanged: metadata.providerSessionChanged ?? false - }) - ? registryEntry?.launchConfig - : undefined -} - -function pruneMigrationUnsupportedEntries( - entries: Record, - predicate: (entry: MigrationUnsupportedPtyEntry) => boolean -): { next: Record; changed: boolean } { - let changed = false - const next: Record = {} - for (const [ptyId, entry] of Object.entries(entries)) { - if (predicate(entry)) { - changed = true - continue - } - next[ptyId] = entry - } - return { next: changed ? next : entries, changed } -} - -function orchestrationContextsEqual( - a: AgentStatusOrchestrationContext, - b: AgentStatusOrchestrationContext -): boolean { - return ( - a.taskId === b.taskId && - a.dispatchId === b.dispatchId && - a.dispatchStatus === b.dispatchStatus && - a.taskTitle === b.taskTitle && - a.displayName === b.displayName && - a.parentTerminalHandle === b.parentTerminalHandle && - a.parentPaneKey === b.parentPaneKey && - a.coordinatorHandle === b.coordinatorHandle && - a.orchestrationRunId === b.orchestrationRunId - ) -} - -function orchestrationMapsEqual( - a: Record, - b: Record -): boolean { - const aKeys = Object.keys(a) - const bKeys = Object.keys(b) - if (aKeys.length !== bKeys.length) { - return false - } - return aKeys.every((key) => b[key] !== undefined && orchestrationContextsEqual(a[key]!, b[key]!)) -} - -function mergeCurrentOrchestrationContext( - existing: AgentStatusOrchestrationContext | undefined, - current: AgentStatusOrchestrationContext -): AgentStatusOrchestrationContext { - if (!existing) { - return current - } - const sameDispatch = - existing.taskId === current.taskId && existing.dispatchId === current.dispatchId - if (!sameDispatch) { - return current - } - if (current.dispatchStatus !== undefined) { - return orchestrationContextsEqual(existing, current) ? existing : current - } - const merged = { ...existing, ...current } - return orchestrationContextsEqual(existing, merged) ? existing : merged -} - -// Why: relay/daemon teardown drops main's rows, but renderer entries whose connectionId stamp never -// matched (unstamped over SSH) survive and stay "fresh" 30 min (#9030). Resolve each worktree's host -// via the canonical hostId-first precedence and keep only ids UNAMBIGUOUSLY on this connection — a -// worktree id is `${repoId}::${path}` (no host component), so the same project mirrored at the same -// path on two hosts yields one shared id that must not clear another host's live rows. -function collectWorktreeIdsForConnection(state: AppState, connectionId: string): Set { - const hostIdsOnConnection = new Set( - state.repos - .filter((repo) => repo.connectionId === connectionId) - .map((repo) => getRepoExecutionHostId(repo)) - ) - if (hostIdsOnConnection.size === 0) { - return new Set() - } - const repoById = new Map(state.repos.map((repo) => [repo.id, repo] as const)) - const onConnection = new Set() - const onOtherHost = new Set() - for (const [repoId, worktrees] of Object.entries(state.worktreesByRepo)) { - const repo = repoById.get(repoId) - for (const worktree of worktrees) { - const bucket = hostIdsOnConnection.has(getWorktreeExecutionHostId(worktree, repo)) - ? onConnection - : onOtherHost - bucket.add(worktree.id) - } - } - // A worktree id that also lives on another host is ambiguous — leave it rather than hide a live row. - for (const id of onOtherHost) { - onConnection.delete(id) - } - return onConnection -} - -/** Slices that only the fold touched, so the batch commits as a MERGE. - * Why: the fold runs against a snapshot taken at transaction start. Committing that - * snapshot wholesale (zustand REPLACE) would silently revert any write that landed on - * the real store meanwhile — and it can, because a batched action reaching another - * slice's setter through `get()` bypasses the shadowed `set` entirely - * (setRuntimeAgentOrchestrationByPaneKey → setGeneratedTabTitleFromAgentPrompt does this). - * A merge keeps the single publication while confining conflicts to keys this fold wrote. */ -function buildAgentStatusBatchPatch( - initialState: AppState, - nextState: AppState -): Partial { - const patch: Record = {} - for (const key of Object.keys(nextState) as (keyof AppState)[]) { - if (!Object.is(nextState[key], initialState[key])) { - patch[key as string] = nextState[key] - } - } - return patch as Partial -} - -/** The slice of app state the tab-prefix drop reduces over — narrow so callers - * outside the store (the paired snapshot apply) can build the same patch. */ -export type AgentStatusTabPrefixDropState = Pick< - AppState, - | 'acknowledgedAgentsByPaneKey' - | 'agentLaunchConfigByPaneKey' - | 'agentStatusByPaneKey' - | 'agentStatusEpoch' - | 'migrationUnsupportedByPtyId' - | 'recentlyClosedAgentStatusTabIds' - | 'recentlyRetiredAgentStatusPaneKeys' - | 'retainedAgentsByPaneKey' - | 'retentionSuppressedPaneKeys' - | 'sortEpoch' - | 'tabsByWorktree' -> - -/** Pure form of the dropAgentStatusByTabPrefix reducer: the paired snapshot - * apply folds the same sweep into a patch it assembles itself, so the two - * paths cannot drift. `retiredAliasPaneKeys` comes from the caller because - * retiring pane-authority aliases is a registry side effect, not a reduction. */ -export function buildAgentStatusTabPrefixDropPatch( - s: AgentStatusTabPrefixDropState, - tabIdPrefix: string, - retiredAliasPaneKeys: readonly string[], - opts?: DropAgentStatusByTabPrefixOptions -): { patch: Partial; hadLive: boolean } { - const prefix = `${tabIdPrefix}:` - let hadLive = false - const buildPatch = (): Partial => { - const completedOrphanKeys = findCompletedOrphanPaneKeysForTabClose(s, opts?.worktreeId, prefix) - const completedOrphanKeySet = new Set(completedOrphanKeys) - const liveKeys = [ - ...Object.keys(s.agentStatusByPaneKey).filter((k) => k.startsWith(prefix)), - ...completedOrphanKeys - ] - const launchConfigKeys = Object.keys(s.agentLaunchConfigByPaneKey).filter( - (k) => k.startsWith(prefix) || completedOrphanKeySet.has(k) - ) - const retainedKeys = Object.keys(s.retainedAgentsByPaneKey).filter( - (k) => k.startsWith(prefix) || completedOrphanKeySet.has(k) - ) - const migrationUnsupported = pruneMigrationUnsupportedEntries( - s.migrationUnsupportedByPtyId, - (entry) => entry.paneKey?.startsWith(prefix) ?? false - ) - // See removeAgentStatus for ack-cleanup rationale; ack entries are owned by the pane lifecycle regardless of live/retained state. - let nextAck = s.acknowledgedAgentsByPaneKey - const ackKeys = Object.keys(nextAck).filter( - (k) => k.startsWith(prefix) || completedOrphanKeySet.has(k) - ) - if (ackKeys.length > 0) { - nextAck = { ...nextAck } - for (const k of ackKeys) { - delete nextAck[k] - } - } - const nextClosedTabs = boundRecentlyClosedAgentStatusTabIds( - s.recentlyClosedAgentStatusTabIds, - tabIdPrefix - ) - const nextRetiredPaneKeys = boundRecentlyRetiredAgentStatusPaneKeys( - s.recentlyRetiredAgentStatusPaneKeys, - retiredAliasPaneKeys - ) - - if ( - liveKeys.length === 0 && - launchConfigKeys.length === 0 && - retainedKeys.length === 0 && - !migrationUnsupported.changed - ) { - if (nextAck !== s.acknowledgedAgentsByPaneKey) { - return { - acknowledgedAgentsByPaneKey: nextAck, - recentlyClosedAgentStatusTabIds: nextClosedTabs, - recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys - } - } - return { - recentlyClosedAgentStatusTabIds: nextClosedTabs, - recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys - } - } - hadLive = liveKeys.length > 0 - - const nextLive = liveKeys.length > 0 ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey - for (const key of liveKeys) { - delete nextLive[key] - } - const nextLaunchConfigs = - launchConfigKeys.length > 0 - ? { ...s.agentLaunchConfigByPaneKey } - : s.agentLaunchConfigByPaneKey - for (const key of launchConfigKeys) { - delete nextLaunchConfigs[key] - } - - const nextRetained = - retainedKeys.length > 0 ? { ...s.retainedAgentsByPaneKey } : s.retainedAgentsByPaneKey - for (const key of retainedKeys) { - delete nextRetained[key] - } - - // Why: a suppressor is only consumed on a live→gone transition, so plant one only for live paneKeys and skip already-suppressed and completed-orphan keys — otherwise it leaks (mirrors dropAgentStatus). - const suppressorAdds = liveKeys.filter( - (k) => !completedOrphanKeySet.has(k) && !(k in s.retentionSuppressedPaneKeys) - ) - let nextRetentionSuppressedPaneKeys = s.retentionSuppressedPaneKeys - if (suppressorAdds.length > 0) { - nextRetentionSuppressedPaneKeys = { ...s.retentionSuppressedPaneKeys } - for (const key of suppressorAdds) { - nextRetentionSuppressedPaneKeys[key] = true - } - } - - return { - agentStatusByPaneKey: nextLive, - agentLaunchConfigByPaneKey: nextLaunchConfigs, - retainedAgentsByPaneKey: nextRetained, - migrationUnsupportedByPtyId: migrationUnsupported.next, - retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys, - recentlyClosedAgentStatusTabIds: nextClosedTabs, - recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys, - ...(nextAck !== s.acknowledgedAgentsByPaneKey - ? { acknowledgedAgentsByPaneKey: nextAck } - : {}), - // Why: mirrors removeAgentStatusByTabPrefix — only bump epochs when the live map changed; retained-only sweeps don't affect sort/freshness. - agentStatusEpoch: - hadLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, - sortEpoch: hadLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch - } - } - const patch = buildPatch() - return { patch, hadLive } -} +import type { AgentStatusSlice } from './agent-status-slice-contract' +import { createAgentStatusRuntime } from './agent-status-runtime' +import { createAgentStatusAuthorityActions } from './agent-status-authority-actions' +import { createAgentStatusCleanupActions } from './agent-status-cleanup-actions' +import { createAgentStatusDropActions } from './agent-status-drop-actions' +import { createAgentStatusWorktreeDropActions } from './agent-status-worktree-drop-actions' +import { createAgentStatusLaunchActions } from './agent-status-launch-actions' +import { createAgentStatusLiveActions } from './agent-status-live-actions' +import { createAgentStatusOrchestrationActions } from './agent-status-orchestration-actions' +import { createAgentStatusProviderSessionActions } from './agent-status-provider-session-actions' +import { createAgentStatusRecoveryActions } from './agent-status-recovery-actions' +import { createAgentStatusRetentionActions } from './agent-status-retention-actions' + +// Keep this path as the public contract for callers while implementation details live beside it. +export type { + AgentLaunchConfigRegistryEntry, + AgentLaunchConfigRegistrationMetadata, + AgentLaunchConfigStatusMetadata, + AgentProviderSessionRecordMetadata, + AgentProviderSessionRouting, + AgentProviderSessionTiming, + AgentStatusBatchTransaction, + AgentStatusBatchUpdate, + AgentStatusMetadata, + AgentStatusPayload, + AgentStatusRouting, + AgentStatusTiming, + AgentStatusUpdate, + AgentProviderSessionUpdate, + AgentStatusWorktreeShutdownReason, + AllAgentSessionCaptureMode, + DropAgentStatusByTabPrefixOptions, + DropAgentStatusByWorktreeOptions, + DropHibernatedAgentPaneOptions, + RetainedAgentEntry, + AgentStateHistoryEntry, + AgentStatusEntry, + AgentStatusOrchestrationContext, + AgentType, + MigrationUnsupportedPtyEntry, + ParsedAgentStatusPayload, + AgentProviderSessionMetadata, + ResumableTuiAgent, + SleepingAgentLaunchConfig, + SleepingAgentSessionRecord +} from './agent-status-contract' +export type { AgentStatusSlice } from './agent-status-slice-contract' +export { + collectSleepingAgentSessionRecordsForWorktree, + collectHibernatedCompletionEvidenceForWorktree +} from './agent-status-recovery-collection' +export { removeSleepingRecordsReplacedByManualWorktreeSleep } from './agent-status-recovery-helpers' +export { + buildAgentStatusTabPrefixDropPatch, + type AgentStatusTabPrefixDropState +} from './agent-status-drop-reducer' +export { MAX_LIVE_AGENT_STATUSES } from './agent-status-pane-helpers' +export { + RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX, + RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX +} from './agent-status-map-helpers' export const createAgentStatusSlice: StateCreator = ( storeSet, storeGet ) => { - type AgentStatusStateUpdate = - | AppState - | Partial - | ((state: AppState) => AppState | Partial) - - let batchedAgentStatusState: AppState | null = null - let batchedAgentStatusEffects: (() => void)[] | null = null - let batchedGeneratedTabTitleUpdates: GeneratedTabTitleUpdate[] | null = null - let batchedAgentStatusFreshnessRequested = false - const get = (): AppState => batchedAgentStatusState ?? storeGet() - // Deliberately narrower than zustand's `set`: no `replace` parameter, so no call site in - // this slice can compile into a REPLACE the batch commit is unable to express. - const set = (update: AgentStatusStateUpdate): void => { - if (batchedAgentStatusState === null) { - storeSet(update, false) - return - } - const nextState = typeof update === 'function' ? update(batchedAgentStatusState) : update - if (Object.is(nextState, batchedAgentStatusState)) { - return - } - batchedAgentStatusState = Object.assign({}, batchedAgentStatusState, nextState) - } - const runAfterAgentStatusCommit = (effect: () => void): void => { - if (batchedAgentStatusEffects) { - batchedAgentStatusEffects.push(effect) - return - } - effect() - } - const applyGeneratedTabTitleUpdate = (update: GeneratedTabTitleUpdate): void => { - if (batchedGeneratedTabTitleUpdates) { - batchedGeneratedTabTitleUpdates.push(update) - return - } - if (update.options) { - get().setGeneratedTabTitleFromAgentPrompt(update.paneKey, update.prompt, update.options) - } else { - get().setGeneratedTabTitleFromAgentPrompt(update.paneKey, update.prompt) - } - } - const applyBatchedAgentStatusUpdate = (update: AgentStatusBatchUpdate): boolean => { - const stateBeforeUpdate = batchedAgentStatusState - if (!stateBeforeUpdate) { - return false - } - if (update.kind === 'providerSession') { - get().recordAgentProviderSession( - update.paneKey, - update.agent, - update.providerSession, - update.timing, - update.routing, - update.metadata - ) - } else { - get().setAgentStatus( - update.paneKey, - update.payload, - update.terminalTitle, - update.timing, - update.routing, - update.metadata - ) - } - return batchedAgentStatusState !== stateBeforeUpdate - } - const batchTransaction: AgentStatusBatchTransaction = { - getState: get, - apply: applyBatchedAgentStatusUpdate, - afterCommit: runAfterAgentStatusCommit - } - const transactAgentStatuses = ( - operation: (transaction: AgentStatusBatchTransaction) => Result - ): Result => { - if (batchedAgentStatusState) { - return operation(batchTransaction) - } - const initialState = storeGet() - batchedAgentStatusState = initialState - batchedAgentStatusEffects = [] - batchedGeneratedTabTitleUpdates = [] - try { - const result = operation(batchTransaction) - const nextState = batchedAgentStatusState - const effects = batchedAgentStatusEffects - const generatedTabTitleUpdates = batchedGeneratedTabTitleUpdates - const freshnessRequested = batchedAgentStatusFreshnessRequested - batchedAgentStatusState = null - batchedAgentStatusEffects = null - batchedGeneratedTabTitleUpdates = null - batchedAgentStatusFreshnessRequested = false - if (nextState !== initialState) { - storeSet(buildAgentStatusBatchPatch(initialState, nextState), false) - } - if (generatedTabTitleUpdates.length > 0) { - storeGet().setGeneratedTabTitlesFromAgentPrompts(generatedTabTitleUpdates) - } - if (freshnessRequested) { - freshness.scheduleDeferred() - } - for (const effect of effects) { - effect() - } - return result - } finally { - batchedAgentStatusState = null - batchedAgentStatusEffects = null - batchedGeneratedTabTitleUpdates = null - batchedAgentStatusFreshnessRequested = false - } - } - - // Why: scheduler is process-lifetime-scoped (no dispose) because the store is a - // module-level singleton with no teardown lifecycle anywhere in the codebase. - const freshness = createFreshnessScheduler({ - getEntries: () => Object.values(get().agentStatusByPaneKey), - bumpEpochs: () => { - // Why: freshness is time-based — bump both epochs at the stale boundary to force selector - // recompute and re-sort even with no new output, since staleness can change worktree ordering. - set((s) => ({ - agentStatusEpoch: s.agentStatusEpoch + 1, - sortEpoch: s.sortEpoch + 1 - })) + let composedActions: + | Pick + | undefined + const runtime = createAgentStatusRuntime(storeSet, storeGet, () => { + if (!composedActions) { + throw new Error('agent-status actions are not initialized') } + return composedActions }) - const requestAgentStatusFreshness = (acceptedInBatch: boolean): void => { - if (batchedAgentStatusState !== null) { - batchedAgentStatusFreshnessRequested ||= acceptedInBatch - return - } - freshness.scheduleDeferred() + const actions = { + ...createAgentStatusAuthorityActions(runtime), + ...createAgentStatusCleanupActions(runtime), + ...createAgentStatusDropActions(runtime), + ...createAgentStatusWorktreeDropActions(runtime), + ...createAgentStatusLaunchActions(runtime), + ...createAgentStatusLiveActions(runtime), + ...createAgentStatusOrchestrationActions(runtime), + ...createAgentStatusProviderSessionActions(runtime), + ...createAgentStatusRecoveryActions(runtime), + ...createAgentStatusRetentionActions(runtime) + } as AgentStatusSlice + composedActions = { + setAgentStatus: actions.setAgentStatus, + recordAgentProviderSession: actions.recordAgentProviderSession } - - const clearSleepingAgentSessionsByPaneKey = (paneKeys: readonly string[]): void => { - if (paneKeys.length === 0) { - return - } - const uniquePaneKeys = new Set(paneKeys) - set((s) => { - let nextSleeping = s.sleepingAgentSessionsByPaneKey - let nextLaunchConfigs = s.agentLaunchConfigByPaneKey - for (const paneKey of uniquePaneKeys) { - if (paneKey in nextSleeping) { - if (nextSleeping === s.sleepingAgentSessionsByPaneKey) { - nextSleeping = { ...nextSleeping } - } - delete nextSleeping[paneKey] - } - if (paneKey in nextLaunchConfigs) { - if (nextLaunchConfigs === s.agentLaunchConfigByPaneKey) { - nextLaunchConfigs = { ...nextLaunchConfigs } - } - delete nextLaunchConfigs[paneKey] - } - } - if ( - nextSleeping === s.sleepingAgentSessionsByPaneKey && - nextLaunchConfigs === s.agentLaunchConfigByPaneKey - ) { - return s - } - return { - sleepingAgentSessionsByPaneKey: nextSleeping, - agentLaunchConfigByPaneKey: nextLaunchConfigs - } - }) - } - return { + ...actions, agentStatusByPaneKey: {}, runtimeAgentOrchestrationByPaneKey: {}, migrationUnsupportedByPtyId: {}, @@ -1626,1861 +103,6 @@ export const createAgentStatusSlice: StateCreator freshness.schedule(), - - retireAgentPaneAuthority: (paneKey, options) => { - const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey) - const retiredPaneKeys = retireAgentPaneAuthorityAliases(paneKey) - const retiredPaneKeySet = new Set(retiredPaneKeys) - for (const key of retiredPaneKeys) { - rendererAgentStatusObservations.forget(key) - } - let hadLive = false - set((s) => { - const retiredLivePaneKeys = retiredPaneKeys.filter((key) => key in s.agentStatusByPaneKey) - hadLive = retiredLivePaneKeys.length > 0 - let nextRetentionSuppressedPaneKeys = removePaneKeys( - s.retentionSuppressedPaneKeys, - retiredPaneKeySet - ) - if ( - retiredLivePaneKeys.length > 0 && - nextRetentionSuppressedPaneKeys === s.retentionSuppressedPaneKeys - ) { - nextRetentionSuppressedPaneKeys = { ...nextRetentionSuppressedPaneKeys } - } - for (const key of retiredLivePaneKeys) { - nextRetentionSuppressedPaneKeys[key] = true - } - return { - agentStatusByPaneKey: removePaneKeys(s.agentStatusByPaneKey, retiredPaneKeySet), - runtimeAgentOrchestrationByPaneKey: removePaneKeys( - s.runtimeAgentOrchestrationByPaneKey, - retiredPaneKeySet - ), - retainedAgentsByPaneKey: removePaneKeys(s.retainedAgentsByPaneKey, retiredPaneKeySet), - sleepingAgentSessionsByPaneKey: options?.preserveSleepingAgentSession - ? s.sleepingAgentSessionsByPaneKey - : removePaneKeys(s.sleepingAgentSessionsByPaneKey, retiredPaneKeySet), - agentLaunchConfigByPaneKey: removePaneKeys( - s.agentLaunchConfigByPaneKey, - retiredPaneKeySet - ), - acknowledgedAgentsByPaneKey: removePaneKeys( - s.acknowledgedAgentsByPaneKey, - retiredPaneKeySet - ), - paneForegroundAgentByPaneKey: removePaneKeys( - s.paneForegroundAgentByPaneKey, - retiredPaneKeySet - ), - unreadTerminalPanes: removePaneKeys(s.unreadTerminalPanes, retiredPaneKeySet), - unreadAgentCompletionPanes: removePaneKeys( - s.unreadAgentCompletionPanes, - retiredPaneKeySet - ), - lastTerminalInputAtByPaneKey: removePaneKeys( - s.lastTerminalInputAtByPaneKey, - retiredPaneKeySet - ), - cacheTimerByKey: removePaneKeys(s.cacheTimerByKey, retiredPaneKeySet), - retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys, - recentlyRetiredAgentStatusPaneKeys: boundRecentlyRetiredAgentStatusPaneKeys( - s.recentlyRetiredAgentStatusPaneKeys, - retiredPaneKeys - ), - agentStatusEpoch: hadLive ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, - sortEpoch: hadLive ? s.sortEpoch + 1 : s.sortEpoch - } - }) - if (hadLive) { - freshness.scheduleDeferred() - } - if (typeof window !== 'undefined') { - window.api?.agentStatus?.retirePaneAuthority?.(ownerPaneKey) - } - }, - - // Why: the tombstone claims this pane is gone; a live PTY binding to it proves - // otherwise. Lift the fence on that proof rather than on the next hook event — - // a pane re-attached mid-turn or while idle emits no new-turn event, so a - // turn-triggered revival leaves exactly the reported permanent suppression - // (STA-4114). This deliberately does NOT restore the rows retirement dropped; - // those are genuinely stale. It only re-opens the pane to future status. - restoreAgentPaneAuthority: (paneKey) => { - const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey) - // Why: a closed tab is a stronger, separate claim — re-attach must not undo it. - if ( - isRecentlyClosedAgentStatusTab( - get().recentlyClosedAgentStatusTabIds, - getTabIdFromPaneKey(ownerPaneKey) - ) - ) { - return - } - set((s) => { - const restorable = [paneKey, ownerPaneKey].filter( - (key) => key in s.recentlyRetiredAgentStatusPaneKeys - ) - if (restorable.length === 0) { - return s - } - const next = { ...s.recentlyRetiredAgentStatusPaneKeys } - for (const key of restorable) { - delete next[key] - } - return { recentlyRetiredAgentStatusPaneKeys: next } - }) - // Why: deliberately OUTSIDE the guard above, and not gated on having cleared - // anything here. This map is not a mirror of main's — main fences panes the - // renderer never hears about (retirePtyAgentLaunchAuthority on command-finished - // and PTY exit calls the hook server directly, and nothing pushes that back), and - // this map is per-window and non-persisted, so a renderer reload empties it while - // main's survives. Gating the send on a local tombstone reintroduces STA-4114 for - // exactly those panes. The send is idempotent and main refuses closed tabs itself. - if (typeof window !== 'undefined') { - window.api?.agentStatus?.restorePaneAuthority?.(ownerPaneKey) - } - }, - - transferAgentPaneAuthority: ({ fromPaneKey, toPaneKey, ptyId }) => { - const transfer = transferAgentPaneAuthorityAlias({ fromPaneKey, toPaneKey, ptyId }) - if (!transfer || transfer.previousOwnerPaneKey === transfer.ownerPaneKey) { - return - } - const from = transfer.previousOwnerPaneKey - const to = transfer.ownerPaneKey - // Why: the moved row carries the observation stamped for its OLD key; renderer-authored - // observations for the new key must sort after it, not race it. - rendererAgentStatusObservations.forget(from) - rendererAgentStatusObservations.rebind(to) - const targetTabId = getTabIdFromPaneKey(to) ?? undefined - const targetLeafId = getLeafIdFromPaneKey(to) ?? undefined - set((s) => ({ - agentStatusByPaneKey: movePaneKeyedRecord(s.agentStatusByPaneKey, from, to, (entry) => ({ - ...entry, - paneKey: to, - tabId: targetTabId - })), - // Why: retention/sidebar consumers gate on the epoch; a moved live row is a - // pane-key change they must observe, not a silent remap. - ...(from in s.agentStatusByPaneKey - ? { agentStatusEpoch: s.agentStatusEpoch + 1, sortEpoch: s.sortEpoch + 1 } - : {}), - runtimeAgentOrchestrationByPaneKey: movePaneKeyedRecord( - s.runtimeAgentOrchestrationByPaneKey, - from, - to - ), - retainedAgentsByPaneKey: movePaneKeyedRecord( - s.retainedAgentsByPaneKey, - from, - to, - (retained) => ({ - ...retained, - entry: { ...retained.entry, paneKey: to, tabId: targetTabId }, - tab: targetTabId ? { ...retained.tab, id: targetTabId } : retained.tab - }) - ), - sleepingAgentSessionsByPaneKey: movePaneKeyedRecord( - s.sleepingAgentSessionsByPaneKey, - from, - to, - (record) => ({ ...record, paneKey: to, tabId: targetTabId }) - ), - agentLaunchConfigByPaneKey: movePaneKeyedRecord( - s.agentLaunchConfigByPaneKey, - from, - to, - (entry) => ({ - ...entry, - identity: { ...entry.identity, tabId: targetTabId, leafId: targetLeafId } - }) - ), - acknowledgedAgentsByPaneKey: movePaneKeyedRecord(s.acknowledgedAgentsByPaneKey, from, to), - paneForegroundAgentByPaneKey: movePaneKeyedRecord(s.paneForegroundAgentByPaneKey, from, to), - unreadTerminalPanes: movePaneKeyedRecord(s.unreadTerminalPanes, from, to), - unreadAgentCompletionPanes: movePaneKeyedRecord(s.unreadAgentCompletionPanes, from, to), - lastTerminalInputAtByPaneKey: movePaneKeyedRecord(s.lastTerminalInputAtByPaneKey, from, to), - cacheTimerByKey: movePaneKeyedRecord(s.cacheTimerByKey, from, to), - retentionSuppressedPaneKeys: movePaneKeyedRecord(s.retentionSuppressedPaneKeys, from, to) - })) - if (typeof window !== 'undefined') { - window.api?.agentStatus?.transferPaneAuthority?.({ - fromPaneKey: from, - toPaneKey: to, - ...(transfer.ptyId ? { ptyId: transfer.ptyId } : {}) - }) - } - }, - - setRuntimeAgentOrchestrationByPaneKey: (entries) => { - const generatedTitleUpdates: AgentStatusEntry[] = [] - set((s) => { - const runtimeMapChanged = !orchestrationMapsEqual( - s.runtimeAgentOrchestrationByPaneKey, - entries - ) - let nextLive = s.agentStatusByPaneKey - let liveChanged = false - let nextRetained = s.retainedAgentsByPaneKey - let retainedChanged = false - - for (const [paneKey, runtimeOrchestration] of Object.entries(entries)) { - const liveEntry = nextLive[paneKey] - if (liveEntry) { - const merged = mergeCurrentOrchestrationContext( - liveEntry.orchestration, - runtimeOrchestration - ) - if (merged !== liveEntry.orchestration) { - if (!liveChanged) { - nextLive = { ...nextLive } - liveChanged = true - } - const nextEntry = { ...liveEntry, orchestration: merged } - nextLive[paneKey] = nextEntry - // Why: only replace titles when labels match the live dispatch taskId; sticky completed context must not rename a later turn. - if ( - (merged.displayName?.trim() || merged.taskTitle?.trim()) && - orchestrationLabelsMatchLiveDispatch({ - prompt: nextEntry.prompt, - orchestration: merged - }) - ) { - generatedTitleUpdates.push(nextEntry) - } - } - } - - const retainedEntry = nextRetained[paneKey] - if (retainedEntry) { - const merged = mergeCurrentOrchestrationContext( - retainedEntry.entry.orchestration, - runtimeOrchestration - ) - if (merged !== retainedEntry.entry.orchestration) { - if (!retainedChanged) { - nextRetained = { ...nextRetained } - retainedChanged = true - } - nextRetained[paneKey] = { - ...retainedEntry, - entry: { ...retainedEntry.entry, orchestration: merged } - } - } - } - } - - if (!runtimeMapChanged && !liveChanged && !retainedChanged) { - return s - } - - return { - ...(runtimeMapChanged ? { runtimeAgentOrchestrationByPaneKey: entries } : {}), - ...(liveChanged ? { agentStatusByPaneKey: nextLive } : {}), - ...(retainedChanged ? { retainedAgentsByPaneKey: nextRetained } : {}), - ...(liveChanged ? { agentStatusEpoch: s.agentStatusEpoch + 1 } : {}) - } - }) - for (const entry of generatedTitleUpdates) { - get().setGeneratedTabTitleFromAgentPrompt( - entry.paneKey, - getAgentRowGeneratedTitleText(entry), - { - replaceExistingGeneratedTitle: true - } - ) - } - }, - - registerAgentLaunchConfig: (paneKey, launchConfig, metadata) => { - set((s) => { - const copiedLaunchConfig = copyLaunchConfig(launchConfig) - const nextRegistryEntry: AgentLaunchConfigRegistryEntry = { - launchConfig: copiedLaunchConfig, - registeredAt: Date.now(), - identity: normalizeLaunchConfigRegistrationMetadata(paneKey, metadata) - } - const existingRegistryEntry = s.agentLaunchConfigByPaneKey[paneKey] - const registryChanged = !launchConfigRegistryEntriesEqual( - existingRegistryEntry, - nextRegistryEntry - ) - const existingEntry = s.agentStatusByPaneKey[paneKey] - const entryMatchesRegistry = registryEntryMatchesStatus({ - entry: nextRegistryEntry, - paneKey, - agentType: existingEntry?.agentType, - tabId: existingEntry?.tabId ?? getTabIdFromPaneKey(paneKey) ?? undefined, - terminalHandle: existingEntry?.terminalHandle, - launchToken: metadata?.launchToken, - providerSession: existingEntry?.providerSession, - existingProviderSession: existingEntry?.providerSession, - providerSessionChanged: false - }) - const existingSleepingRecord = s.sleepingAgentSessionsByPaneKey[paneKey] - let nextSleepingAgentSessions = s.sleepingAgentSessionsByPaneKey - if (existingSleepingRecord && entryMatchesRegistry && existingEntry) { - const worktreeId = - existingEntry.worktreeId ?? - existingSleepingRecord.worktreeId ?? - findAgentPaneWorktreeId(s, paneKey) - const refreshedRecord = worktreeId - ? sleepingRecordFromEntry({ - state: s, - entry: existingEntry, - worktreeId, - capturedAt: existingSleepingRecord.capturedAt, - launchConfig: copiedLaunchConfig, - origin: existingSleepingRecord.origin - }) - : null - if (refreshedRecord) { - nextSleepingAgentSessions = { - ...s.sleepingAgentSessionsByPaneKey, - [paneKey]: { - ...refreshedRecord, - capturedAt: existingSleepingRecord.capturedAt - } - } - } - } - if (!registryChanged && nextSleepingAgentSessions === s.sleepingAgentSessionsByPaneKey) { - return s - } - return { - ...(registryChanged - ? { - agentLaunchConfigByPaneKey: { - ...s.agentLaunchConfigByPaneKey, - [paneKey]: nextRegistryEntry - } - } - : {}), - ...(nextSleepingAgentSessions !== s.sleepingAgentSessionsByPaneKey - ? { sleepingAgentSessionsByPaneKey: nextSleepingAgentSessions } - : {}) - } - }) - }, - getAgentLaunchConfigForStatusEntry: (entry) => getLaunchConfigForEntry(get(), entry), - getAgentLaunchConfigForStatusMetadata: (metadata) => - getLaunchConfigForStatusMetadata(get(), metadata), - - clearAgentLaunchConfig: (paneKey) => { - set((s) => { - if (!(paneKey in s.agentLaunchConfigByPaneKey)) { - return s - } - const nextLaunchConfigs = { ...s.agentLaunchConfigByPaneKey } - delete nextLaunchConfigs[paneKey] - return { agentLaunchConfigByPaneKey: nextLaunchConfigs } - }) - }, - - recordAgentProviderSession: (paneKey, agent, providerSession, timing, routing, metadata) => { - paneKey = resolveAgentPaneAuthorityKey(paneKey) - const updatedAt = timing?.updatedAt ?? Date.now() - if ( - paneKey in get().recentlyRetiredAgentStatusPaneKeys || - isRecentlyClosedAgentStatusTab( - get().recentlyClosedAgentStatusTabIds, - getTabIdFromPaneKey(paneKey) - ) || - !getAgentResumeArgv(agent, providerSession) - ) { - return - } - let removedLiveStatus = false - set((s) => { - const existingStatus = s.agentStatusByPaneKey[paneKey] - const existingRecord = s.sleepingAgentSessionsByPaneKey[paneKey] - if ( - (existingStatus && updatedAt < existingStatus.updatedAt) || - (existingRecord && updatedAt < existingRecord.updatedAt) - ) { - return s - } - const tabId = routing?.tabId ?? getTabIdFromPaneKey(paneKey) ?? existingRecord?.tabId - const worktreeId = - routing?.worktreeId ?? - existingStatus?.worktreeId ?? - existingRecord?.worktreeId ?? - findAgentPaneWorktreeId(s, paneKey) - if (!worktreeId) { - return s - } - const registryEntry = s.agentLaunchConfigByPaneKey[paneKey] - const registryMatches = registryEntryMatchesStatus({ - entry: registryEntry, - paneKey, - agentType: agent, - tabId, - terminalHandle: undefined, - launchToken: metadata?.launchToken, - providerSession, - existingProviderSession: existingRecord?.providerSession, - providerSessionChanged: false - }) - const existingRecordMatchesProviderSession = - existingRecord?.agent === agent && - agentProviderSessionsEqual(agent, existingRecord.providerSession, providerSession) - // Why: provider-session heartbeats can arrive after the turn is complete; preserve the - // completed checkpoint so a late heartbeat cannot make it eligible for ghost resume. - const preservesCompletedRecoveryRecord = - existingRecordMatchesProviderSession && existingRecord?.state === 'done' - // Why: an explicit quit capture must remain the resume handle until a new provider session replaces it. - const preservesQuitOrigin = - existingRecordMatchesProviderSession && existingRecord?.origin === 'quit' - const launchConfig = - (registryMatches ? registryEntry?.launchConfig : undefined) ?? - (existingRecordMatchesProviderSession ? existingRecord.launchConfig : undefined) - const record: SleepingAgentSessionRecord = { - paneKey, - ...(tabId ? { tabId } : {}), - worktreeId, - agent, - providerSession, - prompt: '', - // Why: durable process/session identity, not visible turn state; a non-done value keeps cold restore eligible. - state: preservesCompletedRecoveryRecord ? 'done' : 'working', - capturedAt: updatedAt, - updatedAt, - ...(existingStatus?.terminalTitle - ? { terminalTitle: existingStatus.terminalTitle } - : existingRecord?.terminalTitle - ? { terminalTitle: existingRecord.terminalTitle } - : {}), - ...(routing?.connectionId !== undefined - ? { connectionId: routing.connectionId } - : existingRecord?.connectionId !== undefined - ? { connectionId: existingRecord.connectionId } - : {}), - ...(launchConfig ? { launchConfig: copyLaunchConfig(launchConfig) } : {}), - ...(existingRecordMatchesProviderSession && - existingRecord.automaticResumeBlockedBy === 'legacy-orchestration-worker' - ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' } - : {}), - ...(preservesCompletedRecoveryRecord && existingRecord.interrupted !== undefined - ? { interrupted: existingRecord.interrupted } - : {}), - origin: preservesQuitOrigin ? 'quit' : 'live' - } - removedLiveStatus = existingStatus !== undefined - const nextLive = removedLiveStatus ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey - if (removedLiveStatus) { - delete nextLive[paneKey] - } - const nextRetained = - paneKey in s.retainedAgentsByPaneKey - ? { ...s.retainedAgentsByPaneKey } - : s.retainedAgentsByPaneKey - if (nextRetained !== s.retainedAgentsByPaneKey) { - delete nextRetained[paneKey] - } - // Why: on identity mismatch the sleeping record drops its launch config, so clear the stale - // registry entry too, else a later return to the old identity reuses stale args/env. - let nextLaunchConfigs = s.agentLaunchConfigByPaneKey - if (registryMatches && registryEntry) { - nextLaunchConfigs = { - ...nextLaunchConfigs, - [paneKey]: { - ...registryEntry, - identity: { ...registryEntry.identity, providerSession } - } - } - } else if (registryEntry) { - nextLaunchConfigs = { ...nextLaunchConfigs } - delete nextLaunchConfigs[paneKey] - } - return { - agentStatusByPaneKey: nextLive, - retainedAgentsByPaneKey: nextRetained, - sleepingAgentSessionsByPaneKey: { - ...s.sleepingAgentSessionsByPaneKey, - [paneKey]: record - }, - agentLaunchConfigByPaneKey: nextLaunchConfigs, - acknowledgedAgentsByPaneKey: removePaneKeys( - s.acknowledgedAgentsByPaneKey, - new Set([paneKey]) - ), - unreadAgentCompletionPanes: removePaneKeys( - s.unreadAgentCompletionPanes, - new Set([paneKey]) - ), - agentStatusEpoch: removedLiveStatus ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, - sortEpoch: removedLiveStatus ? s.sortEpoch + 1 : s.sortEpoch - } - }) - if (removedLiveStatus) { - requestAgentStatusFreshness(true) - } - }, - - setAgentStatus: (paneKey, payload, terminalTitle, timing, routing, metadata) => { - paneKey = resolveAgentPaneAuthorityKey(paneKey) - const updatedAt = timing?.updatedAt ?? Date.now() - if ( - paneKey in get().recentlyRetiredAgentStatusPaneKeys || - // Why: a closed tab is no longer a valid destination for hook replays or late status events. - isRecentlyClosedAgentStatusTab( - get().recentlyClosedAgentStatusTabIds, - getTabIdFromPaneKey(paneKey) - ) - ) { - return - } - let completionRefreshWorktreeId: string | null = null - let suppressedInheritedTerminalStatus = false - const generatedTitleEntry: { current: AgentStatusEntry | null } = { current: null } - set((s) => { - const existing = s.agentStatusByPaneKey[paneKey] - // Why: snapshots and live pushes share one timestamp source, so equal timestamps carry - // identical data; strict < preserves same-millisecond live-after-live updates. - if (existing && updatedAt < existing.updatedAt) { - return s - } - // Why: terminalTitle labels the pane itself, not the turn, so a missing title means "no update" — - // preserve the prior value to avoid flicker (unlike tool/prompt fields, which clear on a fresh turn). - const effectiveTitle = terminalTitle ?? existing?.terminalTitle - - // Rolling log of state transitions for the dashboard's activity blocks; push only on - // real state changes to avoid dupes from prompt-only pings within the same state. - // A session-boundary 'done' (idle connect, STA-3386) is not a turn event — keep it - // out of history so activity feeds and unread counts never surface it. The inverse - // also holds: a boundary landing on a REAL done (resume//clear right after a finish) - // must push that completion into history, or the finished timestamp and unread badge - // lose the turn the moment the flag overwrites the live entry. - let history: AgentStateHistoryEntry[] = existing?.stateHistory ?? [] - // Why: a batched burst can fold a whole done→working turn into one publication, so a - // completion-reactive subscriber never sees `lastAssistantMessage` while state is `done`. - // One slot per entry, not one per history row — 20 transcripts per live status OOMs (#9872). - let lastCompletedAssistantMessage = existing?.lastCompletedAssistantMessage - const boundaryLandsOnRealDone = - existing?.state === 'done' && - existing.sessionBoundary !== true && - payload.state === 'done' && - payload.sessionBoundary === true - if ( - existing && - (existing.state !== payload.state || boundaryLandsOnRealDone) && - !(existing.state === 'done' && existing.sessionBoundary === true) - ) { - history = [ - ...history, - { - state: existing.state, - prompt: existing.prompt, - // Why: use stateStartedAt (not updatedAt) so the row reflects when the state was first reported, not the latest within-state ping. - startedAt: existing.stateStartedAt, - // Why: preserve the interrupt flag on the historical `done` entry so activity-block views can render past cancellations. - interrupted: existing.interrupted - } - ] - if (history.length > AGENT_STATE_HISTORY_MAX) { - history = history.slice(history.length - AGENT_STATE_HISTORY_MAX) - } - if (existing.state === 'done') { - // The push above just moved this completion out of the live entry; a done that - // carried no message must clear the slot, or a stale prior turn leaks forward. - lastCompletedAssistantMessage = existing.lastAssistantMessage - } - } - - const identity = resolveAgentStatusIdentity({ - existing: existing - ? { - agentType: existing.agentType, - state: existing.state, - updatedAt: existing.updatedAt, - restoredUnconfirmed: existing.restoredUnconfirmed - } - : undefined, - incoming: payload.agentType, - now: updatedAt - }) - // Why: Command Code has no UserPromptSubmit; a fresh transcript prompt while still `working` is the smart-sort turn boundary. - const commandCodeNewTurn = - existing !== undefined && - isCommandCodeNewTurnWhileWorking({ - agentType: identity.agentType, - previousState: existing.state, - incomingState: payload.state, - previousPrompt: existing.prompt, - incomingPrompt: payload.prompt, - previousPromptInteractionKey: existing.promptInteractionKey, - incomingPromptInteractionKey: payload.promptInteractionKey - }) - const promptInteractionKey = - payload.promptInteractionKey ?? - (payload.prompt === existing?.prompt ? existing?.promptInteractionKey : undefined) - // Why: prefer main's authoritative stateStartedAt (attachStatusTiming persists it across - // same-state pings and restart); fall back to existing only when main sent no timing, updatedAt for a new pane. - const stateStartedAt = - timing?.stateStartedAt ?? - (commandCodeNewTurn - ? updatedAt - : existing && existing.state === payload.state - ? existing.stateStartedAt - : updatedAt) - if ( - existing && - shouldSuppressInheritedTerminalStatus({ - inheritedFromActivePane: identity.inheritedFromActivePane, - incomingState: payload.state - }) - ) { - suppressedInheritedTerminalStatus = true - return s - } - - // Why: tool/assistant fields arrive pre-merged and authoritative from main (resolveToolState - // in server.ts), so write them through directly — no fallback — so UserPromptSubmit clears stale tool lines. - const runtimeOrchestration = s.runtimeAgentOrchestrationByPaneKey[paneKey] - const runtimeMergedOrchestration = runtimeOrchestration - ? mergeCurrentOrchestrationContext(existing?.orchestration, runtimeOrchestration) - : undefined - const payloadMergedOrchestration = payload.orchestration - ? mergeCurrentOrchestrationContext( - runtimeMergedOrchestration ?? existing?.orchestration, - payload.orchestration - ) - : undefined - const completedFallbackOrchestration = - payload.state === 'done' ? existing?.orchestration : undefined - const orchestration = - payloadMergedOrchestration ?? runtimeMergedOrchestration ?? completedFallbackOrchestration - // Why: waiting/blocked are still the same resumable turn; child permission hooks omit the root session id. - // Completing a turn does not end the provider session either — the TUI stays alive and resumable at its - // prompt — so `done` must carry the id through, including done→done (OSC 9999 repaints and reconnect - // snapshot replays both re-deliver a metadata-less `done` onto an already-done row). Without that, every - // surface keyed on the id — mobile Chat UI transcripts, the resumable recovery anchor below — loses the - // session while the agent sits idle, which is precisely when it is read (#10630). Only a new turn - // (done→working) still drops it, so a reused pane cannot inherit a finished session. - const canReuseExistingProviderSession = - existing?.agentType === identity.agentType && - (existing.state !== 'done' || payload.state === 'done') - const providerSession = - metadata?.providerSession ?? - (canReuseExistingProviderSession ? existing.providerSession : undefined) - const existingProviderSession = canReuseExistingProviderSession - ? existing.providerSession - : undefined - const providerSessionChanged = - Boolean(metadata?.providerSession && existingProviderSession) && - !agentProviderSessionsEqual( - identity.agentType, - metadata?.providerSession, - existingProviderSession - ) - const statusTabId = - routing?.tabId ?? existing?.tabId ?? getTabIdFromPaneKey(paneKey) ?? undefined - const statusTerminalHandle = routing?.terminalHandle ?? existing?.terminalHandle - const registryEntry = s.agentLaunchConfigByPaneKey[paneKey] - const matchedRegistryLaunchConfig = registryEntryMatchesStatus({ - entry: registryEntry, - paneKey, - agentType: identity.agentType, - tabId: statusTabId, - terminalHandle: statusTerminalHandle, - launchToken: metadata?.launchToken, - providerSession, - existingProviderSession, - providerSessionChanged - }) - ? registryEntry?.launchConfig - : undefined - const existingSleepingRecord = s.sleepingAgentSessionsByPaneKey[paneKey] - // Why: a completed turn leaves the TUI session alive and resumable at its prompt for any - // resumable agent (Claude/Codex/Pi/…), not just Pi — so keep its persisted recovery anchor - // even when done. Else a cold restore after an abrupt app death (macOS logout, #9454) drops - // the pane to a bare shell instead of `--resume`-ing the agent logged in. - const retainsResumableRecoveryIdentity = - payload.state === 'done' && - isResumableTuiAgent(identity.agentType) && - providerSession !== undefined && - getAgentResumeArgv(identity.agentType, providerSession) !== null - const matchedSleepingLaunchConfig = - (payload.state !== 'done' || retainsResumableRecoveryIdentity) && - existingSleepingRecord?.launchConfig && - existingSleepingRecord.agent === identity.agentType && - providerSession && - agentProviderSessionsEqual( - identity.agentType, - existingSleepingRecord.providerSession, - providerSession - ) - ? existingSleepingRecord.launchConfig - : undefined - // Why: on a reused pane key, once the provider session changes the old launch registry must not bleed options into the new session. - const launchConfigSource = - (payload.state !== 'done' && !providerSessionChanged && metadata?.launchToken - ? metadata?.launchConfig - : undefined) ?? - matchedRegistryLaunchConfig ?? - matchedSleepingLaunchConfig - const entry: AgentStatusEntry = { - state: payload.state, - workingMode: payload.workingMode, - prompt: payload.prompt, - updatedAt, - stateStartedAt, - agentType: identity.agentType, - model: - payload.model ?? - (existing?.agentType === identity.agentType ? existing.model : undefined), - paneKey, - terminalHandle: statusTerminalHandle, - worktreeId: - routing?.worktreeId ?? - existing?.worktreeId ?? - findAgentPaneWorktreeId(s, paneKey) ?? - undefined, - ...(routing?.connectionId !== undefined - ? { connectionId: routing.connectionId } - : existing?.connectionId !== undefined - ? { connectionId: existing.connectionId } - : s.sleepingAgentSessionsByPaneKey[paneKey]?.connectionId !== undefined - ? { connectionId: s.sleepingAgentSessionsByPaneKey[paneKey].connectionId } - : {}), - tabId: statusTabId, - terminalTitle: effectiveTitle, - stateHistory: history, - toolName: payload.toolName, - toolInput: payload.toolInput, - // Why: full untruncated AskUserQuestion JSON so mobile/web can render the live prompt - // card; parseAgentStatusPayload clears it on tool/state change. - interactivePrompt: payload.interactivePrompt, - lastAssistantMessage: payload.lastAssistantMessage, - ...(lastCompletedAssistantMessage ? { lastCompletedAssistantMessage } : {}), - // Why: reused panes can start non-orchestrated work; only final done rows keep the - // previous lineage fallback so completed children stay grouped. - orchestration, - // Why: reuse the prior array ref when the roster is unchanged so identity-comparing subscribers skip re-renders. - subagents: agentSubagentsEqual(existing?.subagents, payload.subagents) - ? existing?.subagents - : payload.subagents, - ...(providerSession ? { providerSession } : {}), - ...(metadata?.terminalResumeEligible === false - ? { terminalResumeEligible: false as const } - : {}), - ...(promptInteractionKey ? { promptInteractionKey } : {}), - ...(payload.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), - // Why: `updatedAt` cannot order two writes inside one millisecond — and the accept check - // above admits equal timestamps — so a deferred process-exit drop needs a token ordered by - // construction to tell "the pane reported again" from "an unrelated field moved". Every - // field-level rewrite of a row spreads it forward, so only a real report re-stamps it. - // - // Derived from the row it replaces, not from a module counter: there is then nothing for a - // sibling teardown path to reset (the bug this replaced), and a batched burst lands the - // same ordinals as the equivalent sequential writes. - acceptedStatusSeq: (existing?.acceptedStatusSeq ?? 0) + 1, - // Why: never inherited from `existing` — an unstamped write is an unstamped - // observation, not the previous one repeated. - ...(payload.observation ? { observation: payload.observation } : {}), - // Why: `interrupted` is done-only; parseAgentStatusPayload already clamps it for non-done states, so write it through directly. - interrupted: payload.interrupted, - // Why: done→done repaints (OSC 9999, reconnect snapshot replays) re-deliver a - // metadata-less `done`; preserving the flag there keeps completion-reactive - // consumers from treating the still-idle session as newly finished. Turn evidence - // (an assistant message or a changed prompt) proves a REAL completion — never - // carry the flag over one, or a genuine finish could be silently suppressed. - sessionBoundary: - payload.sessionBoundary ?? - (existing?.state === 'done' && - payload.state === 'done' && - payload.lastAssistantMessage === undefined && - payload.prompt === existing.prompt - ? existing.sessionBoundary - : undefined) - } - // Why: a boundary `done` becoming a REAL completion does not advance - // `stateStartedAt`, so hibernation would still judge the row by its ancient - // anchor. Stamp it here, synchronously — sampling on the 60s coordinator tick - // misses a boundary written and cleared between two samples. - if ( - entry.state === 'done' && - entry.sessionBoundary !== true && - existing?.sessionBoundary === true - ) { - recordHibernationBoundaryResolved(paneKey, updatedAt) - } - generatedTitleEntry.current = entry - if ( - isAgentCompletionState(entry.state) && - entry.sessionBoundary !== true && - existing !== undefined && - !isAgentCompletionState(existing.state) - ) { - completionRefreshWorktreeId = entry.worktreeId ?? findAgentPaneWorktreeId(s, paneKey) - } - // Why: emit a global tick only when an entry appears, changes state, crosses stale→fresh, - // or is a same-state `done` update — same-state working pings must not fan out to aggregates. - const wasFresh = - !!existing && isExplicitAgentStatusFresh(existing, updatedAt, AGENT_STATUS_STALE_AFTER_MS) - // Why: a late main-process attribution stamp can change which workspace stays visible without changing agent state. - const attributionChanged = - existing?.worktreeId !== entry.worktreeId || existing?.tabId !== entry.tabId - // Why: main can advance stateStartedAt on a same-state turn boundary the renderer - // missed; treat that as sort-relevant so smart sort never goes stale. - // Non-Command-Code agents never advance stateStartedAt at a fixed state, so this stays CC-scoped. - const sameStateStateStartedAtChanged = - !!existing && - existing.state === payload.state && - entry.stateStartedAt !== existing.stateStartedAt - const sameStateDoneAttentionChanged = - existing?.state === 'done' && - entry.state === 'done' && - agentEntryCompletionAt(existing) !== agentEntryCompletionAt(entry) - const workingModeChanged = existing?.workingMode !== entry.workingMode - const sortRelevantChange = - !existing || - existing.state !== payload.state || - !wasFresh || - attributionChanged || - commandCodeNewTurn || - sameStateStateStartedAtChanged || - sameStateDoneAttentionChanged - const doneRetentionFieldsChanged = - existing?.state === 'done' && - entry.state === 'done' && - (entry.prompt !== existing.prompt || - entry.updatedAt !== existing.updatedAt || - entry.stateStartedAt !== existing.stateStartedAt || - entry.agentType !== existing.agentType || - entry.model !== existing.model || - entry.terminalTitle !== existing.terminalTitle || - entry.toolName !== existing.toolName || - entry.toolInput !== existing.toolInput || - entry.lastAssistantMessage !== existing.lastAssistantMessage || - entry.orchestration !== existing.orchestration || - entry.subagents !== existing.subagents || - entry.providerSession !== existing.providerSession || - entry.interrupted !== existing.interrupted) - const retentionRelevantChange = - sortRelevantChange || - attributionChanged || - workingModeChanged || - doneRetentionFieldsChanged - // Why: a fresh status means the agent is live again — lift its one-shot retention suppressor. - // Clone the map only when a suppressor exists, else every high-frequency ping churns the ref. - const hasSuppressor = paneKey in s.retentionSuppressedPaneKeys - let nextRetentionSuppressedPaneKeys = s.retentionSuppressedPaneKeys - if (hasSuppressor) { - nextRetentionSuppressedPaneKeys = { ...s.retentionSuppressedPaneKeys } - delete nextRetentionSuppressedPaneKeys[paneKey] - } - // Why: pane keys are reused across turns, so a fresh live row makes any retained snapshot stale — drop it so it doesn't render beside the live row. - const hasRetainedSnapshot = paneKey in s.retainedAgentsByPaneKey - const nextRetainedAgents = hasRetainedSnapshot - ? { ...s.retainedAgentsByPaneKey } - : s.retainedAgentsByPaneKey - if (hasRetainedSnapshot) { - delete nextRetainedAgents[paneKey] - } - const migrationUnsupported = pruneMigrationUnsupportedEntries( - s.migrationUnsupportedByPtyId, - (entry) => entry.paneKey === paneKey - ) - const liveRecoveryWorktreeId = - entry.state === 'done' && !retainsResumableRecoveryIdentity - ? null - : (entry.worktreeId ?? findAgentPaneWorktreeId(s, entry.paneKey)) - const liveRecoveryRecord = liveRecoveryWorktreeId - ? sleepingRecordFromEntry({ - state: s, - // Why: keep the resume identity of a finished turn without its text, - // but never restate `done` as pending work — the resume sweep reads - // that state to tell an interrupted agent from a completed one, and - // a lie there respawns every finished agent whose pane was killed. - entry: retainsResumableRecoveryIdentity - ? { ...entry, prompt: '', lastAssistantMessage: undefined } - : entry, - worktreeId: liveRecoveryWorktreeId, - capturedAt: updatedAt, - launchConfig: launchConfigSource, - origin: 'live' - }) - : null - let nextSleepingAgentSessions = s.sleepingAgentSessionsByPaneKey - let nextLaunchConfigs = s.agentLaunchConfigByPaneKey - if ( - matchedRegistryLaunchConfig && - registryEntry && - providerSession && - !agentProviderSessionsEqual( - identity.agentType, - registryEntry.identity.providerSession, - providerSession - ) - ) { - nextLaunchConfigs = { - ...nextLaunchConfigs, - [paneKey]: { - ...registryEntry, - identity: { - ...registryEntry.identity, - providerSession - } - } - } - } - // Why: launch tokens can outlive an Orca-started TUI in the shell; once the session is done they must no longer authorize config reuse. - // A session-boundary done is the session CONNECTING (STA-3386) — deleting here would strip - // the pane's registered-launch-agent identity evidence the moment a resumed TUI sits idle. - if ( - (providerSessionChanged || (entry.state === 'done' && entry.sessionBoundary !== true)) && - paneKey in s.agentLaunchConfigByPaneKey - ) { - nextLaunchConfigs = { ...s.agentLaunchConfigByPaneKey } - delete nextLaunchConfigs[paneKey] - } - if (liveRecoveryRecord) { - if (!recoveryRecordMatches(existingSleepingRecord, liveRecoveryRecord)) { - nextSleepingAgentSessions = { - ...s.sleepingAgentSessionsByPaneKey, - [paneKey]: liveRecoveryRecord - } - } - } else if (existingSleepingRecord) { - nextSleepingAgentSessions = { ...s.sleepingAgentSessionsByPaneKey } - delete nextSleepingAgentSessions[paneKey] - } - const nextLive = { ...s.agentStatusByPaneKey, [paneKey]: entry } - // Why: cap the live map so a huge map's per-ping spread copy can't OOM the renderer (#9872). - const evictedPaneKeys = capLiveAgentStatusesInPlace( - nextLive, - paneKey, - () => classifyPaneKeyLiveness(s), - updatedAt - ) - const evictedOrphans = evictedPaneKeys.length > 0 - if (evictedOrphans) { - const evictedPaneKeySet = new Set(evictedPaneKeys) - nextSleepingAgentSessions = removePaneKeys(nextSleepingAgentSessions, evictedPaneKeySet) - nextLaunchConfigs = removePaneKeys(nextLaunchConfigs, evictedPaneKeySet) - } - return { - agentStatusByPaneKey: nextLive, - retainedAgentsByPaneKey: nextRetainedAgents, - sleepingAgentSessionsByPaneKey: nextSleepingAgentSessions, - agentLaunchConfigByPaneKey: nextLaunchConfigs, - migrationUnsupportedByPtyId: migrationUnsupported.next, - retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys, - agentStatusEpoch: - retentionRelevantChange || migrationUnsupported.changed || evictedOrphans - ? s.agentStatusEpoch + 1 - : s.agentStatusEpoch, - sortEpoch: - sortRelevantChange || migrationUnsupported.changed || evictedOrphans - ? s.sortEpoch + 1 - : s.sortEpoch - } - }) - if (suppressedInheritedTerminalStatus) { - return - } - const entryForGeneratedTitle = generatedTitleEntry.current - if (entryForGeneratedTitle) { - // Why: sticky orchestration (~30m) can outlive the dispatch turn, so replace the title on matching labels or a re-dispatch's mismatched taskId. - const hasMatchingOrchestrationLabels = Boolean( - (entryForGeneratedTitle.orchestration?.displayName?.trim() || - entryForGeneratedTitle.orchestration?.taskTitle?.trim()) && - orchestrationLabelsMatchLiveDispatch(entryForGeneratedTitle) - ) - const liveIsDispatchPrompt = isOrcaDispatchPrompt(entryForGeneratedTitle.prompt) - const liveDispatchTaskId = liveIsDispatchPrompt - ? getOrcaDispatchTaskId(entryForGeneratedTitle.prompt) - : null - const stickyOrchestrationTaskId = - entryForGeneratedTitle.orchestration?.taskId?.trim() || null - const isNewDispatchAgainstStickyOrchestration = Boolean( - liveDispatchTaskId && - stickyOrchestrationTaskId && - liveDispatchTaskId !== stickyOrchestrationTaskId - ) - const shouldReplaceGeneratedTitle = - hasMatchingOrchestrationLabels || isNewDispatchAgainstStickyOrchestration - // Why: setAgentStatus is high-frequency, so only parse dispatch preambles when a title write is actually possible. - const mayWriteGeneratedTitle = - get().settings?.tabAutoGenerateTitle === true && - (shouldReplaceGeneratedTitle || - !agentStatusTabAlreadyHasProtectedOrGeneratedTitle( - get(), - entryForGeneratedTitle.tabId ?? getTabIdFromPaneKey(paneKey), - entryForGeneratedTitle.worktreeId - )) - const generatedTitlePrompt = - liveIsDispatchPrompt && mayWriteGeneratedTitle - ? getAgentRowGeneratedTitleText(entryForGeneratedTitle) - : entryForGeneratedTitle.prompt - if (shouldReplaceGeneratedTitle) { - applyGeneratedTabTitleUpdate({ - paneKey, - prompt: generatedTitlePrompt, - options: { - replaceExistingGeneratedTitle: true - } - }) - } else { - applyGeneratedTabTitleUpdate({ paneKey, prompt: generatedTitlePrompt }) - } - } - // Why: batches coalesce accepted updates; standalone calls keep their existing deferred scheduling. - requestAgentStatusFreshness(generatedTitleEntry.current !== null) - if (completionRefreshWorktreeId) { - const worktreeId = completionRefreshWorktreeId - // Why: agents can create a PR via `gh pr create`, bypassing Orca's flow and leaving a stale "no PR" cache entry in place. - queueMicrotask(() => get().refreshGitHubForWorktreeIfStale(worktreeId)) - } - }, - - setAgentStatuses: (updates) => - updates.length === 0 - ? [] - : transactAgentStatuses((transaction) => updates.map(transaction.apply)), - - transactAgentStatuses, - - setMigrationUnsupportedPty: (entry) => { - set((s) => { - const existing = s.migrationUnsupportedByPtyId[entry.ptyId] - if (existing && entry.updatedAt < existing.updatedAt) { - return s - } - return { - migrationUnsupportedByPtyId: { - ...s.migrationUnsupportedByPtyId, - [entry.ptyId]: entry - }, - agentStatusEpoch: s.agentStatusEpoch + 1, - sortEpoch: s.sortEpoch + 1 - } - }) - }, - - clearMigrationUnsupportedPty: (ptyId) => { - if (!(ptyId in get().migrationUnsupportedByPtyId)) { - return - } - set((s) => { - const next = { ...s.migrationUnsupportedByPtyId } - delete next[ptyId] - return { - migrationUnsupportedByPtyId: next, - agentStatusEpoch: s.agentStatusEpoch + 1, - sortEpoch: s.sortEpoch + 1 - } - }) - }, - - removeAgentStatus: (paneKey) => { - if ( - !(paneKey in get().agentStatusByPaneKey) && - !(paneKey in get().agentLaunchConfigByPaneKey) && - !Object.values(get().migrationUnsupportedByPtyId).some((entry) => entry.paneKey === paneKey) - ) { - return - } - set((s) => { - const hasLive = paneKey in s.agentStatusByPaneKey - const next = hasLive ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey - if (hasLive) { - delete next[paneKey] - } - const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey - const nextLaunchConfigs = hasLaunchConfig - ? { ...s.agentLaunchConfigByPaneKey } - : s.agentLaunchConfigByPaneKey - if (hasLaunchConfig) { - delete nextLaunchConfigs[paneKey] - } - const migrationUnsupported = pruneMigrationUnsupportedEntries( - s.migrationUnsupportedByPtyId, - (entry) => entry.paneKey === paneKey - ) - // Why: drop the ack entry with the pane so a future paneKey collision can't inherit a stale ack that suppresses "unvisited" signals. - let nextAck = s.acknowledgedAgentsByPaneKey - if (paneKey in nextAck) { - nextAck = { ...nextAck } - delete nextAck[paneKey] - } - // Why: bump sortEpoch with agentStatusEpoch — removing an agent can change worktree sort order (same as setAgentStatus). - return { - agentStatusByPaneKey: next, - agentLaunchConfigByPaneKey: nextLaunchConfigs, - migrationUnsupportedByPtyId: migrationUnsupported.next, - ...(nextAck !== s.acknowledgedAgentsByPaneKey - ? { acknowledgedAgentsByPaneKey: nextAck } - : {}), - agentStatusEpoch: s.agentStatusEpoch + 1, - sortEpoch: s.sortEpoch + 1 - } - }) - freshness.scheduleDeferred() - }, - - removeAgentStatusByTabPrefix: (tabIdPrefix) => { - const prefix = `${tabIdPrefix}:` - const currentKeys = Object.keys(get().agentStatusByPaneKey) - const toRemove = currentKeys.filter((k) => k.startsWith(prefix)) - const launchConfigKeys = Object.keys(get().agentLaunchConfigByPaneKey).filter((k) => - k.startsWith(prefix) - ) - const hasMigrationUnsupported = Object.values(get().migrationUnsupportedByPtyId).some( - (entry) => entry.paneKey?.startsWith(prefix) - ) - if (toRemove.length === 0 && launchConfigKeys.length === 0 && !hasMigrationUnsupported) { - return - } - set((s) => { - const next = { ...s.agentStatusByPaneKey } - for (const key of toRemove) { - delete next[key] - } - const nextLaunchConfigs = { ...s.agentLaunchConfigByPaneKey } - for (const key of launchConfigKeys) { - delete nextLaunchConfigs[key] - } - const migrationUnsupported = pruneMigrationUnsupportedEntries( - s.migrationUnsupportedByPtyId, - (entry) => entry.paneKey?.startsWith(prefix) ?? false - ) - // See removeAgentStatus for rationale on ack cleanup. - let nextAck = s.acknowledgedAgentsByPaneKey - const ackKeys = Object.keys(nextAck).filter((k) => k.startsWith(prefix)) - if (ackKeys.length > 0) { - nextAck = { ...nextAck } - for (const k of ackKeys) { - delete nextAck[k] - } - } - // Why: bump sortEpoch with agentStatusEpoch — removing agents can change worktree sort order (same as setAgentStatus). - return { - agentStatusByPaneKey: next, - agentLaunchConfigByPaneKey: nextLaunchConfigs, - migrationUnsupportedByPtyId: migrationUnsupported.next, - ...(nextAck !== s.acknowledgedAgentsByPaneKey - ? { acknowledgedAgentsByPaneKey: nextAck } - : {}), - agentStatusEpoch: s.agentStatusEpoch + 1, - sortEpoch: s.sortEpoch + 1 - } - }) - freshness.scheduleDeferred() - }, - - clearTransientAgentStatuses: (connectionId, clearedAt) => { - if (connectionId.length === 0 || !Number.isFinite(clearedAt)) { - return - } - let removed = false - set((s) => { - const worktreeIdsOnConnection = collectWorktreeIdsForConnection(s, connectionId) - let next: Record | null = null - for (const [paneKey, existing] of Object.entries(s.agentStatusByPaneKey)) { - if (existing.updatedAt > clearedAt) { - continue - } - // Why: clear rows stamped for this connection, plus UNSTAMPED (never-stamped) worktree - // rows unambiguously on it (#9030). An explicit stamp — another host's connectionId, or a - // local `null` — is authoritative and never overridden by worktree inference. - const belongsToConnection = - existing.connectionId === connectionId || - (existing.connectionId === undefined && - existing.worktreeId !== undefined && - worktreeIdsOnConnection.has(existing.worktreeId)) - if (!belongsToConnection) { - continue - } - next ??= { ...s.agentStatusByPaneKey } - delete next[paneKey] - } - const wasAlreadyBlocked = connectionId in s.transientClearedAgentStatusConnectionIds - if (!next && wasAlreadyBlocked) { - return s - } - removed = next !== null - // Why: transport loss is reversible. Keep launch, resume, retention, - // and acknowledgement maps intact for same-pane relay replay. - return { - ...(next - ? { - agentStatusByPaneKey: next, - agentStatusEpoch: s.agentStatusEpoch + 1, - sortEpoch: s.sortEpoch + 1 - } - : {}), - transientClearedAgentStatusConnectionIds: wasAlreadyBlocked - ? s.transientClearedAgentStatusConnectionIds - : { ...s.transientClearedAgentStatusConnectionIds, [connectionId]: true } - } - }) - if (removed) { - freshness.scheduleDeferred() - } - }, - - dropAgentStatus: (paneKey) => { - // Why: zustand set is synchronous, so capture liveExisted once inside the callback instead of double-reading the store. - let liveExisted = false - set((s) => { - const hasLive = paneKey in s.agentStatusByPaneKey - liveExisted = hasLive - const hasRetained = paneKey in s.retainedAgentsByPaneKey - const migrationUnsupported = pruneMigrationUnsupportedEntries( - s.migrationUnsupportedByPtyId, - (entry) => entry.paneKey === paneKey - ) - // See removeAgentStatus for ack-cleanup rationale; the ack entry is owned by the pane lifecycle regardless of live/retained state. - let nextAck = s.acknowledgedAgentsByPaneKey - if (paneKey in nextAck) { - nextAck = { ...nextAck } - delete nextAck[paneKey] - } - const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey - const nextLaunchConfigs = hasLaunchConfig - ? { ...s.agentLaunchConfigByPaneKey } - : s.agentLaunchConfigByPaneKey - if (hasLaunchConfig) { - delete nextLaunchConfigs[paneKey] - } - // Why: short-circuit when there's nothing to change, but still flush a pending ack or launch-config cleanup if one is present. - if (!hasLive && !hasRetained && !migrationUnsupported.changed) { - if (hasLaunchConfig) { - return { - agentLaunchConfigByPaneKey: nextLaunchConfigs, - ...(nextAck !== s.acknowledgedAgentsByPaneKey - ? { acknowledgedAgentsByPaneKey: nextAck } - : {}) - } - } - if (nextAck !== s.acknowledgedAgentsByPaneKey) { - return { acknowledgedAgentsByPaneKey: nextAck } - } - return s - } - - const nextLive = hasLive ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey - if (hasLive) { - delete nextLive[paneKey] - } - const nextRetained = hasRetained - ? { ...s.retainedAgentsByPaneKey } - : s.retainedAgentsByPaneKey - if (hasRetained) { - delete nextRetained[paneKey] - } - - // Why: explicit teardown must not let retention sync resurrect this row — plant a one-shot suppressor, but only when hasLive (a retained-only key has no live→gone transition to consume it, so it leaks) and not already present (re-spreading spuriously re-renders subscribers). - const needsSuppressorWrite = hasLive && !(paneKey in s.retentionSuppressedPaneKeys) - - return { - agentStatusByPaneKey: nextLive, - agentLaunchConfigByPaneKey: nextLaunchConfigs, - retainedAgentsByPaneKey: nextRetained, - migrationUnsupportedByPtyId: migrationUnsupported.next, - ...(nextAck !== s.acknowledgedAgentsByPaneKey - ? { acknowledgedAgentsByPaneKey: nextAck } - : {}), - ...(needsSuppressorWrite - ? { - retentionSuppressedPaneKeys: { - ...s.retentionSuppressedPaneKeys, - [paneKey]: true - } - } - : {}), - agentStatusEpoch: - hasLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, - // Why: mirrors removeAgentStatus — dropping a live agent changes its worktree sort score, so bump sortEpoch to recompute the sidebar smart-sort. - sortEpoch: hasLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch - } - }) - // Why: freshness.schedule only matters when the live map changed, so gate on the live presence observed inside set() — no-op/retained-only drops skip it. - if (liveExisted) { - freshness.scheduleDeferred() - } - // Why: propagate the dismissal to the main-process hook cache so the on-disk cache doesn't re-hydrate this row on next launch. Fire-and-forget. - // Why: the typeof window guard keeps the slice usable from the node test env, where window is undefined. - if (typeof window !== 'undefined') { - window.api?.agentStatus?.drop?.(paneKey) - } - }, - - dropAgentStatusByTabPrefix: (tabIdPrefix, opts) => { - const retiredAliasPaneKeys = retireAgentPaneAuthorityAliasesByOwnerTab(tabIdPrefix) - let hadLive = false - set((s) => { - const dropped = buildAgentStatusTabPrefixDropPatch( - s, - tabIdPrefix, - retiredAliasPaneKeys, - opts - ) - hadLive = dropped.hadLive - return dropped.patch - }) - if (hadLive) { - freshness.scheduleDeferred() - } - if (typeof window !== 'undefined') { - window.api?.agentStatus?.dropByTabPrefix?.(tabIdPrefix) - } - }, - - dropHibernatedAgentStatusPane: (worktreeId, paneKey, opts) => { - let hadLive = false - set((s) => { - const liveEntry = s.agentStatusByPaneKey[paneKey] - const hasLive = liveEntry !== undefined - const hasRetained = paneKey in s.retainedAgentsByPaneKey - const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey - const migrationUnsupported = pruneMigrationUnsupportedEntries( - s.migrationUnsupportedByPtyId, - (entry) => entry.paneKey === paneKey - ) - const retainedEvidence = new Map() - for (const retained of opts?.retainedCompletionEvidence ?? []) { - if ( - retained.entry.paneKey === paneKey && - !liveEntry && - shouldReplaceRetainedWithLive(retainedEvidence.get(paneKey), retained) - ) { - retainedEvidence.set(paneKey, retained) - } - } - if ( - liveEntry?.state === 'done' && - liveEntry.agentType !== undefined && - liveEntry.interrupted !== true - ) { - retainedEvidence.set( - paneKey, - retainedAgentEntryFromLive(s, worktreeId, liveEntry, liveEntry.agentType) - ) - } - const keepsCompletionEvidence = retainedEvidence.has(paneKey) - let nextAck = s.acknowledgedAgentsByPaneKey - if (!keepsCompletionEvidence && paneKey in nextAck) { - nextAck = { ...nextAck } - delete nextAck[paneKey] - } - if ( - !hasLive && - !hasRetained && - !hasLaunchConfig && - !migrationUnsupported.changed && - !keepsCompletionEvidence - ) { - if (nextAck !== s.acknowledgedAgentsByPaneKey) { - return { acknowledgedAgentsByPaneKey: nextAck } - } - return s - } - hadLive = hasLive - - const nextLive = hasLive ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey - if (hasLive) { - delete nextLive[paneKey] - } - const nextLaunchConfigs = hasLaunchConfig - ? { ...s.agentLaunchConfigByPaneKey } - : s.agentLaunchConfigByPaneKey - if (hasLaunchConfig) { - delete nextLaunchConfigs[paneKey] - } - - const nextRetained = - hasRetained || keepsCompletionEvidence - ? { ...s.retainedAgentsByPaneKey } - : s.retainedAgentsByPaneKey - if (hasRetained && !keepsCompletionEvidence) { - delete nextRetained[paneKey] - } - for (const [key, retained] of retainedEvidence) { - if (shouldReplaceRetainedWithLive(nextRetained[key], retained)) { - nextRetained[key] = retained - } - } - - const needsSuppressor = - hasLive && !keepsCompletionEvidence && !(paneKey in s.retentionSuppressedPaneKeys) - - return { - agentStatusByPaneKey: nextLive, - agentLaunchConfigByPaneKey: nextLaunchConfigs, - retainedAgentsByPaneKey: nextRetained, - migrationUnsupportedByPtyId: migrationUnsupported.next, - ...(nextAck !== s.acknowledgedAgentsByPaneKey - ? { acknowledgedAgentsByPaneKey: nextAck } - : {}), - ...(needsSuppressor - ? { - retentionSuppressedPaneKeys: { - ...s.retentionSuppressedPaneKeys, - [paneKey]: true - } - } - : {}), - agentStatusEpoch: - hasLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, - sortEpoch: hasLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch - } - }) - if (hadLive) { - freshness.scheduleDeferred() - } - }, - - dropAgentStatusByWorktree: (worktreeId, opts) => { - let hadLive = false - set((s) => { - const tabPrefixes = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) - const liveEntries = Object.entries(s.agentStatusByPaneKey).filter( - ([paneKey, entry]) => - entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) - ) - const liveKeys = liveEntries.map(([paneKey]) => paneKey) - const liveKeySet = new Set(liveKeys) - const launchConfigKeys = Object.keys(s.agentLaunchConfigByPaneKey).filter( - (paneKey) => paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) || liveKeySet.has(paneKey) - ) - const retainedKeys = Object.entries(s.retainedAgentsByPaneKey) - .filter( - ([paneKey, retained]) => - retained.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) - ) - .map(([paneKey]) => paneKey) - const retainedKeySet = new Set(retainedKeys) - const migrationUnsupported = pruneMigrationUnsupportedEntries( - s.migrationUnsupportedByPtyId, - (entry) => - entry.worktreeId === worktreeId || - (entry.paneKey ? paneKeyMatchesAnyTabPrefix(entry.paneKey, tabPrefixes) : false) - ) - const allowedPaneKeys = normalizePaneKeySet(opts?.sleepingPaneKeys) - const preserveHibernatedEvidence = - opts?.shutdownReason === 'auto-hibernate-completed-agent' && - allowedPaneKeys !== null && - allowedPaneKeys.size > 0 - const liveEntryByPaneKey = new Map(liveEntries) - const retainedEvidence = new Map() - if (preserveHibernatedEvidence) { - for (const retained of opts?.retainedCompletionEvidence ?? []) { - if ( - allowedPaneKeys.has(retained.entry.paneKey) && - !liveEntryByPaneKey.has(retained.entry.paneKey) && - shouldReplaceRetainedWithLive(retainedEvidence.get(retained.entry.paneKey), retained) - ) { - retainedEvidence.set(retained.entry.paneKey, retained) - } - } - for (const [paneKey, entry] of liveEntries) { - const agentType = entry.agentType - if ( - allowedPaneKeys.has(paneKey) && - entry.state === 'done' && - agentType !== undefined && - entry.interrupted !== true - ) { - retainedEvidence.set( - paneKey, - retainedAgentEntryFromLive(s, worktreeId, entry, agentType) - ) - } - } - } - const retainedEvidenceKeys = new Set(retainedEvidence.keys()) - // See removeAgentStatus for ack-cleanup rationale; auto-hibernated completion evidence keeps its read state so a slept card doesn't turn bold again. - let nextAck = s.acknowledgedAgentsByPaneKey - const ackKeys = Object.keys(nextAck).filter( - (k) => - !retainedEvidenceKeys.has(k) && - (paneKeyMatchesAnyTabPrefix(k, tabPrefixes) || - liveKeySet.has(k) || - retainedKeySet.has(k)) - ) - if (ackKeys.length > 0) { - nextAck = { ...nextAck } - for (const key of ackKeys) { - delete nextAck[key] - } - } - // Mirror dropAgentStatusByTabPrefix: when nothing live/retained changed, return just the ack delta (or s) to avoid full-state re-renders. - if ( - liveKeys.length === 0 && - launchConfigKeys.length === 0 && - retainedKeys.length === 0 && - retainedEvidence.size === 0 && - !migrationUnsupported.changed - ) { - if (nextAck !== s.acknowledgedAgentsByPaneKey) { - return { acknowledgedAgentsByPaneKey: nextAck } - } - return s - } - hadLive = liveKeys.length > 0 - - const nextLive = - liveKeys.length > 0 ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey - for (const key of liveKeys) { - delete nextLive[key] - } - const nextLaunchConfigs = - launchConfigKeys.length > 0 - ? { ...s.agentLaunchConfigByPaneKey } - : s.agentLaunchConfigByPaneKey - for (const key of launchConfigKeys) { - delete nextLaunchConfigs[key] - } - - const nextRetained = - retainedKeys.length > 0 || retainedEvidence.size > 0 - ? { ...s.retainedAgentsByPaneKey } - : s.retainedAgentsByPaneKey - for (const key of retainedKeys) { - if (!retainedEvidenceKeys.has(key)) { - delete nextRetained[key] - } - } - for (const [paneKey, retained] of retainedEvidence) { - if (shouldReplaceRetainedWithLive(nextRetained[paneKey], retained)) { - nextRetained[paneKey] = retained - } - } - - // Why: suppress live rows on teardown, but skip auto-hibernated `done` rows — they become retained evidence a suppressor would erase next sync. - const suppressorAdds = liveKeys.filter( - (k) => !retainedEvidenceKeys.has(k) && !(k in s.retentionSuppressedPaneKeys) - ) - let nextRetentionSuppressedPaneKeys = s.retentionSuppressedPaneKeys - if (suppressorAdds.length > 0) { - nextRetentionSuppressedPaneKeys = { ...s.retentionSuppressedPaneKeys } - for (const key of suppressorAdds) { - nextRetentionSuppressedPaneKeys[key] = true - } - } - - return { - agentStatusByPaneKey: nextLive, - agentLaunchConfigByPaneKey: nextLaunchConfigs, - retainedAgentsByPaneKey: nextRetained, - migrationUnsupportedByPtyId: migrationUnsupported.next, - retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys, - ...(nextAck !== s.acknowledgedAgentsByPaneKey - ? { acknowledgedAgentsByPaneKey: nextAck } - : {}), - agentStatusEpoch: - hadLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, - sortEpoch: hadLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch - } - }) - if (hadLive) { - freshness.scheduleDeferred() - } - }, - - captureSleepingAgentSessionsByWorktree: (worktreeId, paneKeys) => { - set((s) => { - const records = collectSleepingAgentSessionRecordsForWorktree(s, worktreeId, { - paneKeys, - captureMode: 'manual-worktree-sleep' - }) - const replaced = removeSleepingRecordsReplacedByManualWorktreeSleep( - s.sleepingAgentSessionsByPaneKey, - worktreeId, - paneKeys, - records - ) - const next: Record = { ...replaced.records } - let changed = replaced.changed - - for (const record of Object.values(records)) { - if (next[record.paneKey] !== record) { - next[record.paneKey] = record - changed = true - } - } - - return changed ? { sleepingAgentSessionsByPaneKey: next } : s - }) - }, - - captureAllSleepingAgentSessions: (mode) => { - // Why: periodic checkpoints and quit flushes both persist provider ids, but only a confirmed quit may claim quit precedence. - set((s) => { - const capturedAt = Date.now() - const origin = mode === 'quit' ? ('quit' as const) : ('live' as const) - const next: Record = { - ...s.sleepingAgentSessionsByPaneKey - } - let changed = false - for (const entry of Object.values(s.agentStatusByPaneKey)) { - if (entry.state === 'done') { - const existing = next[entry.paneKey] - if (!isCompletedPiCompatibleAgentWithLiveRecoveryRecord(entry, existing)) { - continue - } - if (mode === 'periodic') { - continue - } - const record = { ...existing, capturedAt, origin } - if (!sleepingRecordsEquivalentIgnoringCaptureTime(existing, record)) { - next[entry.paneKey] = record - changed = true - } - continue - } - const worktreeId = entry.worktreeId ?? findAgentPaneWorktreeId(s, entry.paneKey) - if (!worktreeId) { - continue - } - const record = sleepingRecordFromEntry({ - state: s, - entry, - worktreeId, - capturedAt, - launchConfig: getLaunchConfigForEntry(s, entry), - origin - }) - const existing = next[entry.paneKey] - // Why: a periodic timer must not downgrade a confirmed-quit shutdown snapshot; a live hook event supersedes it elsewhere. - if ( - mode === 'periodic' && - existing?.origin === 'quit' && - record && - recoveryRecordTargetsSameSession(existing, record) - ) { - continue - } - if (record && !sleepingRecordsEquivalentIgnoringCaptureTime(existing, record)) { - next[record.paneKey] = record - changed = true - } - } - return changed ? { sleepingAgentSessionsByPaneKey: next } : s - }) - }, - - clearSleepingAgentSession: (paneKey) => clearSleepingAgentSessionsByPaneKey([paneKey]), - clearSleepingAgentSessionsByPaneKey, - setSleepingAgentAutomaticResumeBlocked: (paneKey, blocked) => { - set((s) => { - const current = s.sleepingAgentSessionsByPaneKey[paneKey] - if ( - !current || - (blocked - ? current.automaticResumeBlockedBy === 'legacy-orchestration-worker' - : current.automaticResumeBlockedBy === undefined) - ) { - return s - } - const next = { ...current } - if (blocked) { - next.automaticResumeBlockedBy = 'legacy-orchestration-worker' - } else { - delete next.automaticResumeBlockedBy - } - return { - sleepingAgentSessionsByPaneKey: { - ...s.sleepingAgentSessionsByPaneKey, - [paneKey]: next - } - } - }) - }, - - clearSleepingAgentSessionsByWorktree: (worktreeId) => { - set((s) => { - let changed = false - const next: Record = {} - const launchConfigKeysToRemove: string[] = [] - for (const [paneKey, record] of Object.entries(s.sleepingAgentSessionsByPaneKey)) { - if (record.worktreeId === worktreeId) { - changed = true - launchConfigKeysToRemove.push(paneKey) - continue - } - next[paneKey] = record - } - const nextLaunchConfigs = - launchConfigKeysToRemove.length > 0 ? { ...s.agentLaunchConfigByPaneKey } : null - if (nextLaunchConfigs) { - for (const paneKey of launchConfigKeysToRemove) { - delete nextLaunchConfigs[paneKey] - } - } - return changed - ? { - sleepingAgentSessionsByPaneKey: next, - ...(nextLaunchConfigs ? { agentLaunchConfigByPaneKey: nextLaunchConfigs } : {}) - } - : s - }) - }, - - pruneSleepingAgentSessions: (validWorktreeIds) => { - set((s) => { - let changed = false - const next: Record = {} - const launchConfigKeysToRemove: string[] = [] - for (const [paneKey, record] of Object.entries(s.sleepingAgentSessionsByPaneKey)) { - if (!validWorktreeIds.has(record.worktreeId)) { - changed = true - launchConfigKeysToRemove.push(paneKey) - continue - } - next[paneKey] = record - } - const nextLaunchConfigs = - launchConfigKeysToRemove.length > 0 ? { ...s.agentLaunchConfigByPaneKey } : null - if (nextLaunchConfigs) { - for (const paneKey of launchConfigKeysToRemove) { - delete nextLaunchConfigs[paneKey] - } - } - return changed - ? { - sleepingAgentSessionsByPaneKey: next, - ...(nextLaunchConfigs ? { agentLaunchConfigByPaneKey: nextLaunchConfigs } : {}) - } - : s - }) - }, - - retainAgents: (entries) => { - // Why: retained entries are a pure read-overlay (no epoch bump needed); batch into one set so multi-agent disappearance is atomic. - if (entries.length === 0) { - return - } - set((s) => { - // Why: skip reallocation when every entry is already present by reference — consumers select on map identity, so a spurious realloc forces re-renders. - let changed = false - for (const retained of entries) { - if (s.retainedAgentsByPaneKey[retained.entry.paneKey] !== retained) { - changed = true - break - } - } - if (!changed) { - return s - } - const next = { ...s.retainedAgentsByPaneKey } - for (const retained of entries) { - const runtimeOrchestration = s.runtimeAgentOrchestrationByPaneKey[retained.entry.paneKey] - const mergedOrchestration = runtimeOrchestration - ? mergeCurrentOrchestrationContext(retained.entry.orchestration, runtimeOrchestration) - : retained.entry.orchestration - const entry = - mergedOrchestration !== retained.entry.orchestration - ? { ...retained.entry, orchestration: mergedOrchestration } - : retained.entry - // INVARIANT: map key equals retained.entry.paneKey, so callers look up retained rows by the same paneKey as agentStatusByPaneKey. - next[retained.entry.paneKey] = - entry === retained.entry ? retained : { ...retained, entry } - } - // Why: cap the map so a long multi-agent session can't leak the renderer heap (retainAgents is the only growth path); evicts oldest-retained first. - return { retainedAgentsByPaneKey: capRetainedAgents(next) } - }) - }, - - dismissRetainedAgent: (paneKey) => { - // Why: no epoch bump (mirrors retainAgents) — retained rows are a pure read-overlay that don't affect smart-sort; selectors re-render on map identity. - set((s) => { - if (!(paneKey in s.retainedAgentsByPaneKey)) { - return s - } - const next = { ...s.retainedAgentsByPaneKey } - delete next[paneKey] - // Why: mirror dropAgentStatus — plant a one-shot suppressor only when a live entry coexists, so the retention sync doesn't resurrect this dismissed row (gate on hasLive, else it leaks). - const hasLive = paneKey in s.agentStatusByPaneKey - if (!hasLive || paneKey in s.retentionSuppressedPaneKeys) { - return { retainedAgentsByPaneKey: next } - } - return { - retainedAgentsByPaneKey: next, - retentionSuppressedPaneKeys: { - ...s.retentionSuppressedPaneKeys, - [paneKey]: true - } - } - }) - }, - - dismissRetainedAgentsByWorktree: (worktreeId) => { - // Why: collect removed paneKeys inside set, then fan out window.api drop so the on-disk cache doesn't resurrect the dismissed rows on next launch. - const dismissedPaneKeys: string[] = [] - set((s) => { - let changed = false - const next: Record = {} - // Why: mirror dismissRetainedAgent — plant a suppressor only for dismissed paneKeys that also have a live entry, else the next live→gone transition re-retains the row (a retained-only suppressor leaks). - const toSuppress: string[] = [] - for (const [key, ra] of Object.entries(s.retainedAgentsByPaneKey)) { - if (ra.worktreeId === worktreeId) { - changed = true - dismissedPaneKeys.push(key) - if (key in s.agentStatusByPaneKey && !(key in s.retentionSuppressedPaneKeys)) { - toSuppress.push(key) - } - continue - } - next[key] = ra - } - if (!changed) { - return s - } - if (toSuppress.length === 0) { - return { retainedAgentsByPaneKey: next } - } - const nextSuppressed = { ...s.retentionSuppressedPaneKeys } - for (const key of toSuppress) { - nextSuppressed[key] = true - } - return { - retainedAgentsByPaneKey: next, - retentionSuppressedPaneKeys: nextSuppressed - } - }) - if (typeof window !== 'undefined') { - for (const paneKey of dismissedPaneKeys) { - window.api?.agentStatus?.drop?.(paneKey) - } - } - }, - - pruneRetainedAgents: (validWorktreeIds) => { - // Why: intentionally leaves retentionSuppressedPaneKeys — paneKeys are minted fresh on worktree re-create, so stale suppressors can never match a future live entry. - set((s) => { - let changed = false - const next: Record = {} - for (const [key, ra] of Object.entries(s.retainedAgentsByPaneKey)) { - if (!validWorktreeIds.has(ra.worktreeId)) { - changed = true - continue - } - next[key] = ra - } - return changed ? { retainedAgentsByPaneKey: next } : s - }) - }, - - clearRetentionSuppressedPaneKeys: (paneKeys) => { - set((s) => { - let changed = false - const next = { ...s.retentionSuppressedPaneKeys } - for (const paneKey of paneKeys) { - if (!(paneKey in next)) { - continue - } - delete next[paneKey] - changed = true - } - return changed ? { retentionSuppressedPaneKeys: next } : s - }) - } + recentlyRetiredAgentStatusPaneKeys: {} } } diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 2668df36fb9..424384e9a42 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -1,2676 +1,18 @@ -/* eslint-disable max-lines */ -import type { StateCreator } from 'zustand' -import type { AppState } from '../types' -import type { - BrowserCertificateFailure, - BrowserCookieImportResult, - BrowserCookieImportSummary, - BrowserHistoryEntry, - BrowserLoadError, - BrowserPage, - BrowserPageDocLocation, +export type { + BrowserSlice, + BrowserSliceGet, + BrowserSliceSet, + BrowserCookieImportExecutionResult, + BrowserTabPageState, + BrowserPageConversionLeg, + BrowserPageConversionTarget, BrowserSessionProfile, - BrowserSessionProfileCreateOptions, - BrowserViewportPresetId, - BrowserWorkspace -} from '../../../../shared/browser-workspace-types' -import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' -import { - GRAB_BUDGET, - type BrowserAnnotationIntent, - type BrowserPageAnnotation -} from '../../../../shared/browser-grab-types' -import { - clearClientHostedBrowserCloseIntents, - isDurableClientHostedBrowserHandle, - recordClientHostedBrowserCloseIntents, - type ClientHostedBrowserCloseIntentsByEnvironment, - type PendingClientHostedBrowserClose -} from '@/runtime/client-hosted-browser-close-intents' -import { isBrowserPageDefinitivelyGone } from '@/runtime/client-hosted-browser-close-intent-replay' -import { FLOATING_TERMINAL_WORKTREE_ID, ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants' -import { folderWorkspaceKey } from '../../../../shared/workspace-scope' -import { redactKagiSessionToken } from '../../../../shared/browser-url' -import { - MAX_BROWSER_HISTORY_ENTRIES, - normalizeBrowserHistoryEntries, - normalizeBrowserHistoryUrl -} from '../../../../shared/workspace-session-browser-history' -import { - MAX_WORKSPACE_DOC_HISTORY_ENTRIES, - normalizeWorkspaceDocHistoryEntries, - normalizeWorkspaceDocHistoryTitle, - type WorkspaceDocHistoryEntry -} from '../../../../shared/workspace-doc-history' -import { browserPageDocLocationsEqual } from '../../../../shared/browser-page-doc-location' -import { destroyWorkspaceWebviews } from './browser-webview-cleanup' -import { - browserWorkspaceMirrorFieldsEqual, - buildBrowserPage, - buildWorkspaceFromPage, - findPage, - findWorkspace, - mirrorWorkspaceFromActivePage, - normalizeBrowserTitle, - normalizeUrl -} from './browser-page-records' -import { - planBrowserPageConversion, - type BrowserPageConversionLeg, - type BrowserPageConversionTarget -} from './browser-page-conversion' -import { releaseDocPreviewGrant } from '@/lib/doc-preview-grants' -import { - getRecentlyClosedTabPosition, - restoreRecentlyClosedTabPosition, - pushRecentlyClosedTabKind -} from './recently-closed-tabs' -import { pickNeighbor } from './tab-group-state' -import type { RecentlyClosedTabPosition } from './recently-closed-tabs' -import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' -import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' -import { ensureBrowserClientHostsForRestoredPages } from '@/runtime/restored-client-hosted-browser-host-attach' -import type { - BrowserDetectProfilesResult, - BrowserProfileClearDefaultCookiesResult, - BrowserProfileCreateResult, - BrowserProfileDeleteResult, - BrowserProfileImportFromBrowserResult, - BrowserProfileListResult -} from '../../../../shared/runtime-types' -import { createBrowserUuid } from '@/lib/browser-uuid' -import { translate } from '@/i18n/i18n' -import { - getSettingsFocusedExecutionHostId, - LOCAL_EXECUTION_HOST_ID, - parseExecutionHostId, - toRuntimeExecutionHostId, - type ExecutionHostId -} from '../../../../shared/execution-host' -import { selectExecutionHostDisplayLabel } from '@/lib/execution-host-display-label' -import type { RuntimeBrowserPlacement } from '../../../../shared/runtime-browser-placement' -import { - getExecutionHostIdForWorktree, - getRuntimeEnvironmentIdForWorktree -} from '@/lib/worktree-runtime-owner' -import { - addAdditionalValidWorkspaceKeys, - type WorkspaceSessionHydrationOptions -} from '@/lib/workspace-session-hydration-keys' -import { buildValidWorktreeIdsForSessionHydration } from './degraded-repo-worktree-validity' -import { - assertManagedBrowserMaterializationAllowed, - getClientCreationActionPolicy -} from '@/lib/client-creation-action-policy' - -type CreateBrowserTabOptions = { - activate?: boolean - browserPageId?: string - title?: string - sessionProfileId?: string | null - sessionPartition?: string | null - // Place the new tab in a specific group (e.g. "Open Preview to the Side"); defaults to the worktree's active group. - targetGroupId?: string - // Explicit "New Tab" focuses the address bar even with a real home URL; link-opened tabs leave it unset. - focusAddressBar?: boolean - browserRuntimeEnvironmentId?: string | null - /** Creates a page that shows a workspace document instead of a URL. */ - docLocation?: BrowserPageDocLocation -} - -type CreateBrowserPageOptions = { - activate?: boolean - title?: string - browserRuntimeEnvironmentId?: string | null - /** Creates a page that shows a workspace document instead of a URL. */ - docLocation?: BrowserPageDocLocation -} - -type BrowserTabPageState = { - title?: string - loading?: boolean - faviconUrl?: string | null - canGoBack?: boolean - canGoForward?: boolean - loadError?: BrowserLoadError | null -} - -type SetBrowserPageUrlOptions = { - preserveLoadError?: boolean -} - -type ClosedBrowserWorkspaceSnapshot = { - workspace: BrowserWorkspace - pages: BrowserPage[] - position?: RecentlyClosedTabPosition -} - -function sanitizeBrowserPageAnnotation(annotation: BrowserPageAnnotation): BrowserPageAnnotation { - return { - ...annotation, - comment: - annotation.comment.length > GRAB_BUDGET.annotationCommentMaxLength - ? annotation.comment.slice(0, GRAB_BUDGET.annotationCommentMaxLength) - : annotation.comment, - payload: { - ...annotation.payload, - // Why: annotations persist to disk; null the transient screenshot to avoid retaining megabytes per note. - screenshot: null - } - } -} - -export type RemoteBrowserPageHandle = { - environmentId: string - remotePageId: string - placement?: RuntimeBrowserPlacement - /** Optimistically staged by this client; the host has not published the page yet. */ - staged?: true - /** - * This client expects to host the staged page itself. The real placement is minted host-side and - * only arrives with the snapshot, so the pane needs this to mount as the right kind of pane from - * the first frame instead of swapping components at adoption. - */ - stagedClientHosted?: true - /** - * Rebuilt at hydration from the persisted page row rather than observed from a host snapshot. The - * page id is real, but nothing has confirmed the host still has it, so the row is exempt from the - * absent-from-snapshot cull until the first snapshot that publishes it clears this. - */ - restoredFromSession?: true - /** The restored page was hosted by this desktop, so it must not restore as a streamed pane. */ - restoredClientHosted?: true -} - -/** Rebuild the remote page handles a restored session implies. No placement is seeded: the - * persisted generations belong to the host lease that died with the last run, and the first host - * snapshot is what supplies the live one. - * - * Client-hosted rows only. A server-hosted page lives on the runtime, which may have restarted or - * been redeployed while this desktop was closed; a seeded handle sends its pane down the adopt - * branch, and the browser_tab_not_found that comes back deletes the row. With no handle the pane - * creates a fresh page at the URL the row persisted, which is what the user left behind. */ -function buildRestoredRemoteBrowserPageHandles( - browserPagesByWorkspace: Record -): Record { - const handles: Record = {} - for (const pages of Object.values(browserPagesByWorkspace)) { - for (const page of pages) { - if ( - !page.browserRuntimeEnvironmentId || - !page.remoteBrowserPageId || - !page.remoteBrowserPageClientHosted - ) { - continue - } - handles[page.id] = { - environmentId: page.browserRuntimeEnvironmentId, - remotePageId: page.remoteBrowserPageId, - restoredFromSession: true, - restoredClientHosted: true - } - } - } - return handles -} - -export type BrowserCookieImportExecutionResult = BrowserCookieImportResult & { - executionHostId: ExecutionHostId - executionHostLabel: string - // Why: for a remote environment the import silently runs on either machine; toasts must say which. - executionMachine: 'client' | 'remote' - executionRemoteEnvironment: boolean -} - -function retainCookieImportExecutionHost( - result: BrowserCookieImportResult, - executionHostId: ExecutionHostId, - executionHostLabel: string, - executionMachine: 'client' | 'remote' -): BrowserCookieImportExecutionResult { - return { - ...result, - executionHostId, - executionHostLabel, - executionMachine, - executionRemoteEnvironment: parseExecutionHostId(executionHostId)?.kind === 'runtime' - } -} - -export type BrowserSlice = { - browserTabsByWorktree: Record - browserPagesByWorkspace: Record - browserCertificateFailuresByPageId: Record - browserAnnotationsByPageId: Record - remoteBrowserPageHandlesByPageId: Record - /** - * Closes of client-hosted pages their owning runtime never heard, keyed by environment. - * - * Durable because the runtime persists the pages themselves: a close swallowed while the host - * was down would otherwise be undone by that host's next start. - */ - clientHostedBrowserCloseIntentsByEnvironment: ClientHostedBrowserCloseIntentsByEnvironment - recordClientHostedBrowserCloseIntents: ( - closes: readonly PendingClientHostedBrowserClose[] - ) => void - clearClientHostedBrowserCloseIntents: ( - environmentId: string, - browserPageIds: readonly string[] - ) => void - activeBrowserTabId: string | null - activeBrowserTabIdByWorktree: Record - recentlyClosedBrowserTabsByWorktree: Record - recentlyClosedBrowserPagesByWorkspace: Record - pendingAddressBarFocusByTabId: Record - pendingAddressBarFocusByPageId: Record - createBrowserTab: ( - worktreeId: string, - url: string, - options?: CreateBrowserTabOptions - ) => BrowserWorkspace - openNewBrowserTabInActiveWorkspace: (groupId: string) => Promise - /** `profileId: null` uses the workspace default profile. */ - openBrowserProfileTabInActiveWorkspace: ( - url: string, - profileId: string | null - ) => Promise - closeBrowserTab: (tabId: string, options?: { reason?: 'cleanup' }) => void - shutdownWorktreeBrowsers: (worktreeId: string) => Promise - reopenClosedBrowserTab: (worktreeId: string) => BrowserWorkspace | null - setActiveBrowserTab: (tabId: string) => void - createBrowserPage: ( - workspaceId: string, - url: string, - options?: CreateBrowserPageOptions - ) => BrowserPage | null - closeBrowserPage: (pageId: string) => void - // Why replacement and not mutation: a page's kind (doc vs web) is immutable for its life, so the - // address bar converts by replacing the page — fresh id, same workspace row — keeping the two - // main-process registry halves disjoint by construction. - convertBrowserPage: ( - pageId: string, - target: BrowserPageConversionTarget, - options?: { leg?: BrowserPageConversionLeg } - ) => BrowserPage | null - reopenClosedBrowserPage: (workspaceId: string) => BrowserPage | null - setActiveBrowserPage: (workspaceId: string, pageId: string) => void - // Focus that never yanks the user across worktrees: per-worktree slots always update, globals only when targeting the active worktree. - focusBrowserTabInWorktree: ( - worktreeId: string, - browserPageId: string, - options?: { surfacePane?: boolean } - ) => void - consumeAddressBarFocusRequest: (pageId: string) => boolean - updateBrowserTabPageState: (pageId: string, updates: BrowserTabPageState) => void - updateBrowserPageState: (pageId: string, updates: BrowserTabPageState) => void - setBrowserPageCertificateFailure: ( - pageId: string, - failure: BrowserCertificateFailure | null - ) => void - setBrowserTabUrl: (pageId: string, url: string) => void - setBrowserPageUrl: (pageId: string, url: string, options?: SetBrowserPageUrlOptions) => void - setRemoteBrowserPageHandle: (pageId: string, handle: RemoteBrowserPageHandle) => void - removeRemoteBrowserPageHandle: ( - pageId: string, - remotePageId?: string - ) => RemoteBrowserPageHandle | null - setBrowserPageViewportPreset: ( - pageId: string, - viewportPresetId: BrowserViewportPresetId | null - ) => void - addBrowserPageAnnotation: (annotation: BrowserPageAnnotation) => void - updateBrowserPageAnnotation: ( - pageId: string, - annotationId: string, - patch: { comment: string; intent: BrowserAnnotationIntent } - ) => void - deleteBrowserPageAnnotation: (pageId: string, annotationId: string) => void - clearBrowserPageAnnotations: (pageId: string) => void - hydrateBrowserSession: ( - session: WorkspaceSessionState, - options?: WorkspaceSessionHydrationOptions - ) => void - switchBrowserTabProfile: ( - workspaceId: string, - profileId: string | null, - sessionPartition?: string | null - ) => void - browserSessionProfiles: BrowserSessionProfile[] - browserSessionProfilesByHostId: Partial> - browserSessionHostIdOverride: ExecutionHostId | null - setBrowserSessionHostId: (hostId: ExecutionHostId) => Promise - browserSessionImportState: { - profileId: string - status: 'idle' | 'importing' | 'success' | 'error' - summary: BrowserCookieImportSummary | null - error: string | null - } | null - fetchBrowserSessionProfiles: () => Promise - createBrowserSessionProfile: ( - scope: 'isolated' | 'imported', - label: string, - options?: BrowserSessionProfileCreateOptions - ) => Promise - deleteBrowserSessionProfile: (profileId: string) => Promise - importCookiesToProfile: (profileId: string) => Promise - clearBrowserSessionImportState: () => void - detectedBrowsers: { - family: string - label: string - profiles: { name: string; directory: string }[] - selectedProfile: string - }[] - detectedBrowsersLoaded: boolean - // Why: which machine answered detection for a remote environment, so import menus can say where - // the import will read and store; null while browser settings target the local host. - detectedBrowsersHost: { machine: 'client' | 'remote'; hostLabel: string } | null - fetchDetectedBrowsers: () => Promise - importCookiesFromBrowser: ( - profileId: string, - browserFamily: string, - browserProfile?: string - ) => Promise - clearDefaultSessionCookies: () => Promise - browserUrlHistory: BrowserHistoryEntry[] - addBrowserHistoryEntry: (url: string, title: string) => void - workspaceDocHistory: WorkspaceDocHistoryEntry[] - /** A visit bumps recency and count; a title-only refresh (bump: false) renames the row. */ - recordWorkspaceDocVisit: ( - docLocation: BrowserPageDocLocation, - title: string | null, - options?: { bump?: boolean } - ) => void - clearBrowserHistory: () => void - defaultBrowserSessionProfileId: string | null - defaultBrowserSessionProfileIdByHostId: Partial> - setDefaultBrowserSessionProfileId: (profileId: string | null) => void -} - -function getBrowserSettingsHostId( - state: Pick -): ExecutionHostId { - return state.browserSessionHostIdOverride ?? getSettingsFocusedExecutionHostId(state.settings) -} - -function getBrowserSettingsRuntimeEnvironmentId( - state: Pick -): string | null { - const parsed = parseExecutionHostId(getBrowserSettingsHostId(state)) - return parsed?.kind === 'runtime' ? parsed.environmentId : null -} - -function getBrowserWorktreeHostId(state: AppState, worktreeId: string): ExecutionHostId { - return getExecutionHostIdForWorktree(state, worktreeId) -} - -function getBrowserSessionProfileHostId( - state: AppState, - worktreeId: string, - browserRuntimeEnvironmentId: string | null | undefined -): ExecutionHostId { - if (browserRuntimeEnvironmentId === null) { - return LOCAL_EXECUTION_HOST_ID - } - if (browserRuntimeEnvironmentId !== undefined) { - const runtimeEnvironmentId = browserRuntimeEnvironmentId.trim() - return runtimeEnvironmentId - ? toRuntimeExecutionHostId(runtimeEnvironmentId) - : LOCAL_EXECUTION_HOST_ID - } - return getBrowserWorktreeHostId(state, worktreeId) -} - -export function isLocalBrowserPageOwner( - state: AppState, - worktreeId: string, - browserRuntimeEnvironmentId: string | null | undefined -): boolean { - return ( - parseExecutionHostId( - getBrowserSessionProfileHostId(state, worktreeId, browserRuntimeEnvironmentId) - )?.kind !== 'runtime' - ) -} - -function profileListByHostUpdate( - state: Pick< - AppState, - 'browserSessionHostIdOverride' | 'browserSessionProfilesByHostId' | 'settings' - >, - profiles: BrowserSessionProfile[], - hostId: ExecutionHostId = getBrowserSettingsHostId(state) -): Partial { - return { - ...(getBrowserSettingsHostId(state) === hostId ? { browserSessionProfiles: profiles } : {}), - browserSessionProfilesByHostId: { - ...state.browserSessionProfilesByHostId, - [hostId]: profiles - } - } -} - -function getBrowserProfilesForHost( - state: AppState, - hostId: ExecutionHostId -): BrowserSessionProfile[] { - return ( - state.browserSessionProfilesByHostId[hostId] ?? - (getBrowserSettingsHostId(state) === hostId ? state.browserSessionProfiles : []) - ) -} - -function getDefaultBrowserProfileForHost(state: AppState, hostId: ExecutionHostId): string | null { - return ( - state.defaultBrowserSessionProfileIdByHostId[hostId] ?? - (getBrowserSettingsHostId(state) === hostId ? state.defaultBrowserSessionProfileId : null) - ) -} - -function browserImportStateForHostUpdate( - state: AppState, - hostId: ExecutionHostId, - browserSessionImportState: BrowserSlice['browserSessionImportState'] -): Partial { - return getBrowserSettingsHostId(state) === hostId ? { browserSessionImportState } : {} -} - -function closeRemoteBrowserPageInOwningEnvironment( - worktreeId: string, - handle: RemoteBrowserPageHandle, - // Why a callback and not the store: this is module scope, and the recording action lives on the - // slice this file is still defining. - recordCloseIntents: (closes: readonly PendingClientHostedBrowserClose[]) => void -): void { - const target: RuntimeClientTarget = { kind: 'environment', environmentId: handle.environmentId } - void callRuntimeRpc( - target, - 'browser.tabClose', - { worktree: toRuntimeWorktreeSelector(worktreeId), page: handle.remotePageId }, - { timeoutMs: 15_000 } - ).catch((error) => { - // A close the runtime never heard is a resurrection waiting to happen: the runtime persists - // client-hosted pages, so a durable intent replays this same RPC on the next reconnect. - // A definitive page-unknown answer means there is nothing left to resurrect. - if (!isDurableClientHostedBrowserHandle(handle) || isBrowserPageDefinitivelyGone(error)) { - return - } - recordCloseIntents([ - { environmentId: handle.environmentId, browserPageId: handle.remotePageId, worktreeId } - ]) - }) -} - -function getFallbackTabTypeForWorktree( - worktreeId: string, - openFiles: AppState['openFiles'], - terminalTabsByWorktree: AppState['tabsByWorktree'], - browserTabsByWorktree?: AppState['browserTabsByWorktree'] -): AppState['activeTabType'] { - if (openFiles.some((file) => file.worktreeId === worktreeId)) { - return 'editor' - } - if ((browserTabsByWorktree?.[worktreeId] ?? []).length > 0) { - return 'browser' - } - if ((terminalTabsByWorktree[worktreeId] ?? []).length > 0) { - return 'terminal' - } - return 'terminal' -} - -export const createBrowserSlice: StateCreator = (set, get) => ({ - browserTabsByWorktree: {}, - browserPagesByWorkspace: {}, - browserCertificateFailuresByPageId: {}, - browserAnnotationsByPageId: {}, - remoteBrowserPageHandlesByPageId: {}, - clientHostedBrowserCloseIntentsByEnvironment: {}, - activeBrowserTabId: null, - activeBrowserTabIdByWorktree: {}, - recentlyClosedBrowserTabsByWorktree: {}, - recentlyClosedBrowserPagesByWorkspace: {}, - pendingAddressBarFocusByTabId: {}, - pendingAddressBarFocusByPageId: {}, - browserSessionProfiles: [], - browserSessionProfilesByHostId: {}, - browserSessionHostIdOverride: null, - browserSessionImportState: null, - browserUrlHistory: [], - workspaceDocHistory: [], - defaultBrowserSessionProfileId: null, - defaultBrowserSessionProfileIdByHostId: {}, - - recordClientHostedBrowserCloseIntents: (closes) => { - set((s) => { - const next = recordClientHostedBrowserCloseIntents( - s.clientHostedBrowserCloseIntentsByEnvironment, - closes, - Date.now() - ) - return next ? { clientHostedBrowserCloseIntentsByEnvironment: next } : {} - }) - }, - - clearClientHostedBrowserCloseIntents: (environmentId, browserPageIds) => { - set((s) => { - const next = clearClientHostedBrowserCloseIntents( - s.clientHostedBrowserCloseIntentsByEnvironment, - { environmentId, browserPageIds, now: Date.now() } - ) - return next ? { clientHostedBrowserCloseIntentsByEnvironment: next } : {} - }) - }, - - setBrowserSessionHostId: async (hostId) => { - const parsed = parseExecutionHostId(hostId) - if (parsed?.kind !== 'local' && parsed?.kind !== 'runtime') { - return - } - const nextHostId = parsed.id - set((s) => ({ - browserSessionHostIdOverride: nextHostId, - browserSessionProfiles: s.browserSessionProfilesByHostId[nextHostId] ?? [], - defaultBrowserSessionProfileId: s.defaultBrowserSessionProfileIdByHostId[nextHostId] ?? null, - browserSessionImportState: null, - detectedBrowsers: [], - detectedBrowsersLoaded: false, - detectedBrowsersHost: null - })) - await Promise.all([get().fetchBrowserSessionProfiles(), get().fetchDetectedBrowsers()]) - }, - - setDefaultBrowserSessionProfileId: (profileId) => { - set((s) => ({ - defaultBrowserSessionProfileId: profileId, - defaultBrowserSessionProfileIdByHostId: { - ...s.defaultBrowserSessionProfileIdByHostId, - [getBrowserSettingsHostId(s)]: profileId - } - })) - }, - - createBrowserTab: (worktreeId, url, options) => { - assertManagedBrowserMaterializationAllowed(get(), options?.browserRuntimeEnvironmentId) - const workspaceId = createBrowserUuid() - const browserPageId = options?.browserPageId - if ( - browserPageId && - (findWorkspace(get().browserTabsByWorktree, browserPageId) || - findPage(get().browserPagesByWorkspace, browserPageId)) - ) { - throw new Error(`Browser page ${browserPageId} already exists`) - } - const page = buildBrowserPage( - workspaceId, - worktreeId, - url, - options?.title, - options?.browserRuntimeEnvironmentId, - browserPageId, - options?.docLocation - ) - // Why: with no explicit profile, inherit the user's default so a Settings preference applies to new tabs. - const sessionProfileId = - options?.sessionProfileId !== undefined - ? options.sessionProfileId - : (get().defaultBrowserSessionProfileIdByHostId[ - getBrowserSessionProfileHostId(get(), worktreeId, options?.browserRuntimeEnvironmentId) - ] ?? get().defaultBrowserSessionProfileId) - const browserTab = buildWorkspaceFromPage( - workspaceId, - worktreeId, - page, - [page.id], - sessionProfileId, - options?.sessionPartition - ) - - set((s) => { - const existingTabs = s.browserTabsByWorktree[worktreeId] ?? [] - const nextTabBarOrder = (() => { - const currentOrder = s.tabBarOrderByWorktree[worktreeId] ?? [] - const terminalIds = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id) - const editorIds = s.openFiles - .filter((file) => file.worktreeId === worktreeId) - .map((file) => file.id) - const browserIds = existingTabs.map((tab) => tab.id) - const allExistingIds = new Set([...terminalIds, ...editorIds, ...browserIds]) - const base = currentOrder.filter((entryId) => allExistingIds.has(entryId)) - const inBase = new Set(base) - for (const entryId of [...terminalIds, ...editorIds, ...browserIds]) { - if (!inBase.has(entryId)) { - base.push(entryId) - inBase.add(entryId) - } - } - base.push(workspaceId) - return base - })() - - const shouldActivate = options?.activate ?? true - const shouldUpdateGlobalActiveSurface = shouldActivate && s.activeWorktreeId === worktreeId - const shouldFocusFloatingTab = shouldActivate && worktreeId === FLOATING_TERMINAL_WORKTREE_ID - const shouldFocusAddressBar = - (shouldUpdateGlobalActiveSurface || shouldFocusFloatingTab) && - // Why the doc check and not just the url: a document page is blank by construction, and - // the blank url is exactly what marks a New Tab as wanting the address bar it does not have. - !page.docLocation && - (options?.focusAddressBar ?? - (page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL)) - - return { - browserTabsByWorktree: { - ...s.browserTabsByWorktree, - [worktreeId]: [...existingTabs, browserTab] - }, - browserPagesByWorkspace: { - ...s.browserPagesByWorkspace, - [workspaceId]: [page] - }, - tabBarOrderByWorktree: { - ...s.tabBarOrderByWorktree, - [worktreeId]: nextTabBarOrder - }, - activeBrowserTabId: shouldUpdateGlobalActiveSurface ? workspaceId : s.activeBrowserTabId, - activeBrowserTabIdByWorktree: { - ...s.activeBrowserTabIdByWorktree, - [worktreeId]: shouldActivate - ? workspaceId - : (s.activeBrowserTabIdByWorktree[worktreeId] ?? null) - }, - activeTabType: shouldUpdateGlobalActiveSurface ? 'browser' : s.activeTabType, - activeTabTypeByWorktree: shouldActivate - ? { ...s.activeTabTypeByWorktree, [worktreeId]: 'browser' } - : s.activeTabTypeByWorktree, - pendingAddressBarFocusByPageId: shouldFocusAddressBar - ? { - ...s.pendingAddressBarFocusByPageId, - [page.id]: true - } - : s.pendingAddressBarFocusByPageId, - pendingAddressBarFocusByTabId: shouldFocusAddressBar - ? { - ...s.pendingAddressBarFocusByTabId, - [workspaceId]: true, - [page.id]: true - } - : s.pendingAddressBarFocusByTabId - } - }) - - const state = get() - const alreadyHasUnifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).some( - (t) => t.contentType === 'browser' && t.entityId === workspaceId - ) - if (!alreadyHasUnifiedTab) { - state.createUnifiedTab(worktreeId, 'browser', { - entityId: workspaceId, - label: browserTab.title, - targetGroupId: options?.targetGroupId, - activate: options?.activate ?? true - }) - } - return browserTab - }, - - openNewBrowserTabInActiveWorkspace: async (groupId) => { - const state = get() - const worktreeId = state.activeWorktreeId - if (!worktreeId) { - return - } - const browserAvailability = getClientCreationActionPolicy(state, worktreeId)['managed-browser'] - if (browserAvailability.state !== 'enabled') { - throw new Error(browserAvailability.reason) - } - const defaultUrl = state.browserDefaultUrl ?? 'about:blank' - const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) - if (browserAvailability.provider === 'paired-runtime') { - if (!runtimeEnvironmentId) { - throw new Error('The paired runtime browser provider is unavailable.') - } - const { createWebRuntimeSessionBrowserTab } = await import('@/runtime/web-runtime-session') - try { - const created = await createWebRuntimeSessionBrowserTab({ - worktreeId, - environmentId: runtimeEnvironmentId, - url: defaultUrl, - // Why: desktop pane groups are client-owned — the local reconciler only honors a - // recorded clientTargetGroupId; targetGroupId steers snapshot-driven clients instead. - targetGroupId: groupId, - clientTargetGroupId: groupId - }) - if (created) { - get().recordFeatureInteraction('browser-tab-created') - return - } - } catch (error) { - // Why: browser.headless.v1 remotes succeed above, so a failure here is real; surface it instead of a confusing local-tab fallback (split ownership). - console.warn( - '[browser] remote browser tab creation failed:', - error instanceof Error ? error.message : String(error) - ) - throw error - } - throw new Error('The paired runtime could not create a managed browser tab.') - } - get().createBrowserTab(worktreeId, defaultUrl, { - title: translate('auto.store.slices.browser.d175274b6d', 'New Browser Tab'), - focusAddressBar: true, - ...(runtimeEnvironmentId ? { browserRuntimeEnvironmentId: null } : {}), - targetGroupId: groupId - }) - get().recordFeatureInteraction('browser-tab-created') - }, - - openBrowserProfileTabInActiveWorkspace: async (url, profileId) => { - const state = get() - const worktreeId = state.activeWorktreeId - if (!worktreeId) { - return false - } - const browserAvailability = getClientCreationActionPolicy(state, worktreeId)['managed-browser'] - if (browserAvailability.state !== 'enabled') { - return false - } - const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) - if (browserAvailability.provider === 'paired-runtime') { - if (!runtimeEnvironmentId) { - return false - } - const { createWebRuntimeSessionBrowserTab } = await import('@/runtime/web-runtime-session') - try { - return await createWebRuntimeSessionBrowserTab({ - worktreeId, - environmentId: runtimeEnvironmentId, - url, - profileId - }) - } catch (error) { - console.warn( - '[browser] remote profile tab creation failed:', - error instanceof Error ? error.message : String(error) - ) - return false - } - } - get().createBrowserTab(worktreeId, url, { - activate: true, - sessionProfileId: profileId, - ...(runtimeEnvironmentId ? { browserRuntimeEnvironmentId: null } : {}) - }) - return true - }, - closeBrowserTab: (tabId, options) => { - // Why: a cleanup close unwinds a tab that never finished being created — it owns no host - // page to close and must not enter the reopen stack as if the user had closed something. - const isCleanup = options?.reason === 'cleanup' - let remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = [] - // Why collected rather than released inside the reducer: revoking is main-process work, and a - // grant is the only authority the preview scheme honors — a closed document must stop being - // readable, and it must stop being readable even if the reducer bails out below. - let docPageIdsToRelease: string[] = [] - let activeBrowserWorktreeIdToNotify: string | null = null - set((s) => { - let owningWorktreeId: string | null = null - let closedWorkspace: BrowserWorkspace | null = null - const nextBrowserTabsByWorktree: Record = {} - for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) { - const removedTab = tabs.find((tab) => tab.id === tabId) ?? null - const filtered = tabs.filter((tab) => tab.id !== tabId) - if (filtered.length !== tabs.length) { - owningWorktreeId = worktreeId - closedWorkspace = removedTab - } - if (filtered.length > 0) { - nextBrowserTabsByWorktree[worktreeId] = filtered - } - } - if (!owningWorktreeId || !closedWorkspace) { - return s - } - - const closedPages = s.browserPagesByWorkspace[tabId] ?? [] - const nextBrowserPagesByWorkspace = { ...s.browserPagesByWorkspace } - delete nextBrowserPagesByWorkspace[tabId] - const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } - const nextBrowserCertificateFailuresByPageId = { - ...s.browserCertificateFailuresByPageId - } - for (const page of closedPages) { - delete nextBrowserAnnotationsByPageId[page.id] - delete nextBrowserCertificateFailuresByPageId[page.id] - } - docPageIdsToRelease = closedPages.filter((page) => page.docLocation).map((page) => page.id) - remotePagesToClose = isCleanup - ? [] - : closedPages.flatMap((page) => { - const handle = s.remoteBrowserPageHandlesByPageId[page.id] - return handle ? [{ worktreeId: page.worktreeId, handle }] : [] - }) - const nextRemoteBrowserPageHandlesByPageId = { - ...s.remoteBrowserPageHandlesByPageId - } - for (const page of closedPages) { - delete nextRemoteBrowserPageHandlesByPageId[page.id] - } - - const remainingBrowserTabs = nextBrowserTabsByWorktree[owningWorktreeId] ?? [] - const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree } - if (nextActiveBrowserTabIdByWorktree[owningWorktreeId] === tabId) { - const neighborId = pickNeighbor(s.tabBarOrderByWorktree[owningWorktreeId] ?? [], tabId) - nextActiveBrowserTabIdByWorktree[owningWorktreeId] = - (neighborId && remainingBrowserTabs.some((tab) => tab.id === neighborId) - ? neighborId - : remainingBrowserTabs[0]?.id) ?? null - } - - const nextTabBarOrder = { - ...s.tabBarOrderByWorktree, - [owningWorktreeId]: (s.tabBarOrderByWorktree[owningWorktreeId] ?? []).filter( - (entryId) => entryId !== tabId - ) - } - - const isActiveTabInOwningWorktree = - s.activeWorktreeId === owningWorktreeId && s.activeBrowserTabId === tabId - if (isActiveTabInOwningWorktree) { - activeBrowserWorktreeIdToNotify = owningWorktreeId - } - const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree } - let nextActiveTabType = s.activeTabType - if (remainingBrowserTabs.length === 0) { - const fallbackTabType = getFallbackTabTypeForWorktree( - owningWorktreeId, - s.openFiles, - s.tabsByWorktree - ) - nextActiveTabTypeByWorktree[owningWorktreeId] = fallbackTabType - if (isActiveTabInOwningWorktree && s.activeTabType === 'browser') { - nextActiveTabType = fallbackTabType - } - } - - const nextRecentlyClosedBrowserTabsByWorktree = { ...s.recentlyClosedBrowserTabsByWorktree } - if (!isCleanup) { - const existingSnapshots = nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] ?? [] - const position = getRecentlyClosedTabPosition(s, owningWorktreeId, tabId) - nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] = [ - { - workspace: closedWorkspace, - pages: closedPages, - ...(position ? { position } : {}) - }, - ...existingSnapshots.filter((entry) => entry.workspace.id !== closedWorkspace.id) - ].slice(0, 10) - } - const nextRecentlyClosedTabKindsByWorktree = isCleanup - ? s.recentlyClosedTabKindsByWorktree - : pushRecentlyClosedTabKind(s.recentlyClosedTabKindsByWorktree, owningWorktreeId, 'browser') - - const nextRecentlyClosedBrowserPagesByWorkspace = { - ...s.recentlyClosedBrowserPagesByWorkspace - } - delete nextRecentlyClosedBrowserPagesByWorkspace[tabId] - - const nextPendingAddressBarFocusByPageId = Object.fromEntries( - Object.entries(s.pendingAddressBarFocusByPageId).filter( - ([pageId]) => !closedPages.some((page) => page.id === pageId) - ) - ) - const nextPendingAddressBarFocusByTabId = Object.fromEntries( - Object.entries(s.pendingAddressBarFocusByTabId).filter( - ([focusId]) => focusId !== tabId && !closedPages.some((page) => page.id === focusId) - ) - ) - - return { - browserTabsByWorktree: nextBrowserTabsByWorktree, - browserPagesByWorkspace: nextBrowserPagesByWorkspace, - activeBrowserTabId: - s.activeBrowserTabId === tabId - ? (nextActiveBrowserTabIdByWorktree[owningWorktreeId] ?? null) - : s.activeBrowserTabId, - activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree, - tabBarOrderByWorktree: nextTabBarOrder, - activeTabType: nextActiveTabType, - pendingAddressBarFocusByPageId: nextPendingAddressBarFocusByPageId, - pendingAddressBarFocusByTabId: nextPendingAddressBarFocusByTabId, - activeTabTypeByWorktree: nextActiveTabTypeByWorktree, - recentlyClosedBrowserTabsByWorktree: nextRecentlyClosedBrowserTabsByWorktree, - recentlyClosedTabKindsByWorktree: nextRecentlyClosedTabKindsByWorktree, - recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace, - remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, - browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, - browserAnnotationsByPageId: nextBrowserAnnotationsByPageId - } - }) - - for (const remotePage of remotePagesToClose) { - closeRemoteBrowserPageInOwningEnvironment( - remotePage.worktreeId, - remotePage.handle, - get().recordClientHostedBrowserCloseIntents - ) - } - - for (const docPageId of docPageIdsToRelease) { - releaseDocPreviewGrant(docPageId) - } - - for (const tabs of Object.values(get().unifiedTabsByWorktree)) { - const workspaceItem = tabs.find( - (entry) => entry.contentType === 'browser' && entry.entityId === tabId - ) - if (workspaceItem) { - get().closeUnifiedTab( - workspaceItem.id, - isCleanup ? { preserveWorktreeSelection: true, recordInteraction: false } : undefined - ) - } - } - - // Why: announce the MRU page before guest teardown so bridge fallback cannot choose registration order. - if (activeBrowserWorktreeIdToNotify) { - const state = get() - const activeWorkspaceId = state.activeBrowserTabIdByWorktree[activeBrowserWorktreeIdToNotify] - const activeWorkspace = activeWorkspaceId - ? findWorkspace(state.browserTabsByWorktree, activeWorkspaceId) - : null - const activePage = activeWorkspace?.activePageId - ? (state.browserPagesByWorkspace[activeWorkspace.id] ?? []).find( - (page) => page.id === activeWorkspace.activePageId - ) - : undefined - if ( - activeWorkspace?.activePageId && - isLocalBrowserPageOwner( - state, - activeBrowserWorktreeIdToNotify, - activePage?.browserRuntimeEnvironmentId - ) && - typeof window !== 'undefined' && - window.api?.browser - ) { - window.api.browser - .notifyActiveTabChanged({ browserPageId: activeWorkspace.activePageId }) - .catch(() => {}) - } - } - }, - - shutdownWorktreeBrowsers: async (worktreeId) => { - const workspaces = get().browserTabsByWorktree[worktreeId] ?? [] - // Why: snapshot before the loop — closeBrowserTab empties the array, so set() below couldn't recompute hadBrowserTabs. - const hadBrowserTabs = workspaces.length > 0 - for (const workspace of workspaces) { - const browserPagesByWorkspace = get().browserPagesByWorkspace - get().closeBrowserTab(workspace.id) - destroyWorkspaceWebviews(browserPagesByWorkspace, workspace.id) - } - set((s) => { - const nextBrowserTabsByWorktree = { ...s.browserTabsByWorktree } - delete nextBrowserTabsByWorktree[worktreeId] - const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree } - delete nextActiveBrowserTabIdByWorktree[worktreeId] - // Why: reset the global browser surface only when the shut-down worktree is the active one AND had tabs. - const shouldResetGlobalBrowser = s.activeWorktreeId === worktreeId && hadBrowserTabs - return { - browserTabsByWorktree: nextBrowserTabsByWorktree, - activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree, - ...(shouldResetGlobalBrowser - ? { activeBrowserTabId: null, activeTabType: 'terminal' as const } - : {}) - } - }) - }, - - reopenClosedBrowserTab: (worktreeId) => { - // Why: read and pop atomically inside set() so two rapid Cmd+Shift+T presses can't both restore the same entry (TOCTOU). - let entryToRestore: ClosedBrowserWorkspaceSnapshot | undefined - - set((s) => { - const recentlyClosed = s.recentlyClosedBrowserTabsByWorktree[worktreeId] ?? [] - entryToRestore = recentlyClosed[0] - if (!entryToRestore) { - return s - } - return { - recentlyClosedBrowserTabsByWorktree: { - ...s.recentlyClosedBrowserTabsByWorktree, - [worktreeId]: recentlyClosed.slice(1) - } - } - }) - - if (!entryToRestore) { - return null - } - - const snap = entryToRestore.workspace - const pages = entryToRestore.pages - const sessionProfileId = snap.sessionProfileId ?? null - const sessionPartition = snap.sessionPartition ?? null - - if (pages.length === 0) { - const restored = get().createBrowserTab(worktreeId, snap.url, { - title: snap.title, - activate: true, - sessionProfileId, - sessionPartition, - ...(snap.docLocation ? { docLocation: snap.docLocation } : {}), - targetGroupId: entryToRestore.position?.groupId - }) - restoreRecentlyClosedTabPosition(get, worktreeId, restored.id, entryToRestore.position) - return get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ?? null - } - - // Why: append remaining pages in original order so multi-page workspaces preserve their page sequence. - const [firstPage, ...restPages] = pages - const restored = get().createBrowserTab(worktreeId, firstPage.url, { - title: firstPage.title, - activate: true, - sessionProfileId, - sessionPartition, - targetGroupId: entryToRestore.position?.groupId, - browserRuntimeEnvironmentId: firstPage.browserRuntimeEnvironmentId, - ...(firstPage.docLocation ? { docLocation: firstPage.docLocation } : {}) - }) - - for (const p of restPages) { - get().createBrowserPage(restored.id, p.url, { - activate: false, - title: p.title, - browserRuntimeEnvironmentId: p.browserRuntimeEnvironmentId, - ...(p.docLocation ? { docLocation: p.docLocation } : {}) - }) - } - - // Why: duplicate URLs are valid, so matching by URL can pick the wrong copy; restore preserves order, so map by index. - const activePageId = snap.activePageId - if (activePageId) { - const restoredPages = get().browserPagesByWorkspace[restored.id] ?? [] - const activePageIndex = pages.findIndex((orig) => orig.id === activePageId) - const targetPage = activePageIndex !== -1 ? restoredPages[activePageIndex] : null - if (targetPage && targetPage.id !== restoredPages[0]?.id) { - get().setActiveBrowserPage(restored.id, targetPage.id) - } - } - - restoreRecentlyClosedTabPosition(get, worktreeId, restored.id, entryToRestore.position) - - return get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ?? null - }, - - setActiveBrowserTab: (tabId) => { - set((s) => { - const browserTab = findWorkspace(s.browserTabsByWorktree, tabId) - if (!browserTab) { - return s - } - return { - activeBrowserTabId: tabId, - activeBrowserTabIdByWorktree: { - ...s.activeBrowserTabIdByWorktree, - [browserTab.worktreeId]: tabId - }, - activeTabType: 'browser', - activeTabTypeByWorktree: { - ...s.activeTabTypeByWorktree, - [browserTab.worktreeId]: 'browser' - } - } - }) - - // Why: notify the CDP bridge of the active guest; it keys on page IDs not workspace IDs, so resolve the workspace's active page. - const workspace = findWorkspace(get().browserTabsByWorktree, tabId) - const activePage = workspace?.activePageId - ? (get().browserPagesByWorkspace[workspace.id] ?? []).find( - (page) => page.id === workspace.activePageId - ) - : undefined - if ( - workspace?.activePageId && - isLocalBrowserPageOwner( - get(), - workspace.worktreeId, - activePage?.browserRuntimeEnvironmentId - ) && - typeof window !== 'undefined' && - window.api?.browser - ) { - window.api.browser - .notifyActiveTabChanged({ browserPageId: workspace.activePageId }) - .catch(() => {}) - } - - const item = Object.values(get().unifiedTabsByWorktree) - .flat() - .find((entry) => entry.contentType === 'browser' && entry.entityId === tabId) - if (item) { - get().activateTab(item.id) - } - }, - - createBrowserPage: (workspaceId, url, options) => { - const workspace = findWorkspace(get().browserTabsByWorktree, workspaceId) - if (!workspace) { - return null - } - const page = buildBrowserPage( - workspaceId, - workspace.worktreeId, - url, - options?.title, - options?.browserRuntimeEnvironmentId, - undefined, - options?.docLocation - ) - - set((s) => { - const pages = s.browserPagesByWorkspace[workspaceId] ?? [] - const shouldActivate = options?.activate ?? true - const nextPages = [...pages, page] - const nextWorkspace = mirrorWorkspaceFromActivePage( - { - ...workspace, - activePageId: shouldActivate ? page.id : (workspace.activePageId ?? page.id), - pageIds: nextPages.map((entry) => entry.id) - }, - nextPages - ) - const shouldUpdateGlobalActiveSurface = - shouldActivate && - s.activeWorktreeId === workspace.worktreeId && - s.activeBrowserTabIdByWorktree[workspace.worktreeId] === workspaceId - const shouldFocusAddressBar = - shouldUpdateGlobalActiveSurface && - !page.docLocation && - (page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL) - - return { - browserPagesByWorkspace: { - ...s.browserPagesByWorkspace, - [workspaceId]: nextPages - }, - browserTabsByWorktree: { - ...s.browserTabsByWorktree, - [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( - (tab) => (tab.id === workspaceId ? nextWorkspace : tab) - ) - }, - pendingAddressBarFocusByPageId: shouldFocusAddressBar - ? { - ...s.pendingAddressBarFocusByPageId, - [page.id]: true - } - : s.pendingAddressBarFocusByPageId, - pendingAddressBarFocusByTabId: shouldFocusAddressBar - ? { - ...s.pendingAddressBarFocusByTabId, - [page.id]: true - } - : s.pendingAddressBarFocusByTabId - } - }) - - const nextWorkspace = findWorkspace(get().browserTabsByWorktree, workspaceId) - if (nextWorkspace?.activePageId === page.id) { - const item = Object.values(get().unifiedTabsByWorktree) - .flat() - .find((entry) => entry.contentType === 'browser' && entry.entityId === workspaceId) - if (item) { - get().setTabLabel(item.id, page.title) - } - } - return page - }, - - closeBrowserPage: (pageId) => { - let closedWorkspaceIdForLabel: string | null = null - let docPageIdToRelease: string | null = null - const remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = [] - set((s) => { - const page = findPage(s.browserPagesByWorkspace, pageId) - if (!page) { - return s - } - const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId) - if (!workspace) { - return s - } - closedWorkspaceIdForLabel = page.workspaceId - docPageIdToRelease = page.docLocation ? page.id : null - const currentPages = s.browserPagesByWorkspace[workspace.id] ?? [] - const nextPages = currentPages.filter((entry) => entry.id !== pageId) - const closedIdx = currentPages.findIndex((entry) => entry.id === pageId) - const nextActivePageId = - workspace.activePageId === pageId - ? ((nextPages[closedIdx] ?? nextPages[closedIdx - 1] ?? null)?.id ?? null) - : workspace.activePageId - const nextWorkspace = mirrorWorkspaceFromActivePage( - { - ...workspace, - activePageId: nextActivePageId, - pageIds: nextPages.map((entry) => entry.id) - }, - nextPages - ) - const remoteHandle = s.remoteBrowserPageHandlesByPageId[pageId] - if (remoteHandle) { - remotePagesToClose.push({ worktreeId: page.worktreeId, handle: remoteHandle }) - } - const nextRemoteBrowserPageHandlesByPageId = { - ...s.remoteBrowserPageHandlesByPageId - } - delete nextRemoteBrowserPageHandlesByPageId[pageId] - const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } - delete nextBrowserAnnotationsByPageId[pageId] - const nextBrowserCertificateFailuresByPageId = { - ...s.browserCertificateFailuresByPageId - } - delete nextBrowserCertificateFailuresByPageId[pageId] - - return { - browserPagesByWorkspace: { - ...s.browserPagesByWorkspace, - [workspace.id]: nextPages - }, - browserTabsByWorktree: { - ...s.browserTabsByWorktree, - [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( - (tab) => (tab.id === workspace.id ? nextWorkspace : tab) - ) - }, - recentlyClosedBrowserPagesByWorkspace: { - ...s.recentlyClosedBrowserPagesByWorkspace, - [workspace.id]: [ - page, - ...(s.recentlyClosedBrowserPagesByWorkspace[workspace.id] ?? []).filter( - (entry) => entry.id !== page.id - ) - ].slice(0, 10) - }, - pendingAddressBarFocusByPageId: Object.fromEntries( - Object.entries(s.pendingAddressBarFocusByPageId).filter( - ([pendingPageId]) => pendingPageId !== pageId - ) - ), - pendingAddressBarFocusByTabId: Object.fromEntries( - Object.entries(s.pendingAddressBarFocusByTabId).filter( - ([pendingPageId]) => pendingPageId !== pageId - ) - ), - remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, - browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, - browserAnnotationsByPageId: nextBrowserAnnotationsByPageId - } - }) - - for (const remotePage of remotePagesToClose) { - closeRemoteBrowserPageInOwningEnvironment( - remotePage.worktreeId, - remotePage.handle, - get().recordClientHostedBrowserCloseIntents - ) - } - - if (docPageIdToRelease) { - releaseDocPreviewGrant(docPageIdToRelease) - } - - const closedWorkspaceId = closedWorkspaceIdForLabel - if (!closedWorkspaceId) { - return - } - const workspace = findWorkspace(get().browserTabsByWorktree, closedWorkspaceId) - const item = Object.values(get().unifiedTabsByWorktree) - .flat() - .find((entry) => entry.contentType === 'browser' && entry.entityId === closedWorkspaceId) - if (item && workspace) { - get().setTabLabel(item.id, workspace.title) - } - }, - - convertBrowserPage: (pageId, target, options) => { - // Why the same assert as createBrowserTab: a conversion materializes a browser surface the - // same way a creation does, and a paired web client that cannot host one must refuse here too. - // Ownership is resolved ONCE, on the plan's own terms — property-present-undefined means - // worktree-inferred — so the assert and the plan cannot disagree about what is being built. - const oldPageForOwnership = findPage(get().browserPagesByWorkspace, pageId) - const declaredOwnership = - target.kind === 'workspace-doc' - ? null - : 'browserRuntimeEnvironmentId' in target - ? target.browserRuntimeEnvironmentId - : (oldPageForOwnership?.browserRuntimeEnvironmentId ?? null) - assertManagedBrowserMaterializationAllowed( - get(), - declaredOwnership !== undefined - ? declaredOwnership - : oldPageForOwnership - ? (getRuntimeEnvironmentIdForWorktree(get(), oldPageForOwnership.worktreeId) ?? null) - : null - ) - let converted: BrowserPage | null = null - let docPageIdToRelease: string | null = null - let remotePageToClose: { worktreeId: string; handle: RemoteBrowserPageHandle } | null = null - set((s) => { - const plan = planBrowserPageConversion(s, pageId, target, options) - if (!plan) { - return s - } - converted = plan.newPage - // Why collected rather than released inside the reducer: revoking is main-process work, and - // it must happen exactly once even if a later set() retries the reducer. - if (plan.oldPage.docLocation) { - docPageIdToRelease = plan.oldPage.id - } - const remoteHandle = s.remoteBrowserPageHandlesByPageId[plan.oldPage.id] - if (remoteHandle) { - remotePageToClose = { worktreeId: plan.oldPage.worktreeId, handle: remoteHandle } - } - const nextRemoteBrowserPageHandlesByPageId = { ...s.remoteBrowserPageHandlesByPageId } - delete nextRemoteBrowserPageHandlesByPageId[plan.oldPage.id] - const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } - delete nextBrowserAnnotationsByPageId[plan.oldPage.id] - const nextBrowserCertificateFailuresByPageId = { ...s.browserCertificateFailuresByPageId } - delete nextBrowserCertificateFailuresByPageId[plan.oldPage.id] - return { - browserPagesByWorkspace: { - ...s.browserPagesByWorkspace, - [plan.workspace.id]: plan.nextPages - }, - browserTabsByWorktree: { - ...s.browserTabsByWorktree, - [plan.workspace.worktreeId]: ( - s.browserTabsByWorktree[plan.workspace.worktreeId] ?? [] - ).map((tab) => (tab.id === plan.workspace.id ? plan.nextWorkspace : tab)) - }, - pendingAddressBarFocusByPageId: Object.fromEntries( - Object.entries(s.pendingAddressBarFocusByPageId).filter( - ([pendingPageId]) => pendingPageId !== plan.oldPage.id - ) - ), - pendingAddressBarFocusByTabId: Object.fromEntries( - Object.entries(s.pendingAddressBarFocusByTabId).filter( - ([pendingPageId]) => pendingPageId !== plan.oldPage.id - ) - ), - remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, - browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, - browserAnnotationsByPageId: nextBrowserAnnotationsByPageId - } - }) - // Why the casts: the assignments happen inside set()'s callback, which TS's flow analysis does - // not track, so the initializers' null narrowing would otherwise read these as never. - const newPage = converted as BrowserPage | null - const remoteClose = remotePageToClose as { - worktreeId: string - handle: RemoteBrowserPageHandle - } | null - if (!newPage) { - return null - } - if (remoteClose) { - closeRemoteBrowserPageInOwningEnvironment( - remoteClose.worktreeId, - remoteClose.handle, - get().recordClientHostedBrowserCloseIntents - ) - } - // Why after the reducer: a closed document must stop being readable, and the grant is the only - // authority the preview scheme honors — but the store row has to stop naming it first. - if (docPageIdToRelease) { - releaseDocPreviewGrant(docPageIdToRelease) - } - const workspaceAfter = findWorkspace(get().browserTabsByWorktree, newPage.workspaceId) - if (workspaceAfter?.activePageId === newPage.id) { - const item = Object.values(get().unifiedTabsByWorktree) - .flat() - .find((entry) => entry.contentType === 'browser' && entry.entityId === newPage.workspaceId) - if (item) { - get().setTabLabel(item.id, newPage.title) - } - } - return newPage - }, - - reopenClosedBrowserPage: (workspaceId) => { - // Why: read and pop atomically inside set() so two rapid Cmd+Shift+T presses can't both restore the same page (TOCTOU). - let pageToRestore: BrowserPage | undefined - - set((s) => { - const recentlyClosed = s.recentlyClosedBrowserPagesByWorkspace[workspaceId] ?? [] - pageToRestore = recentlyClosed[0] - if (!pageToRestore) { - return s - } - return { - recentlyClosedBrowserPagesByWorkspace: { - ...s.recentlyClosedBrowserPagesByWorkspace, - [workspaceId]: recentlyClosed.slice(1) - } - } - }) - - if (!pageToRestore) { - return null - } - - return get().createBrowserPage(workspaceId, pageToRestore.url, { - title: pageToRestore.title, - activate: true, - browserRuntimeEnvironmentId: pageToRestore.browserRuntimeEnvironmentId, - ...(pageToRestore.docLocation ? { docLocation: pageToRestore.docLocation } : {}) - }) - }, - - setActiveBrowserPage: (workspaceId, pageId) => { - set((s) => { - const workspace = findWorkspace(s.browserTabsByWorktree, workspaceId) - if (!workspace) { - return s - } - const pages = s.browserPagesByWorkspace[workspaceId] ?? [] - if (!pages.some((page) => page.id === pageId)) { - return s - } - const nextWorkspace = mirrorWorkspaceFromActivePage( - { - ...workspace, - activePageId: pageId - }, - pages - ) - return { - browserTabsByWorktree: { - ...s.browserTabsByWorktree, - [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( - (tab) => (tab.id === workspaceId ? nextWorkspace : tab) - ) - } - } - }) - - // Why: switching the active page changes which guest webContents the CDP bridge targets for agent commands. - const activePage = (get().browserPagesByWorkspace[workspaceId] ?? []).find( - (page) => page.id === pageId - ) - const workspace = findWorkspace(get().browserTabsByWorktree, workspaceId) - if ( - workspace && - isLocalBrowserPageOwner( - get(), - workspace.worktreeId, - activePage?.browserRuntimeEnvironmentId - ) && - typeof window !== 'undefined' && - window.api?.browser - ) { - window.api.browser.notifyActiveTabChanged({ browserPageId: pageId }).catch(() => {}) - } - if (!workspace) { - return - } - const item = Object.values(get().unifiedTabsByWorktree) - .flat() - .find((entry) => entry.contentType === 'browser' && entry.entityId === workspaceId) - if (item) { - get().setTabLabel(item.id, workspace.title) - } - }, - - focusBrowserTabInWorktree: (worktreeId, browserPageId, options) => { - // Why: bridge targets a browserPageId but tabs activate a workspace; find the owning workspace (they differ for multi-page tabs). - const tabsForWorktree = get().browserTabsByWorktree[worktreeId] ?? [] - const workspace = tabsForWorktree.find((tab) => (tab.pageIds ?? []).includes(browserPageId)) - if (!workspace) { - // Best-effort: worktree state may not be hydrated yet, or the page closed between bridge switch and this IPC arriving. - return - } - // Default true: the only caller (tab switch --focus) wants the pane surfaced; false is an opt-out for pre-staging callers. - const surfacePane = options?.surfacePane ?? true - const pages = get().browserPagesByWorkspace[workspace.id] ?? [] - const nextWorkspace = mirrorWorkspaceFromActivePage( - { ...workspace, activePageId: browserPageId }, - pages - ) - // TODO: duplicates setActiveBrowserTab/Page; can't reuse (they touch globals unconditionally). Extract a per-worktree-only helper. - set((s) => { - const isActiveWorktree = s.activeWorktreeId === worktreeId - // Per-worktree slots: always update — safe pre-staging, only visible when user navigates here. - const nextTabsByWorktree = { - ...s.browserTabsByWorktree, - [worktreeId]: tabsForWorktree.map((tab) => (tab.id === workspace.id ? nextWorkspace : tab)) - } - const nextActiveTabIdByWorktree = { - ...s.activeBrowserTabIdByWorktree, - [worktreeId]: workspace.id - } - const nextActiveTabTypeByWorktree = surfacePane - ? { ...s.activeTabTypeByWorktree, [worktreeId]: 'browser' as const } - : s.activeTabTypeByWorktree - // Globals: mutate only when the targeted worktree is active — keeps cross-worktree --focus silent. - return { - browserTabsByWorktree: nextTabsByWorktree, - activeBrowserTabIdByWorktree: nextActiveTabIdByWorktree, - activeTabTypeByWorktree: nextActiveTabTypeByWorktree, - activeBrowserTabId: isActiveWorktree ? workspace.id : s.activeBrowserTabId, - activeTabType: isActiveWorktree && surfacePane ? 'browser' : s.activeTabType - } - }) - - // Why: notify the CDP bridge which guest webContents is active so agent commands target the correct page. - const focusedPage = pages.find((page) => page.id === browserPageId) - if ( - isLocalBrowserPageOwner(get(), worktreeId, focusedPage?.browserRuntimeEnvironmentId) && - typeof window !== 'undefined' && - window.api?.browser - ) { - window.api.browser.notifyActiveTabChanged({ browserPageId }).catch(() => {}) - } - - // Why: sync the unified-tab strip's active entry; activateTab only mutates per-worktree slices, so it's cross-worktree-safe. - const item = (get().unifiedTabsByWorktree[worktreeId] ?? []).find( - (entry) => entry.contentType === 'browser' && entry.entityId === workspace.id - ) - if (item) { - get().activateTab(item.id) - } - }, - - consumeAddressBarFocusRequest: (pageId) => { - const state = get() - if ( - !state.pendingAddressBarFocusByPageId[pageId] && - !state.pendingAddressBarFocusByTabId[pageId] - ) { - return false - } - - set((s) => { - const nextByPageId = { ...s.pendingAddressBarFocusByPageId } - delete nextByPageId[pageId] - const nextByTabId = { ...s.pendingAddressBarFocusByTabId } - delete nextByTabId[pageId] - return { - pendingAddressBarFocusByPageId: nextByPageId, - pendingAddressBarFocusByTabId: nextByTabId - } - }) - - return true - }, - - updateBrowserTabPageState: (pageId, updates) => get().updateBrowserPageState(pageId, updates), - - updateBrowserPageState: (pageId, updates) => { - set((s) => { - const page = findPage(s.browserPagesByWorkspace, pageId) - if (!page) { - return s - } - const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId) - if (!workspace) { - return s - } - const nextPage = { - ...page, - title: - updates.title === undefined - ? page.title - : normalizeBrowserTitle(updates.title, page.url, page.docLocation), - loading: updates.loading ?? page.loading, - faviconUrl: updates.faviconUrl === undefined ? page.faviconUrl : updates.faviconUrl, - canGoBack: updates.canGoBack ?? page.canGoBack, - canGoForward: updates.canGoForward ?? page.canGoForward, - loadError: updates.loadError === undefined ? page.loadError : updates.loadError - } - const unifiedTabs = s.unifiedTabsByWorktree[workspace.worktreeId] ?? [] - const unifiedIndex = - workspace.activePageId === pageId && updates.title !== undefined - ? unifiedTabs.findIndex( - (entry) => entry.contentType === 'browser' && entry.entityId === workspace.id - ) - : -1 - const unifiedLabelNeedsRepair = - unifiedIndex !== -1 && unifiedTabs[unifiedIndex]?.label !== nextPage.title - const pageStateUnchanged = - nextPage.title === page.title && - nextPage.loading === page.loading && - nextPage.faviconUrl === page.faviconUrl && - nextPage.canGoBack === page.canGoBack && - nextPage.canGoForward === page.canGoForward && - nextPage.loadError === page.loadError - const currentPages = s.browserPagesByWorkspace[workspace.id] ?? [] - const mirroredWorkspace = pageStateUnchanged - ? mirrorWorkspaceFromActivePage(workspace, currentPages) - : null - const workspaceNeedsRepair = - mirroredWorkspace !== null && - !browserWorkspaceMirrorFieldsEqual(workspace, mirroredWorkspace) - if (pageStateUnchanged && !unifiedLabelNeedsRepair && !workspaceNeedsRepair) { - return s - } - if (pageStateUnchanged) { - const nextState: Partial = {} - if (workspaceNeedsRepair && mirroredWorkspace) { - nextState.browserTabsByWorktree = { - ...s.browserTabsByWorktree, - [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( - (tab) => (tab.id === workspace.id ? mirroredWorkspace : tab) - ) - } - } - if (unifiedLabelNeedsRepair) { - nextState.unifiedTabsByWorktree = { - ...s.unifiedTabsByWorktree, - [workspace.worktreeId]: unifiedTabs.map((entry, index) => - index === unifiedIndex ? { ...entry, label: nextPage.title } : entry - ) - } - } - return nextState - } - const nextPages = currentPages.map((entry) => (entry.id === pageId ? nextPage : entry)) - const nextWorkspace = mirrorWorkspaceFromActivePage(workspace, nextPages) - const nextState: Partial = { - browserPagesByWorkspace: { - ...s.browserPagesByWorkspace, - [workspace.id]: nextPages - } - } - if (!browserWorkspaceMirrorFieldsEqual(workspace, nextWorkspace)) { - nextState.browserTabsByWorktree = { - ...s.browserTabsByWorktree, - [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( - (tab) => (tab.id === workspace.id ? nextWorkspace : tab) - ) - } - } - if (workspace.activePageId === pageId && updates.title !== undefined && unifiedIndex !== -1) { - if (unifiedLabelNeedsRepair || unifiedTabs[unifiedIndex]?.label !== nextWorkspace.title) { - nextState.unifiedTabsByWorktree = { - ...s.unifiedTabsByWorktree, - [workspace.worktreeId]: unifiedTabs.map((entry, index) => - index === unifiedIndex ? { ...entry, label: nextWorkspace.title } : entry - ) - } - } - } - return nextState - }) - if (updates.loadError === null) { - get().setBrowserPageCertificateFailure(pageId, null) - } - }, - - setBrowserPageCertificateFailure: (pageId, failure) => { - set((s) => { - const current = s.browserCertificateFailuresByPageId[pageId] - if (failure === null) { - if (!current) { - return s - } - const nextFailures = { ...s.browserCertificateFailuresByPageId } - delete nextFailures[pageId] - return { browserCertificateFailuresByPageId: nextFailures } - } - if (!findPage(s.browserPagesByWorkspace, pageId) || current === failure) { - return s - } - return { - browserCertificateFailuresByPageId: { - ...s.browserCertificateFailuresByPageId, - [pageId]: failure - } - } - }) - }, - - setBrowserTabUrl: (pageId, url) => get().setBrowserPageUrl(pageId, url), - - setBrowserPageUrl: (pageId, url, options) => { - const nextUrl = normalizeUrl(url) - if (nextUrl !== 'about:blank' && nextUrl !== ORCA_BROWSER_BLANK_URL) { - const currentPage = findPage(get().browserPagesByWorkspace, pageId) - if (currentPage) { - get().recordFeatureInteraction?.('browser') - } - } - set((s) => { - const page = findPage(s.browserPagesByWorkspace, pageId) - if (!page) { - return s - } - const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId) - if (!workspace) { - return s - } - // Why a document page keeps its blank url here too: this is the third door onto a page's url, - // and a document's url is blank by construction. A grant committed here would reach - // persistence, the publish boundary and the address bar, exactly as at the other two doors. - const nextPageUrl = page.docLocation ? ORCA_BROWSER_BLANK_URL : nextUrl - // Why: annotations point at DOM coords of the loaded document; a real URL change invalidates those markers. - const shouldClearAnnotations = normalizeUrl(page.url) !== nextPageUrl - const nextPages = (s.browserPagesByWorkspace[workspace.id] ?? []).map((entry) => - entry.id === pageId - ? { - ...entry, - url: nextPageUrl, - title: normalizeBrowserTitle(entry.title, nextPageUrl, entry.docLocation), - // Why not simply true: a document page's guest is inert, so there is nothing to wait - // for and the loading affordance would never clear. - loading: !entry.docLocation, - loadError: options?.preserveLoadError ? entry.loadError : null - } - : entry - ) - const nextWorkspace = mirrorWorkspaceFromActivePage(workspace, nextPages) - const nextBrowserAnnotationsByPageId = shouldClearAnnotations - ? { ...s.browserAnnotationsByPageId } - : s.browserAnnotationsByPageId - if (shouldClearAnnotations) { - delete nextBrowserAnnotationsByPageId[pageId] - } - return { - browserPagesByWorkspace: { - ...s.browserPagesByWorkspace, - [workspace.id]: nextPages - }, - browserTabsByWorktree: { - ...s.browserTabsByWorktree, - [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( - (tab) => (tab.id === workspace.id ? nextWorkspace : tab) - ) - }, - ...(shouldClearAnnotations - ? { browserAnnotationsByPageId: nextBrowserAnnotationsByPageId } - : {}) - } - }) - get().setBrowserPageCertificateFailure(pageId, null) - }, - - setRemoteBrowserPageHandle: (pageId, handle) => { - set((s) => ({ - remoteBrowserPageHandlesByPageId: { - ...s.remoteBrowserPageHandlesByPageId, - [pageId]: handle - } - })) - }, - - removeRemoteBrowserPageHandle: (pageId, remotePageId) => { - let removedHandle: RemoteBrowserPageHandle | null = null - set((s) => { - const current = s.remoteBrowserPageHandlesByPageId[pageId] - if (!current || (remotePageId && current.remotePageId !== remotePageId)) { - return s - } - removedHandle = current - const nextRemoteBrowserPageHandlesByPageId = { - ...s.remoteBrowserPageHandlesByPageId - } - delete nextRemoteBrowserPageHandlesByPageId[pageId] - return { remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId } - }) - return removedHandle - }, - - // viewportPresetId is intentionally page-local (no workspace-layer UI consumer); do NOT add mirrorWorkspaceFromActivePage here. - setBrowserPageViewportPreset: (pageId, viewportPresetId) => - set((s) => { - const page = findPage(s.browserPagesByWorkspace, pageId) - if (!page) { - return s - } - const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId) - if (!workspace) { - return s - } - const nextPages = (s.browserPagesByWorkspace[workspace.id] ?? []).map((entry) => - entry.id === pageId ? { ...entry, viewportPresetId } : entry - ) - return { - browserPagesByWorkspace: { - ...s.browserPagesByWorkspace, - [workspace.id]: nextPages - } - } - }), - - addBrowserPageAnnotation: (annotation) => - set((s) => { - const existing = s.browserAnnotationsByPageId[annotation.browserPageId] ?? [] - const next = [...existing, sanitizeBrowserPageAnnotation(annotation)].slice( - -GRAB_BUDGET.annotationsMaxPerPage - ) - return { - browserAnnotationsByPageId: { - ...s.browserAnnotationsByPageId, - [annotation.browserPageId]: next - } - } - }), - - updateBrowserPageAnnotation: (pageId, annotationId, patch) => - set((s) => { - const existing = s.browserAnnotationsByPageId[pageId] ?? [] - const target = existing.find((annotation) => annotation.id === annotationId) - if (!target) { - return s - } - const updated = sanitizeBrowserPageAnnotation({ ...target, ...patch }) - return { - browserAnnotationsByPageId: { - ...s.browserAnnotationsByPageId, - [pageId]: existing.map((annotation) => - annotation.id === annotationId ? updated : annotation - ) - } - } - }), - - deleteBrowserPageAnnotation: (pageId, annotationId) => - set((s) => { - const existing = s.browserAnnotationsByPageId[pageId] ?? [] - const next = existing.filter((annotation) => annotation.id !== annotationId) - if (next.length === existing.length) { - return s - } - const nextByPageId = { ...s.browserAnnotationsByPageId } - if (next.length > 0) { - nextByPageId[pageId] = next - } else { - delete nextByPageId[pageId] - } - return { browserAnnotationsByPageId: nextByPageId } - }), - - clearBrowserPageAnnotations: (pageId) => - set((s) => { - if (!s.browserAnnotationsByPageId[pageId]?.length) { - return s - } - const nextByPageId = { ...s.browserAnnotationsByPageId } - delete nextByPageId[pageId] - return { browserAnnotationsByPageId: nextByPageId } - }), - - hydrateBrowserSession: (session, options) => { - const persistedTabsByWorktree = session.browserTabsByWorktree ?? {} - const currentState = get() - const validWorktreeIdsForCleanup = buildValidWorktreeIdsForSessionHydration( - currentState, - Object.keys(persistedTabsByWorktree) - ) - validWorktreeIdsForCleanup.add(FLOATING_TERMINAL_WORKTREE_ID) - for (const workspace of currentState.folderWorkspaces) { - validWorktreeIdsForCleanup.add(folderWorkspaceKey(workspace.id)) - } - addAdditionalValidWorkspaceKeys(validWorktreeIdsForCleanup, options) - - // Why: destroy dropped workspaces' webviews before the pure reducer; no-op today (boot registry empty), defends future re-hydration callers. - const droppedWorkspaceIds: string[] = [] - for (const [worktreeId, tabs] of Object.entries(persistedTabsByWorktree)) { - if (!validWorktreeIdsForCleanup.has(worktreeId)) { - for (const tab of tabs) { - droppedWorkspaceIds.push(tab.id) - } - } - } - for (const workspaceId of droppedWorkspaceIds) { - destroyWorkspaceWebviews(currentState.browserPagesByWorkspace, workspaceId) - } - - set((s) => { - const persistedPagesByWorkspace = session.browserPagesByWorkspace ?? {} - const persistedActiveBrowserTabIdByWorktree = session.activeBrowserTabIdByWorktree ?? {} - const persistedActiveTabTypeByWorktree = session.activeTabTypeByWorktree ?? {} - const validWorktreeIds = buildValidWorktreeIdsForSessionHydration( - s, - Object.keys(persistedTabsByWorktree) - ) - validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID) - for (const workspace of s.folderWorkspaces) { - validWorktreeIds.add(folderWorkspaceKey(workspace.id)) - } - addAdditionalValidWorkspaceKeys(validWorktreeIds, options) - - const browserTabsByWorktree: Record = {} - const browserPagesByWorkspace: Record = {} - - for (const [worktreeId, tabs] of Object.entries(persistedTabsByWorktree)) { - if (!validWorktreeIds.has(worktreeId)) { - continue - } - const hydratedTabs: BrowserWorkspace[] = [] - for (const tab of tabs) { - // Salvage can leave an empty page array; hydrate it like a missing array. - const storedPages = persistedPagesByWorkspace[tab.id] - const persistedPages = storedPages?.length - ? storedPages - : [ - { - id: createBrowserUuid(), - workspaceId: tab.id, - worktreeId, - url: normalizeUrl(tab.url), - title: tab.title, - loading: false, - faviconUrl: tab.faviconUrl ?? null, - canGoBack: tab.canGoBack, - canGoForward: tab.canGoForward, - loadError: tab.loadError ?? null, - createdAt: tab.createdAt, - // Why the tab's copy is authority here: this branch runs when the page array was - // salvaged away, and without it a restored document page comes back as a blank - // New Tab under a strip entry still naming the document. - docLocation: tab.docLocation ?? null - } satisfies BrowserPage - ] - const nextPages = persistedPages.map((page) => { - // Why: in-memory hydration callers can bypass the persistence schema's unknown-key stripping. - const { allowWindowClose: _legacyAllowWindowClose, ...persistedPage } = - page as typeof page & { - allowWindowClose?: boolean - } - return { - ...persistedPage, - workspaceId: tab.id, - worktreeId, - // Why re-asserted on restore: the same invariant creation enforces. A session written - // by an older or hand-edited build could carry a grant URL here, and it would name a - // grant that died with the process that minted it. - url: page.docLocation ? ORCA_BROWSER_BLANK_URL : normalizeUrl(page.url), - loading: false, - loadError: page.loadError ?? null - } - }) - browserPagesByWorkspace[tab.id] = nextPages - hydratedTabs.push( - mirrorWorkspaceFromActivePage( - { - ...tab, - activePageId: nextPages.some((page) => page.id === tab.activePageId) - ? (tab.activePageId ?? nextPages[0]?.id ?? null) - : (nextPages[0]?.id ?? null), - pageIds: nextPages.map((page) => page.id) - }, - nextPages - ) - ) - } - if (hydratedTabs.length > 0) { - browserTabsByWorktree[worktreeId] = hydratedTabs - } - } - - const validBrowserTabIds = new Set( - Object.values(browserTabsByWorktree) - .flat() - .map((tab) => tab.id) - ) - - const activeBrowserTabIdByWorktree: Record = {} - for (const [worktreeId, tabs] of Object.entries(browserTabsByWorktree)) { - const persistedTabId = persistedActiveBrowserTabIdByWorktree[worktreeId] - activeBrowserTabIdByWorktree[worktreeId] = - persistedTabId && validBrowserTabIds.has(persistedTabId) - ? persistedTabId - : (tabs[0]?.id ?? null) - } - - const activeWorktreeId = s.activeWorktreeId - const activeBrowserTabId = - activeWorktreeId && activeBrowserTabIdByWorktree[activeWorktreeId] - ? activeBrowserTabIdByWorktree[activeWorktreeId] - : null - - const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree } - for (const worktreeId of validWorktreeIds) { - const hasBrowserTabs = (browserTabsByWorktree[worktreeId] ?? []).length > 0 - if ( - persistedActiveTabTypeByWorktree[worktreeId] === 'browser' && - hasBrowserTabs && - !nextActiveTabTypeByWorktree[worktreeId] - ) { - nextActiveTabTypeByWorktree[worktreeId] = 'browser' - continue - } - if (nextActiveTabTypeByWorktree[worktreeId] === 'browser' && !hasBrowserTabs) { - nextActiveTabTypeByWorktree[worktreeId] = getFallbackTabTypeForWorktree( - worktreeId, - s.openFiles, - s.tabsByWorktree, - browserTabsByWorktree - ) - } - } - - const activeTabType = (() => { - if (!activeWorktreeId) { - return s.activeTabType - } - const restoredTabType = nextActiveTabTypeByWorktree[activeWorktreeId] - if (restoredTabType === 'browser' && activeBrowserTabId) { - return 'browser' - } - if ( - restoredTabType === 'editor' && - s.openFiles.some((file) => file.worktreeId === activeWorktreeId) - ) { - return 'editor' - } - return getFallbackTabTypeForWorktree( - activeWorktreeId, - s.openFiles, - s.tabsByWorktree, - browserTabsByWorktree - ) - })() - - return { - browserTabsByWorktree, - browserPagesByWorkspace, - activeBrowserTabIdByWorktree, - activeBrowserTabId, - activeTabTypeByWorktree: nextActiveTabTypeByWorktree, - activeTabType, - remoteBrowserPageHandlesByPageId: - buildRestoredRemoteBrowserPageHandles(browserPagesByWorkspace), - browserCertificateFailuresByPageId: {}, - browserAnnotationsByPageId: {}, - browserUrlHistory: normalizeBrowserHistoryEntries(session.browserUrlHistory ?? []), - workspaceDocHistory: normalizeWorkspaceDocHistoryEntries(session.workspaceDocHistory ?? []), - // Why restored before the rows are: a close the host never heard must outlive the relaunch - // that also restores the row it closed, or the restore silently wins. - clientHostedBrowserCloseIntentsByEnvironment: - session.clientHostedBrowserCloseIntentsByEnvironment ?? {} - } - }) - - const state = get() - // Why here and not in the startup chain: the seeded handles are the only record that this - // desktop was hosting pages, and the runtime only hands them back once it sees an attach. - void ensureBrowserClientHostsForRestoredPages(state) - for (const [worktreeId, browserTabs] of Object.entries(state.browserTabsByWorktree)) { - for (const bt of browserTabs) { - const exists = (state.unifiedTabsByWorktree[worktreeId] ?? []).some( - (t) => t.contentType === 'browser' && t.entityId === bt.id - ) - if (!exists) { - state.createUnifiedTab(worktreeId, 'browser', { - entityId: bt.id, - label: bt.title, - recordInteraction: false - }) - } - } - } - }, - - switchBrowserTabProfile: (workspaceId, profileId, sessionPartition) => { - set((s) => { - for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) { - const tabIndex = tabs.findIndex((t) => t.id === workspaceId) - if (tabIndex !== -1) { - const updatedTabs = [...tabs] - updatedTabs[tabIndex] = { - ...updatedTabs[tabIndex], - sessionProfileId: profileId, - sessionPartition: sessionPartition ?? null - } - return { - browserTabsByWorktree: { - ...s.browserTabsByWorktree, - [worktreeId]: updatedTabs - } - } - } - } - return {} - }) - }, - - fetchBrowserSessionProfiles: async () => { - const hostId = getBrowserSettingsHostId(get()) - const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) - if (runtimeEnvironmentId) { - try { - const result = await callRuntimeRpc( - { kind: 'environment', environmentId: runtimeEnvironmentId }, - 'browser.profileList', - undefined, - { timeoutMs: 15_000 } - ) - // Why: client-hosted imports never touch the server, so the server's - // records can't carry the "imported from Chrome" badge — this desktop - // remembers what it imported into each environment's jar and overlays it. - const clientImportSources = await window.api.browser - .sessionClientRouteImportSources?.({ environmentId: runtimeEnvironmentId }) - .catch(() => ({})) - const profiles = result.profiles.map((profile) => - !profile.source && clientImportSources?.[profile.id] - ? { ...profile, source: clientImportSources[profile.id] } - : profile - ) - set((s) => profileListByHostUpdate(s, profiles, hostId)) - } catch { - set((s) => profileListByHostUpdate(s, [], hostId)) - } - return - } - try { - const profiles = (await window.api.browser.sessionListProfiles()) as BrowserSessionProfile[] - set((s) => profileListByHostUpdate(s, profiles, hostId)) - } catch { - /* best-effort — stale profile list is preferable to a crash */ - } - }, - - createBrowserSessionProfile: async (scope, label, options) => { - const hostId = getBrowserSettingsHostId(get()) - const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) - if (runtimeEnvironmentId) { - try { - const result = await callRuntimeRpc( - { kind: 'environment', environmentId: runtimeEnvironmentId }, - 'browser.profileCreate', - { scope, label, ...options }, - { timeoutMs: 15_000 } - ) - const profile = result.profile - if (profile) { - set((s) => ({ - ...profileListByHostUpdate( - s, - [...getBrowserProfilesForHost(s, hostId), profile], - hostId - ) - })) - } - return profile - } catch { - return null - } - } - try { - const profile = (await window.api.browser.sessionCreateProfile({ - scope, - label, - ...options - })) as BrowserSessionProfile | null - if (profile) { - set((s) => ({ - ...profileListByHostUpdate(s, [...getBrowserProfilesForHost(s, hostId), profile], hostId) - })) - } - return profile - } catch { - return null - } - }, - - deleteBrowserSessionProfile: async (profileId) => { - const hostId = getBrowserSettingsHostId(get()) - const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) - if (runtimeEnvironmentId) { - try { - const result = await callRuntimeRpc( - { kind: 'environment', environmentId: runtimeEnvironmentId }, - 'browser.profileDelete', - { profileId }, - { timeoutMs: 15_000 } - ) - if (result.deleted) { - set((s) => ({ - ...profileListByHostUpdate( - s, - getBrowserProfilesForHost(s, hostId).filter((profile) => profile.id !== profileId), - hostId - ), - ...(getDefaultBrowserProfileForHost(s, hostId) === profileId - ? { - ...(getBrowserSettingsHostId(s) === hostId - ? { defaultBrowserSessionProfileId: null } - : {}), - defaultBrowserSessionProfileIdByHostId: { - ...s.defaultBrowserSessionProfileIdByHostId, - [hostId]: null - } - } - : {}) - })) - } - return result.deleted - } catch { - return false - } - } - try { - const ok = await window.api.browser.sessionDeleteProfile({ profileId }) - if (ok) { - set((s) => ({ - ...profileListByHostUpdate( - s, - getBrowserProfilesForHost(s, hostId).filter((profile) => profile.id !== profileId), - hostId - ), - ...(getDefaultBrowserProfileForHost(s, hostId) === profileId - ? { - ...(getBrowserSettingsHostId(s) === hostId - ? { defaultBrowserSessionProfileId: null } - : {}), - defaultBrowserSessionProfileIdByHostId: { - ...s.defaultBrowserSessionProfileIdByHostId, - [hostId]: null - } - } - : {}) - })) - } - return ok - } catch { - return false - } - }, - - importCookiesToProfile: async (profileId) => { - const initialState = get() - const hostId = getBrowserSettingsHostId(initialState) - const executionHostLabel = selectExecutionHostDisplayLabel(initialState, hostId) - if (getBrowserSettingsRuntimeEnvironmentId(initialState)) { - const reason = translate( - 'auto.store.slices.browser.remoteCookieImportUnavailable', - 'Manual cookie file import is unavailable while a remote runtime is active.' - ) - set((state) => - browserImportStateForHostUpdate(state, hostId, { - profileId, - status: 'error', - summary: null, - error: reason - }) - ) - return retainCookieImportExecutionHost( - { ok: false as const, reason }, - hostId, - executionHostLabel, - 'client' - ) - } - set((state) => - browserImportStateForHostUpdate(state, hostId, { - profileId, - status: 'importing', - summary: null, - error: null - }) - ) - try { - const result = (await window.api.browser.sessionImportCookies({ - profileId - })) as BrowserCookieImportResult - if (result.ok) { - get().recordFeatureInteraction?.('cookie-import') - set((state) => - browserImportStateForHostUpdate(state, hostId, { - profileId, - status: 'success', - summary: result.summary, - error: null - }) - ) - if (getBrowserSettingsHostId(get()) === hostId) { - await get() - .fetchBrowserSessionProfiles() - .catch(() => {}) - } - } else { - set((state) => - browserImportStateForHostUpdate(state, hostId, { - profileId, - status: result.reason === 'canceled' ? 'idle' : 'error', - summary: null, - error: result.reason === 'canceled' ? null : result.reason - }) - ) - } - return retainCookieImportExecutionHost(result, hostId, executionHostLabel, 'client') - } catch (err) { - const reason = String((err as Error)?.message ?? err) - set((state) => - browserImportStateForHostUpdate(state, hostId, { - profileId, - status: 'error', - summary: null, - error: reason - }) - ) - return retainCookieImportExecutionHost( - { ok: false as const, reason }, - hostId, - executionHostLabel, - 'client' - ) - } - }, - - clearBrowserSessionImportState: () => { - set({ browserSessionImportState: null }) - }, - - detectedBrowsers: [], - detectedBrowsersLoaded: false, - detectedBrowsersHost: null, - - fetchDetectedBrowsers: async () => { - const hostId = getBrowserSettingsHostId(get()) - const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) - if (runtimeEnvironmentId) { - const hostLabel = selectExecutionHostDisplayLabel(get(), hostId) - try { - // Why: the import runs on whichever machine hosts the pages, so the picker must offer that - // machine's browsers -- client-hosted means this desktop, not the (usually headless) remote. - const clientHostBrowsers = await window.api.browser.sessionDetectBrowsersForClientHost({ - environmentId: runtimeEnvironmentId - }) - const browsers = - clientHostBrowsers ?? - ( - await callRuntimeRpc( - { kind: 'environment', environmentId: runtimeEnvironmentId }, - 'browser.profileDetectBrowsers', - undefined, - { timeoutMs: 15_000 } - ) - ).browsers - // Why: retain which machine answered so import menus can say where imports read and store. - const detectedBrowsersHost = { - machine: clientHostBrowsers ? ('client' as const) : ('remote' as const), - hostLabel - } - set((s) => - getBrowserSettingsHostId(s) === hostId - ? { detectedBrowsers: browsers, detectedBrowsersLoaded: true, detectedBrowsersHost } - : {} - ) - } catch { - set((s) => - getBrowserSettingsHostId(s) === hostId - ? { detectedBrowsers: [], detectedBrowsersLoaded: true, detectedBrowsersHost: null } - : {} - ) - } - return - } - if (get().detectedBrowsersLoaded) { - return - } - try { - const browsers = (await window.api.browser.sessionDetectBrowsers()) as { - family: string - label: string - profiles: { name: string; directory: string }[] - selectedProfile: string - }[] - set((s) => - getBrowserSettingsHostId(s) === hostId - ? { detectedBrowsers: browsers, detectedBrowsersLoaded: true, detectedBrowsersHost: null } - : {} - ) - } catch { - /* best-effort — empty list is acceptable fallback */ - set((s) => (getBrowserSettingsHostId(s) === hostId ? { detectedBrowsersLoaded: true } : {})) - } - }, - - importCookiesFromBrowser: async (profileId, browserFamily, browserProfile?) => { - const initialState = get() - const hostId = getBrowserSettingsHostId(initialState) - const executionHostLabel = selectExecutionHostDisplayLabel(initialState, hostId) - const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(initialState) - if (runtimeEnvironmentId) { - set((state) => - browserImportStateForHostUpdate(state, hostId, { - profileId, - status: 'importing', - summary: null, - error: null - }) - ) - // Why: tracked across the try so a failed fallback RPC still reports the machine it ran on. - let ranOnClient = false - try { - // Why: client-hosted pages render on this desktop, so their logins live - // here -- detecting and importing on the headless remote finds nothing. - const clientHostResult = await window.api.browser.sessionImportFromBrowserForClientHost({ - environmentId: runtimeEnvironmentId, - profileId, - browserFamily, - browserProfile - }) - ranOnClient = clientHostResult != null - const result = - clientHostResult ?? - (await callRuntimeRpc( - { kind: 'environment', environmentId: runtimeEnvironmentId }, - 'browser.profileImportFromBrowser', - { profileId, browserFamily, browserProfile, supportsPartitionSkippedCookies: true }, - { timeoutMs: 30_000 } - )) - if (result.ok) { - set((state) => - browserImportStateForHostUpdate(state, hostId, { - profileId, - status: 'success', - summary: result.summary, - error: null - }) - ) - if (getBrowserSettingsHostId(get()) === hostId) { - await get() - .fetchBrowserSessionProfiles() - .catch(() => {}) - } - } else { - set((state) => - browserImportStateForHostUpdate(state, hostId, { - profileId, - status: 'error', - summary: null, - error: result.reason - }) - ) - } - return retainCookieImportExecutionHost( - result, - hostId, - executionHostLabel, - ranOnClient ? 'client' : 'remote' - ) - } catch (err) { - const reason = String((err as Error)?.message ?? err) - set((state) => - browserImportStateForHostUpdate(state, hostId, { - profileId, - status: 'error', - summary: null, - error: reason - }) - ) - return retainCookieImportExecutionHost( - { ok: false as const, reason }, - hostId, - executionHostLabel, - ranOnClient ? 'client' : 'remote' - ) - } - } - set((state) => - browserImportStateForHostUpdate(state, hostId, { - profileId, - status: 'importing', - summary: null, - error: null - }) - ) - try { - const result = (await window.api.browser.sessionImportFromBrowser({ - profileId, - browserFamily, - browserProfile - })) as BrowserCookieImportResult - if (result.ok) { - get().recordFeatureInteraction?.('cookie-import') - set((state) => - browserImportStateForHostUpdate(state, hostId, { - profileId, - status: 'success', - summary: result.summary, - error: null - }) - ) - if (getBrowserSettingsHostId(get()) === hostId) { - await get() - .fetchBrowserSessionProfiles() - .catch(() => {}) - } - } else { - set((state) => - browserImportStateForHostUpdate(state, hostId, { - profileId, - status: 'error', - summary: null, - error: result.reason - }) - ) - } - return retainCookieImportExecutionHost(result, hostId, executionHostLabel, 'client') - } catch (err) { - const reason = String((err as Error)?.message ?? err) - set((state) => - browserImportStateForHostUpdate(state, hostId, { - profileId, - status: 'error', - summary: null, - error: reason - }) - ) - return retainCookieImportExecutionHost( - { ok: false as const, reason }, - hostId, - executionHostLabel, - 'client' - ) - } - }, - - clearDefaultSessionCookies: async () => { - const hostId = getBrowserSettingsHostId(get()) - const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) - if (runtimeEnvironmentId) { - try { - const result = await callRuntimeRpc( - { kind: 'environment', environmentId: runtimeEnvironmentId }, - 'browser.profileClearDefaultCookies', - undefined, - { timeoutMs: 15_000 } - ) - if (result.cleared && getBrowserSettingsHostId(get()) === hostId) { - await get().fetchBrowserSessionProfiles() - } - return result.cleared - } catch { - return false - } - } - try { - const ok = await window.api.browser.sessionClearDefaultCookies() - if (ok && getBrowserSettingsHostId(get()) === hostId) { - get().recordFeatureInteraction?.('cookie-import') - await get().fetchBrowserSessionProfiles() - } - return ok - } catch { - return false - } - }, - - recordWorkspaceDocVisit: (docLocation, title, options) => { - const bump = options?.bump ?? true - set((s) => { - const now = Date.now() - const existing = s.workspaceDocHistory.find((entry) => - browserPageDocLocationsEqual(entry.docLocation, docLocation) - ) - if (!existing && !bump) { - // A title refresh for a document never visited records nothing. - return s - } - const normalizedTitle = normalizeWorkspaceDocHistoryTitle( - title ?? existing?.title, - docLocation - ) - const next: WorkspaceDocHistoryEntry[] = existing - ? s.workspaceDocHistory.map((entry) => - entry === existing - ? { - ...entry, - title: normalizedTitle, - ...(bump ? { lastVisitedAt: now, visitCount: entry.visitCount + 1 } : {}) - } - : entry - ) - : [ - { docLocation, title: normalizedTitle, lastVisitedAt: now, visitCount: 1 }, - ...s.workspaceDocHistory - ] - return { - workspaceDocHistory: - next.length > MAX_WORKSPACE_DOC_HISTORY_ENTRIES - ? normalizeWorkspaceDocHistoryEntries(next) - : next - } - }) - }, - - addBrowserHistoryEntry: (url, title) => { - const safeUrl = redactKagiSessionToken(url) - if (safeUrl === ORCA_BROWSER_BLANK_URL || safeUrl === 'about:blank' || !safeUrl) { - return - } - const normalized = normalizeBrowserHistoryUrl(safeUrl) - set((s) => { - const existing = s.browserUrlHistory.find((entry) => entry.normalizedUrl === normalized) - let next: BrowserHistoryEntry[] = existing - ? s.browserUrlHistory.map((entry) => - entry === existing - ? { ...entry, title, lastVisitedAt: Date.now(), visitCount: entry.visitCount + 1 } - : entry - ) - : [ - { - url: safeUrl, - normalizedUrl: normalized, - title, - lastVisitedAt: Date.now(), - visitCount: 1 - }, - ...s.browserUrlHistory - ] - if (next.length > MAX_BROWSER_HISTORY_ENTRIES) { - next = next - .sort((a, b) => b.lastVisitedAt - a.lastVisitedAt) - .slice(0, MAX_BROWSER_HISTORY_ENTRIES) - } - return { browserUrlHistory: next } - }) - }, - - // One clear for both sources: the dropdown presents them as one history. - clearBrowserHistory: () => set({ browserUrlHistory: [], workspaceDocHistory: [] }) -}) + CreateBrowserTabOptions, + CreateBrowserPageOptions, + SetBrowserPageUrlOptions, + ClosedBrowserWorkspaceSnapshot, + RemoteBrowserPageHandle +} from './browser/browser-slice-contract' +export { createBrowserSlice } from './browser/create-browser-slice' +export { isLocalBrowserPageOwner } from './browser/browser-host-state' +export { sanitizeBrowserPageAnnotation } from './browser/browser-page-annotation' diff --git a/src/renderer/src/store/slices/browser/browser-close-actions.ts b/src/renderer/src/store/slices/browser/browser-close-actions.ts new file mode 100644 index 00000000000..c70b93f6eb2 --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-close-actions.ts @@ -0,0 +1,250 @@ +import type { + BrowserSlice, + BrowserSliceGet, + BrowserSliceSet, + RemoteBrowserPageHandle +} from './browser-slice-contract' +import type { BrowserWorkspace } from '../../../../../shared/browser-workspace-types' +import { findWorkspace } from '../browser-page-records' +import { pickNeighbor } from '../tab-group-state' +import { getRecentlyClosedTabPosition, pushRecentlyClosedTabKind } from '../recently-closed-tabs' +import { getFallbackTabTypeForWorktree, isLocalBrowserPageOwner } from './browser-host-state' +import { closeRemoteBrowserPageInOwningEnvironment } from './browser-remote-close' +import { releaseDocPreviewGrant } from '@/lib/doc-preview-grants' +import { destroyWorkspaceWebviews } from '../browser-webview-cleanup' + +export function createBrowserCloseActions( + set: BrowserSliceSet, + get: BrowserSliceGet +): Pick { + return { + closeBrowserTab: (tabId, options) => { + // Why: a cleanup close unwinds a tab that never finished being created — it owns no host + // page to close and must not enter the reopen stack as if the user had closed something. + const isCleanup = options?.reason === 'cleanup' + let remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = [] + // Why collected rather than released inside the reducer: revoking is main-process work, and a + // grant is the only authority the preview scheme honors — a closed document must stop being + // readable, and it must stop being readable even if the reducer bails out below. + let docPageIdsToRelease: string[] = [] + let activeBrowserWorktreeIdToNotify: string | null = null + set((s) => { + let owningWorktreeId: string | null = null + let closedWorkspace: BrowserWorkspace | null = null + const nextBrowserTabsByWorktree: Record = {} + for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) { + const removedTab = tabs.find((tab) => tab.id === tabId) ?? null + const filtered = tabs.filter((tab) => tab.id !== tabId) + if (filtered.length !== tabs.length) { + owningWorktreeId = worktreeId + closedWorkspace = removedTab + } + if (filtered.length > 0) { + nextBrowserTabsByWorktree[worktreeId] = filtered + } + } + if (!owningWorktreeId || !closedWorkspace) { + return s + } + + const closedPages = s.browserPagesByWorkspace[tabId] ?? [] + const nextBrowserPagesByWorkspace = { ...s.browserPagesByWorkspace } + delete nextBrowserPagesByWorkspace[tabId] + const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } + const nextBrowserCertificateFailuresByPageId = { + ...s.browserCertificateFailuresByPageId + } + for (const page of closedPages) { + delete nextBrowserAnnotationsByPageId[page.id] + delete nextBrowserCertificateFailuresByPageId[page.id] + } + docPageIdsToRelease = closedPages.filter((page) => page.docLocation).map((page) => page.id) + remotePagesToClose = isCleanup + ? [] + : closedPages.flatMap((page) => { + const handle = s.remoteBrowserPageHandlesByPageId[page.id] + return handle ? [{ worktreeId: page.worktreeId, handle }] : [] + }) + const nextRemoteBrowserPageHandlesByPageId = { + ...s.remoteBrowserPageHandlesByPageId + } + for (const page of closedPages) { + delete nextRemoteBrowserPageHandlesByPageId[page.id] + } + + const remainingBrowserTabs = nextBrowserTabsByWorktree[owningWorktreeId] ?? [] + const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree } + if (nextActiveBrowserTabIdByWorktree[owningWorktreeId] === tabId) { + const neighborId = pickNeighbor(s.tabBarOrderByWorktree[owningWorktreeId] ?? [], tabId) + nextActiveBrowserTabIdByWorktree[owningWorktreeId] = + (neighborId && remainingBrowserTabs.some((tab) => tab.id === neighborId) + ? neighborId + : remainingBrowserTabs[0]?.id) ?? null + } + + const nextTabBarOrder = { + ...s.tabBarOrderByWorktree, + [owningWorktreeId]: (s.tabBarOrderByWorktree[owningWorktreeId] ?? []).filter( + (entryId) => entryId !== tabId + ) + } + + const isActiveTabInOwningWorktree = + s.activeWorktreeId === owningWorktreeId && s.activeBrowserTabId === tabId + if (isActiveTabInOwningWorktree) { + activeBrowserWorktreeIdToNotify = owningWorktreeId + } + const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree } + let nextActiveTabType = s.activeTabType + if (remainingBrowserTabs.length === 0) { + const fallbackTabType = getFallbackTabTypeForWorktree( + owningWorktreeId, + s.openFiles, + s.tabsByWorktree + ) + nextActiveTabTypeByWorktree[owningWorktreeId] = fallbackTabType + if (isActiveTabInOwningWorktree && s.activeTabType === 'browser') { + nextActiveTabType = fallbackTabType + } + } + + const nextRecentlyClosedBrowserTabsByWorktree = { ...s.recentlyClosedBrowserTabsByWorktree } + if (!isCleanup) { + const existingSnapshots = nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] ?? [] + const position = getRecentlyClosedTabPosition(s, owningWorktreeId, tabId) + nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] = [ + { + workspace: closedWorkspace, + pages: closedPages, + ...(position ? { position } : {}) + }, + ...existingSnapshots.filter((entry) => entry.workspace.id !== closedWorkspace.id) + ].slice(0, 10) + } + const nextRecentlyClosedTabKindsByWorktree = isCleanup + ? s.recentlyClosedTabKindsByWorktree + : pushRecentlyClosedTabKind( + s.recentlyClosedTabKindsByWorktree, + owningWorktreeId, + 'browser' + ) + + const nextRecentlyClosedBrowserPagesByWorkspace = { + ...s.recentlyClosedBrowserPagesByWorkspace + } + delete nextRecentlyClosedBrowserPagesByWorkspace[tabId] + + const nextPendingAddressBarFocusByPageId = Object.fromEntries( + Object.entries(s.pendingAddressBarFocusByPageId).filter( + ([pageId]) => !closedPages.some((page) => page.id === pageId) + ) + ) + const nextPendingAddressBarFocusByTabId = Object.fromEntries( + Object.entries(s.pendingAddressBarFocusByTabId).filter( + ([focusId]) => focusId !== tabId && !closedPages.some((page) => page.id === focusId) + ) + ) + + return { + browserTabsByWorktree: nextBrowserTabsByWorktree, + browserPagesByWorkspace: nextBrowserPagesByWorkspace, + activeBrowserTabId: + s.activeBrowserTabId === tabId + ? (nextActiveBrowserTabIdByWorktree[owningWorktreeId] ?? null) + : s.activeBrowserTabId, + activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree, + tabBarOrderByWorktree: nextTabBarOrder, + activeTabType: nextActiveTabType, + pendingAddressBarFocusByPageId: nextPendingAddressBarFocusByPageId, + pendingAddressBarFocusByTabId: nextPendingAddressBarFocusByTabId, + activeTabTypeByWorktree: nextActiveTabTypeByWorktree, + recentlyClosedBrowserTabsByWorktree: nextRecentlyClosedBrowserTabsByWorktree, + recentlyClosedTabKindsByWorktree: nextRecentlyClosedTabKindsByWorktree, + recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace, + remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, + browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, + browserAnnotationsByPageId: nextBrowserAnnotationsByPageId + } + }) + + for (const remotePage of remotePagesToClose) { + closeRemoteBrowserPageInOwningEnvironment( + remotePage.worktreeId, + remotePage.handle, + get().recordClientHostedBrowserCloseIntents + ) + } + + for (const docPageId of docPageIdsToRelease) { + releaseDocPreviewGrant(docPageId) + } + + for (const tabs of Object.values(get().unifiedTabsByWorktree)) { + const workspaceItem = tabs.find( + (entry) => entry.contentType === 'browser' && entry.entityId === tabId + ) + if (workspaceItem) { + get().closeUnifiedTab( + workspaceItem.id, + isCleanup ? { preserveWorktreeSelection: true, recordInteraction: false } : undefined + ) + } + } + + // Why: announce the MRU page before guest teardown so bridge fallback cannot choose registration order. + if (activeBrowserWorktreeIdToNotify) { + const state = get() + const activeWorkspaceId = + state.activeBrowserTabIdByWorktree[activeBrowserWorktreeIdToNotify] + const activeWorkspace = activeWorkspaceId + ? findWorkspace(state.browserTabsByWorktree, activeWorkspaceId) + : null + const activePage = activeWorkspace?.activePageId + ? (state.browserPagesByWorkspace[activeWorkspace.id] ?? []).find( + (page) => page.id === activeWorkspace.activePageId + ) + : undefined + if ( + activeWorkspace?.activePageId && + isLocalBrowserPageOwner( + state, + activeBrowserWorktreeIdToNotify, + activePage?.browserRuntimeEnvironmentId + ) && + typeof window !== 'undefined' && + window.api?.browser + ) { + window.api.browser + .notifyActiveTabChanged({ browserPageId: activeWorkspace.activePageId }) + .catch(() => {}) + } + } + }, + + shutdownWorktreeBrowsers: async (worktreeId) => { + const workspaces = get().browserTabsByWorktree[worktreeId] ?? [] + // Why: snapshot before the loop — closeBrowserTab empties the array, so set() below couldn't recompute hadBrowserTabs. + const hadBrowserTabs = workspaces.length > 0 + for (const workspace of workspaces) { + const browserPagesByWorkspace = get().browserPagesByWorkspace + get().closeBrowserTab(workspace.id) + destroyWorkspaceWebviews(browserPagesByWorkspace, workspace.id) + } + set((s) => { + const nextBrowserTabsByWorktree = { ...s.browserTabsByWorktree } + delete nextBrowserTabsByWorktree[worktreeId] + const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree } + delete nextActiveBrowserTabIdByWorktree[worktreeId] + // Why: reset the global browser surface only when the shut-down worktree is the active one AND had tabs. + const shouldResetGlobalBrowser = s.activeWorktreeId === worktreeId && hadBrowserTabs + return { + browserTabsByWorktree: nextBrowserTabsByWorktree, + activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree, + ...(shouldResetGlobalBrowser + ? { activeBrowserTabId: null, activeTabType: 'terminal' as const } + : {}) + } + }) + } + } +} diff --git a/src/renderer/src/store/slices/browser/browser-cookie-import-actions.ts b/src/renderer/src/store/slices/browser/browser-cookie-import-actions.ts new file mode 100644 index 00000000000..99ad8a753e3 --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-cookie-import-actions.ts @@ -0,0 +1,193 @@ +import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract' +import type { BrowserCookieImportResult } from '../../../../../shared/browser-workspace-types' +import type { + BrowserProfileImportFromBrowserResult, + BrowserProfileClearDefaultCookiesResult +} from '../../../../../shared/runtime-types' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { selectExecutionHostDisplayLabel } from '@/lib/execution-host-display-label' +import { + getBrowserSettingsHostId, + getBrowserSettingsRuntimeEnvironmentId, + browserImportStateForHostUpdate, + retainCookieImportExecutionHost +} from './browser-host-state' + +export function createBrowserCookieImportActions( + set: BrowserSliceSet, + get: BrowserSliceGet +): Pick { + return { + importCookiesFromBrowser: async (profileId, browserFamily, browserProfile?) => { + const initialState = get() + const hostId = getBrowserSettingsHostId(initialState) + const executionHostLabel = selectExecutionHostDisplayLabel(initialState, hostId) + const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(initialState) + if (runtimeEnvironmentId) { + set((state) => + browserImportStateForHostUpdate(state, hostId, { + profileId, + status: 'importing', + summary: null, + error: null + }) + ) + // Why: tracked across the try so a failed fallback RPC still reports the machine it ran on. + let ranOnClient = false + try { + // Why: client-hosted pages render on this desktop, so their logins live + // here -- detecting and importing on the headless remote finds nothing. + const clientHostResult = await window.api.browser.sessionImportFromBrowserForClientHost({ + environmentId: runtimeEnvironmentId, + profileId, + browserFamily, + browserProfile + }) + ranOnClient = clientHostResult != null + const result = + clientHostResult ?? + (await callRuntimeRpc( + { kind: 'environment', environmentId: runtimeEnvironmentId }, + 'browser.profileImportFromBrowser', + { profileId, browserFamily, browserProfile, supportsPartitionSkippedCookies: true }, + { timeoutMs: 30_000 } + )) + if (result.ok) { + set((state) => + browserImportStateForHostUpdate(state, hostId, { + profileId, + status: 'success', + summary: result.summary, + error: null + }) + ) + if (getBrowserSettingsHostId(get()) === hostId) { + await get() + .fetchBrowserSessionProfiles() + .catch(() => {}) + } + } else { + set((state) => + browserImportStateForHostUpdate(state, hostId, { + profileId, + status: 'error', + summary: null, + error: result.reason + }) + ) + } + return retainCookieImportExecutionHost( + result, + hostId, + executionHostLabel, + ranOnClient ? 'client' : 'remote' + ) + } catch (err) { + const reason = String((err as Error)?.message ?? err) + set((state) => + browserImportStateForHostUpdate(state, hostId, { + profileId, + status: 'error', + summary: null, + error: reason + }) + ) + return retainCookieImportExecutionHost( + { ok: false as const, reason }, + hostId, + executionHostLabel, + ranOnClient ? 'client' : 'remote' + ) + } + } + set((state) => + browserImportStateForHostUpdate(state, hostId, { + profileId, + status: 'importing', + summary: null, + error: null + }) + ) + try { + const result = (await window.api.browser.sessionImportFromBrowser({ + profileId, + browserFamily, + browserProfile + })) as BrowserCookieImportResult + if (result.ok) { + get().recordFeatureInteraction?.('cookie-import') + set((state) => + browserImportStateForHostUpdate(state, hostId, { + profileId, + status: 'success', + summary: result.summary, + error: null + }) + ) + if (getBrowserSettingsHostId(get()) === hostId) { + await get() + .fetchBrowserSessionProfiles() + .catch(() => {}) + } + } else { + set((state) => + browserImportStateForHostUpdate(state, hostId, { + profileId, + status: 'error', + summary: null, + error: result.reason + }) + ) + } + return retainCookieImportExecutionHost(result, hostId, executionHostLabel, 'client') + } catch (err) { + const reason = String((err as Error)?.message ?? err) + set((state) => + browserImportStateForHostUpdate(state, hostId, { + profileId, + status: 'error', + summary: null, + error: reason + }) + ) + return retainCookieImportExecutionHost( + { ok: false as const, reason }, + hostId, + executionHostLabel, + 'client' + ) + } + }, + + clearDefaultSessionCookies: async () => { + const hostId = getBrowserSettingsHostId(get()) + const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) + if (runtimeEnvironmentId) { + try { + const result = await callRuntimeRpc( + { kind: 'environment', environmentId: runtimeEnvironmentId }, + 'browser.profileClearDefaultCookies', + undefined, + { timeoutMs: 15_000 } + ) + if (result.cleared && getBrowserSettingsHostId(get()) === hostId) { + await get().fetchBrowserSessionProfiles() + } + return result.cleared + } catch { + return false + } + } + try { + const ok = await window.api.browser.sessionClearDefaultCookies() + if (ok && getBrowserSettingsHostId(get()) === hostId) { + get().recordFeatureInteraction?.('cookie-import') + await get().fetchBrowserSessionProfiles() + } + return ok + } catch { + return false + } + } + } +} diff --git a/src/renderer/src/store/slices/browser/browser-history-actions.ts b/src/renderer/src/store/slices/browser/browser-history-actions.ts new file mode 100644 index 00000000000..97ebf8b873b --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-history-actions.ts @@ -0,0 +1,99 @@ +import type { BrowserHistoryEntry } from '../../../../../shared/browser-workspace-types' +import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract' +import type { WorkspaceDocHistoryEntry } from '../../../../../shared/workspace-doc-history' +import { redactKagiSessionToken } from '../../../../../shared/browser-url' +import { + MAX_BROWSER_HISTORY_ENTRIES, + normalizeBrowserHistoryUrl +} from '../../../../../shared/workspace-session-browser-history' +import { + MAX_WORKSPACE_DOC_HISTORY_ENTRIES, + normalizeWorkspaceDocHistoryEntries, + normalizeWorkspaceDocHistoryTitle +} from '../../../../../shared/workspace-doc-history' +import { browserPageDocLocationsEqual } from '../../../../../shared/browser-page-doc-location' +import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/constants' + +export function createBrowserHistoryActions( + set: BrowserSliceSet, + _get: BrowserSliceGet +): Pick< + BrowserSlice, + 'recordWorkspaceDocVisit' | 'addBrowserHistoryEntry' | 'clearBrowserHistory' +> { + return { + recordWorkspaceDocVisit: (docLocation, title, options) => { + const bump = options?.bump ?? true + set((s) => { + const now = Date.now() + const existing = s.workspaceDocHistory.find((entry) => + browserPageDocLocationsEqual(entry.docLocation, docLocation) + ) + if (!existing && !bump) { + // A title refresh for a document never visited records nothing. + return s + } + const normalizedTitle = normalizeWorkspaceDocHistoryTitle( + title ?? existing?.title, + docLocation + ) + const next: WorkspaceDocHistoryEntry[] = existing + ? s.workspaceDocHistory.map((entry) => + entry === existing + ? { + ...entry, + title: normalizedTitle, + ...(bump ? { lastVisitedAt: now, visitCount: entry.visitCount + 1 } : {}) + } + : entry + ) + : [ + { docLocation, title: normalizedTitle, lastVisitedAt: now, visitCount: 1 }, + ...s.workspaceDocHistory + ] + return { + workspaceDocHistory: + next.length > MAX_WORKSPACE_DOC_HISTORY_ENTRIES + ? normalizeWorkspaceDocHistoryEntries(next) + : next + } + }) + }, + + addBrowserHistoryEntry: (url, title) => { + const safeUrl = redactKagiSessionToken(url) + if (safeUrl === ORCA_BROWSER_BLANK_URL || safeUrl === 'about:blank' || !safeUrl) { + return + } + const normalized = normalizeBrowserHistoryUrl(safeUrl) + set((s) => { + const existing = s.browserUrlHistory.find((entry) => entry.normalizedUrl === normalized) + let next: BrowserHistoryEntry[] = existing + ? s.browserUrlHistory.map((entry) => + entry === existing + ? { ...entry, title, lastVisitedAt: Date.now(), visitCount: entry.visitCount + 1 } + : entry + ) + : [ + { + url: safeUrl, + normalizedUrl: normalized, + title, + lastVisitedAt: Date.now(), + visitCount: 1 + }, + ...s.browserUrlHistory + ] + if (next.length > MAX_BROWSER_HISTORY_ENTRIES) { + next = next + .sort((a, b) => b.lastVisitedAt - a.lastVisitedAt) + .slice(0, MAX_BROWSER_HISTORY_ENTRIES) + } + return { browserUrlHistory: next } + }) + }, + + // One clear for both sources: the dropdown presents them as one history. + clearBrowserHistory: () => set({ browserUrlHistory: [], workspaceDocHistory: [] }) + } +} diff --git a/src/renderer/src/store/slices/browser/browser-host-actions.ts b/src/renderer/src/store/slices/browser/browser-host-actions.ts new file mode 100644 index 00000000000..2977717d38c --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-host-actions.ts @@ -0,0 +1,70 @@ +import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract' +import { + clearClientHostedBrowserCloseIntents, + recordClientHostedBrowserCloseIntents +} from '@/runtime/client-hosted-browser-close-intents' +import { getBrowserSettingsHostId } from './browser-host-state' +import { parseExecutionHostId } from '../../../../../shared/execution-host' + +export function createBrowserHostActions( + set: BrowserSliceSet, + get: BrowserSliceGet +): Pick< + BrowserSlice, + | 'recordClientHostedBrowserCloseIntents' + | 'clearClientHostedBrowserCloseIntents' + | 'setBrowserSessionHostId' + | 'setDefaultBrowserSessionProfileId' +> { + return { + recordClientHostedBrowserCloseIntents: (closes) => { + set((s) => { + const next = recordClientHostedBrowserCloseIntents( + s.clientHostedBrowserCloseIntentsByEnvironment, + closes, + Date.now() + ) + return next ? { clientHostedBrowserCloseIntentsByEnvironment: next } : {} + }) + }, + + clearClientHostedBrowserCloseIntents: (environmentId, browserPageIds) => { + set((s) => { + const next = clearClientHostedBrowserCloseIntents( + s.clientHostedBrowserCloseIntentsByEnvironment, + { environmentId, browserPageIds, now: Date.now() } + ) + return next ? { clientHostedBrowserCloseIntentsByEnvironment: next } : {} + }) + }, + + setBrowserSessionHostId: async (hostId) => { + const parsed = parseExecutionHostId(hostId) + if (parsed?.kind !== 'local' && parsed?.kind !== 'runtime') { + return + } + const nextHostId = parsed.id + set((s) => ({ + browserSessionHostIdOverride: nextHostId, + browserSessionProfiles: s.browserSessionProfilesByHostId[nextHostId] ?? [], + defaultBrowserSessionProfileId: + s.defaultBrowserSessionProfileIdByHostId[nextHostId] ?? null, + browserSessionImportState: null, + detectedBrowsers: [], + detectedBrowsersLoaded: false, + detectedBrowsersHost: null + })) + await Promise.all([get().fetchBrowserSessionProfiles(), get().fetchDetectedBrowsers()]) + }, + + setDefaultBrowserSessionProfileId: (profileId) => { + set((s) => ({ + defaultBrowserSessionProfileId: profileId, + defaultBrowserSessionProfileIdByHostId: { + ...s.defaultBrowserSessionProfileIdByHostId, + [getBrowserSettingsHostId(s)]: profileId + } + })) + } + } +} diff --git a/src/renderer/src/store/slices/browser/browser-host-state.ts b/src/renderer/src/store/slices/browser/browser-host-state.ts new file mode 100644 index 00000000000..1b4b3a70e9a --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-host-state.ts @@ -0,0 +1,175 @@ +import type { AppState } from '../../types' +import type { + BrowserCookieImportResult, + BrowserPage, + BrowserSessionProfile +} from '../../../../../shared/browser-workspace-types' +import { + getSettingsFocusedExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../../../shared/execution-host' +import type { + BrowserSlice, + BrowserCookieImportExecutionResult, + RemoteBrowserPageHandle +} from './browser-slice-contract' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' + +/** Rebuild the remote page handles a restored session implies. No placement is seeded: the + * persisted generations belong to the host lease that died with the last run, and the first host + * snapshot is what supplies the live one. + * + * Client-hosted rows only. A server-hosted page lives on the runtime, which may have restarted or + * been redeployed while this desktop was closed; a seeded handle sends its pane down the adopt + * branch, and the browser_tab_not_found that comes back deletes the row. With no handle the pane + * creates a fresh page at the URL the row persisted, which is what the user left behind. */ +export function buildRestoredRemoteBrowserPageHandles( + browserPagesByWorkspace: Record +): Record { + const handles: Record = {} + for (const pages of Object.values(browserPagesByWorkspace)) { + for (const page of pages) { + if ( + !page.browserRuntimeEnvironmentId || + !page.remoteBrowserPageId || + !page.remoteBrowserPageClientHosted + ) { + continue + } + handles[page.id] = { + environmentId: page.browserRuntimeEnvironmentId, + remotePageId: page.remoteBrowserPageId, + restoredFromSession: true, + restoredClientHosted: true + } + } + } + return handles +} + +export function retainCookieImportExecutionHost( + result: BrowserCookieImportResult, + executionHostId: ExecutionHostId, + executionHostLabel: string, + executionMachine: 'client' | 'remote' +): BrowserCookieImportExecutionResult { + return { + ...result, + executionHostId, + executionHostLabel, + executionMachine, + executionRemoteEnvironment: parseExecutionHostId(executionHostId)?.kind === 'runtime' + } +} + +export function getBrowserSettingsHostId( + state: Pick +): ExecutionHostId { + return state.browserSessionHostIdOverride ?? getSettingsFocusedExecutionHostId(state.settings) +} + +export function getBrowserSettingsRuntimeEnvironmentId( + state: Pick +): string | null { + const parsed = parseExecutionHostId(getBrowserSettingsHostId(state)) + return parsed?.kind === 'runtime' ? parsed.environmentId : null +} + +export function getBrowserWorktreeHostId(state: AppState, worktreeId: string): ExecutionHostId { + return getExecutionHostIdForWorktree(state, worktreeId) +} + +export function getBrowserSessionProfileHostId( + state: AppState, + worktreeId: string, + browserRuntimeEnvironmentId: string | null | undefined +): ExecutionHostId { + if (browserRuntimeEnvironmentId === null) { + return LOCAL_EXECUTION_HOST_ID + } + if (browserRuntimeEnvironmentId !== undefined) { + const runtimeEnvironmentId = browserRuntimeEnvironmentId.trim() + return runtimeEnvironmentId + ? toRuntimeExecutionHostId(runtimeEnvironmentId) + : LOCAL_EXECUTION_HOST_ID + } + return getBrowserWorktreeHostId(state, worktreeId) +} + +export function isLocalBrowserPageOwner( + state: AppState, + worktreeId: string, + browserRuntimeEnvironmentId: string | null | undefined +): boolean { + return ( + parseExecutionHostId( + getBrowserSessionProfileHostId(state, worktreeId, browserRuntimeEnvironmentId) + )?.kind !== 'runtime' + ) +} + +export function profileListByHostUpdate( + state: Pick< + AppState, + 'browserSessionHostIdOverride' | 'browserSessionProfilesByHostId' | 'settings' + >, + profiles: BrowserSessionProfile[], + hostId: ExecutionHostId = getBrowserSettingsHostId(state) +): Partial { + return { + ...(getBrowserSettingsHostId(state) === hostId ? { browserSessionProfiles: profiles } : {}), + browserSessionProfilesByHostId: { + ...state.browserSessionProfilesByHostId, + [hostId]: profiles + } + } +} + +export function getBrowserProfilesForHost( + state: AppState, + hostId: ExecutionHostId +): BrowserSessionProfile[] { + return ( + state.browserSessionProfilesByHostId[hostId] ?? + (getBrowserSettingsHostId(state) === hostId ? state.browserSessionProfiles : []) + ) +} + +export function getDefaultBrowserProfileForHost( + state: AppState, + hostId: ExecutionHostId +): string | null { + return ( + state.defaultBrowserSessionProfileIdByHostId[hostId] ?? + (getBrowserSettingsHostId(state) === hostId ? state.defaultBrowserSessionProfileId : null) + ) +} + +export function browserImportStateForHostUpdate( + state: AppState, + hostId: ExecutionHostId, + browserSessionImportState: BrowserSlice['browserSessionImportState'] +): Partial { + return getBrowserSettingsHostId(state) === hostId ? { browserSessionImportState } : {} +} + +export function getFallbackTabTypeForWorktree( + worktreeId: string, + openFiles: AppState['openFiles'], + terminalTabsByWorktree: AppState['tabsByWorktree'], + browserTabsByWorktree?: AppState['browserTabsByWorktree'] +): AppState['activeTabType'] { + if (openFiles.some((file) => file.worktreeId === worktreeId)) { + return 'editor' + } + if ((browserTabsByWorktree?.[worktreeId] ?? []).length > 0) { + return 'browser' + } + if ((terminalTabsByWorktree[worktreeId] ?? []).length > 0) { + return 'terminal' + } + return 'terminal' +} diff --git a/src/renderer/src/store/slices/browser/browser-hydration-actions.ts b/src/renderer/src/store/slices/browser/browser-hydration-actions.ts new file mode 100644 index 00000000000..a2ed3f5bbb1 --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-hydration-actions.ts @@ -0,0 +1,264 @@ +import type { BrowserPage, BrowserWorkspace } from '../../../../../shared/browser-workspace-types' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { + FLOATING_TERMINAL_WORKTREE_ID, + ORCA_BROWSER_BLANK_URL +} from '../../../../../shared/constants' +import { folderWorkspaceKey } from '../../../../../shared/workspace-scope' +import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract' +import { addAdditionalValidWorkspaceKeys } from '@/lib/workspace-session-hydration-keys' +import { buildValidWorktreeIdsForSessionHydration } from '../degraded-repo-worktree-validity' +import { destroyWorkspaceWebviews } from '../browser-webview-cleanup' +import { mirrorWorkspaceFromActivePage, normalizeUrl } from '../browser-page-records' +import { + buildRestoredRemoteBrowserPageHandles, + getFallbackTabTypeForWorktree +} from './browser-host-state' +import { ensureBrowserClientHostsForRestoredPages } from '@/runtime/restored-client-hosted-browser-host-attach' +import { normalizeBrowserHistoryEntries } from '../../../../../shared/workspace-session-browser-history' +import { normalizeWorkspaceDocHistoryEntries } from '../../../../../shared/workspace-doc-history' + +export function createBrowserHydrationActions( + set: BrowserSliceSet, + get: BrowserSliceGet +): Pick { + return { + hydrateBrowserSession: (session, options) => { + const persistedTabsByWorktree = session.browserTabsByWorktree ?? {} + const currentState = get() + const validWorktreeIdsForCleanup = buildValidWorktreeIdsForSessionHydration( + currentState, + Object.keys(persistedTabsByWorktree) + ) + validWorktreeIdsForCleanup.add(FLOATING_TERMINAL_WORKTREE_ID) + for (const workspace of currentState.folderWorkspaces) { + validWorktreeIdsForCleanup.add(folderWorkspaceKey(workspace.id)) + } + addAdditionalValidWorkspaceKeys(validWorktreeIdsForCleanup, options) + + // Why: destroy dropped workspaces' webviews before the pure reducer; no-op today (boot registry empty), defends future re-hydration callers. + const droppedWorkspaceIds: string[] = [] + for (const [worktreeId, tabs] of Object.entries(persistedTabsByWorktree)) { + if (!validWorktreeIdsForCleanup.has(worktreeId)) { + for (const tab of tabs) { + droppedWorkspaceIds.push(tab.id) + } + } + } + for (const workspaceId of droppedWorkspaceIds) { + destroyWorkspaceWebviews(currentState.browserPagesByWorkspace, workspaceId) + } + + set((s) => { + const persistedPagesByWorkspace = session.browserPagesByWorkspace ?? {} + const persistedActiveBrowserTabIdByWorktree = session.activeBrowserTabIdByWorktree ?? {} + const persistedActiveTabTypeByWorktree = session.activeTabTypeByWorktree ?? {} + const validWorktreeIds = buildValidWorktreeIdsForSessionHydration( + s, + Object.keys(persistedTabsByWorktree) + ) + validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID) + for (const workspace of s.folderWorkspaces) { + validWorktreeIds.add(folderWorkspaceKey(workspace.id)) + } + addAdditionalValidWorkspaceKeys(validWorktreeIds, options) + + const browserTabsByWorktree: Record = {} + const browserPagesByWorkspace: Record = {} + + for (const [worktreeId, tabs] of Object.entries(persistedTabsByWorktree)) { + if (!validWorktreeIds.has(worktreeId)) { + continue + } + const hydratedTabs: BrowserWorkspace[] = [] + for (const tab of tabs) { + // Salvage can leave an empty page array; hydrate it like a missing array. + const storedPages = persistedPagesByWorkspace[tab.id] + const persistedPages = storedPages?.length + ? storedPages + : [ + { + id: createBrowserUuid(), + workspaceId: tab.id, + worktreeId, + url: normalizeUrl(tab.url), + title: tab.title, + loading: false, + faviconUrl: tab.faviconUrl ?? null, + canGoBack: tab.canGoBack, + canGoForward: tab.canGoForward, + loadError: tab.loadError ?? null, + createdAt: tab.createdAt, + // Why the tab's copy is authority here: this branch runs when the page array was + // salvaged away, and without it a restored document page comes back as a blank + // New Tab under a strip entry still naming the document. + docLocation: tab.docLocation ?? null + } satisfies BrowserPage + ] + const nextPages = persistedPages.map((page) => { + // Why: in-memory hydration callers can bypass the persistence schema's unknown-key stripping. + const { allowWindowClose: _legacyAllowWindowClose, ...persistedPage } = + page as typeof page & { + allowWindowClose?: boolean + } + return { + ...persistedPage, + workspaceId: tab.id, + worktreeId, + // Why re-asserted on restore: the same invariant creation enforces. A session written + // by an older or hand-edited build could carry a grant URL here, and it would name a + // grant that died with the process that minted it. + url: page.docLocation ? ORCA_BROWSER_BLANK_URL : normalizeUrl(page.url), + loading: false, + loadError: page.loadError ?? null + } + }) + browserPagesByWorkspace[tab.id] = nextPages + hydratedTabs.push( + mirrorWorkspaceFromActivePage( + { + ...tab, + activePageId: nextPages.some((page) => page.id === tab.activePageId) + ? (tab.activePageId ?? nextPages[0]?.id ?? null) + : (nextPages[0]?.id ?? null), + pageIds: nextPages.map((page) => page.id) + }, + nextPages + ) + ) + } + if (hydratedTabs.length > 0) { + browserTabsByWorktree[worktreeId] = hydratedTabs + } + } + + const validBrowserTabIds = new Set( + Object.values(browserTabsByWorktree) + .flat() + .map((tab) => tab.id) + ) + + const activeBrowserTabIdByWorktree: Record = {} + for (const [worktreeId, tabs] of Object.entries(browserTabsByWorktree)) { + const persistedTabId = persistedActiveBrowserTabIdByWorktree[worktreeId] + activeBrowserTabIdByWorktree[worktreeId] = + persistedTabId && validBrowserTabIds.has(persistedTabId) + ? persistedTabId + : (tabs[0]?.id ?? null) + } + + const activeWorktreeId = s.activeWorktreeId + const activeBrowserTabId = + activeWorktreeId && activeBrowserTabIdByWorktree[activeWorktreeId] + ? activeBrowserTabIdByWorktree[activeWorktreeId] + : null + + const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree } + for (const worktreeId of validWorktreeIds) { + const hasBrowserTabs = (browserTabsByWorktree[worktreeId] ?? []).length > 0 + if ( + persistedActiveTabTypeByWorktree[worktreeId] === 'browser' && + hasBrowserTabs && + !nextActiveTabTypeByWorktree[worktreeId] + ) { + nextActiveTabTypeByWorktree[worktreeId] = 'browser' + continue + } + if (nextActiveTabTypeByWorktree[worktreeId] === 'browser' && !hasBrowserTabs) { + nextActiveTabTypeByWorktree[worktreeId] = getFallbackTabTypeForWorktree( + worktreeId, + s.openFiles, + s.tabsByWorktree, + browserTabsByWorktree + ) + } + } + + const activeTabType = (() => { + if (!activeWorktreeId) { + return s.activeTabType + } + const restoredTabType = nextActiveTabTypeByWorktree[activeWorktreeId] + if (restoredTabType === 'browser' && activeBrowserTabId) { + return 'browser' + } + if ( + restoredTabType === 'editor' && + s.openFiles.some((file) => file.worktreeId === activeWorktreeId) + ) { + return 'editor' + } + return getFallbackTabTypeForWorktree( + activeWorktreeId, + s.openFiles, + s.tabsByWorktree, + browserTabsByWorktree + ) + })() + + return { + browserTabsByWorktree, + browserPagesByWorkspace, + activeBrowserTabIdByWorktree, + activeBrowserTabId, + activeTabTypeByWorktree: nextActiveTabTypeByWorktree, + activeTabType, + remoteBrowserPageHandlesByPageId: + buildRestoredRemoteBrowserPageHandles(browserPagesByWorkspace), + browserCertificateFailuresByPageId: {}, + browserAnnotationsByPageId: {}, + browserUrlHistory: normalizeBrowserHistoryEntries(session.browserUrlHistory ?? []), + workspaceDocHistory: normalizeWorkspaceDocHistoryEntries( + session.workspaceDocHistory ?? [] + ), + // Why restored before the rows are: a close the host never heard must outlive the relaunch + // that also restores the row it closed, or the restore silently wins. + clientHostedBrowserCloseIntentsByEnvironment: + session.clientHostedBrowserCloseIntentsByEnvironment ?? {} + } + }) + + const state = get() + // Why here and not in the startup chain: the seeded handles are the only record that this + // desktop was hosting pages, and the runtime only hands them back once it sees an attach. + void ensureBrowserClientHostsForRestoredPages(state) + for (const [worktreeId, browserTabs] of Object.entries(state.browserTabsByWorktree)) { + for (const bt of browserTabs) { + const exists = (state.unifiedTabsByWorktree[worktreeId] ?? []).some( + (t) => t.contentType === 'browser' && t.entityId === bt.id + ) + if (!exists) { + state.createUnifiedTab(worktreeId, 'browser', { + entityId: bt.id, + label: bt.title, + recordInteraction: false + }) + } + } + } + }, + + switchBrowserTabProfile: (workspaceId, profileId, sessionPartition) => { + set((s) => { + for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) { + const tabIndex = tabs.findIndex((t) => t.id === workspaceId) + if (tabIndex !== -1) { + const updatedTabs = [...tabs] + updatedTabs[tabIndex] = { + ...updatedTabs[tabIndex], + sessionProfileId: profileId, + sessionPartition: sessionPartition ?? null + } + return { + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [worktreeId]: updatedTabs + } + } + } + } + return {} + }) + } + } +} diff --git a/src/renderer/src/store/slices/browser/browser-page-annotation.ts b/src/renderer/src/store/slices/browser/browser-page-annotation.ts new file mode 100644 index 00000000000..c8be505ae2d --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-page-annotation.ts @@ -0,0 +1,18 @@ +import { GRAB_BUDGET, type BrowserPageAnnotation } from '../../../../../shared/browser-grab-types' + +export function sanitizeBrowserPageAnnotation( + annotation: BrowserPageAnnotation +): BrowserPageAnnotation { + return { + ...annotation, + comment: + annotation.comment.length > GRAB_BUDGET.annotationCommentMaxLength + ? annotation.comment.slice(0, GRAB_BUDGET.annotationCommentMaxLength) + : annotation.comment, + payload: { + ...annotation.payload, + // Why: annotations persist to disk; null the transient screenshot to avoid retaining megabytes per note. + screenshot: null + } + } +} diff --git a/src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts b/src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts new file mode 100644 index 00000000000..53156c04dd1 --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts @@ -0,0 +1,212 @@ +import type { + BrowserSlice, + BrowserSliceGet, + BrowserSliceSet, + RemoteBrowserPageHandle +} from './browser-slice-contract' +import type { BrowserPage } from '../../../../../shared/browser-workspace-types' +import { planBrowserPageConversion } from '../browser-page-conversion' +import { findPage, findWorkspace, mirrorWorkspaceFromActivePage } from '../browser-page-records' +import { assertManagedBrowserMaterializationAllowed } from '@/lib/client-creation-action-policy' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { closeRemoteBrowserPageInOwningEnvironment } from './browser-remote-close' +import { releaseDocPreviewGrant } from '@/lib/doc-preview-grants' +import { isLocalBrowserPageOwner } from './browser-host-state' + +export function createBrowserPageConversionActions( + set: BrowserSliceSet, + get: BrowserSliceGet +): Pick { + return { + convertBrowserPage: (pageId, target, options) => { + // Why the same assert as createBrowserTab: a conversion materializes a browser surface the + // same way a creation does, and a paired web client that cannot host one must refuse here too. + // Ownership is resolved ONCE, on the plan's own terms — property-present-undefined means + // worktree-inferred — so the assert and the plan cannot disagree about what is being built. + const oldPageForOwnership = findPage(get().browserPagesByWorkspace, pageId) + const declaredOwnership = + target.kind === 'workspace-doc' + ? null + : 'browserRuntimeEnvironmentId' in target + ? target.browserRuntimeEnvironmentId + : (oldPageForOwnership?.browserRuntimeEnvironmentId ?? null) + assertManagedBrowserMaterializationAllowed( + get(), + declaredOwnership !== undefined + ? declaredOwnership + : oldPageForOwnership + ? (getRuntimeEnvironmentIdForWorktree(get(), oldPageForOwnership.worktreeId) ?? null) + : null + ) + let converted: BrowserPage | null = null + let docPageIdToRelease: string | null = null + let remotePageToClose: { worktreeId: string; handle: RemoteBrowserPageHandle } | null = null + set((s) => { + const plan = planBrowserPageConversion(s, pageId, target, options) + if (!plan) { + return s + } + converted = plan.newPage + // Why collected rather than released inside the reducer: revoking is main-process work, and + // it must happen exactly once even if a later set() retries the reducer. + if (plan.oldPage.docLocation) { + docPageIdToRelease = plan.oldPage.id + } + const remoteHandle = s.remoteBrowserPageHandlesByPageId[plan.oldPage.id] + if (remoteHandle) { + remotePageToClose = { worktreeId: plan.oldPage.worktreeId, handle: remoteHandle } + } + const nextRemoteBrowserPageHandlesByPageId = { ...s.remoteBrowserPageHandlesByPageId } + delete nextRemoteBrowserPageHandlesByPageId[plan.oldPage.id] + const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } + delete nextBrowserAnnotationsByPageId[plan.oldPage.id] + const nextBrowserCertificateFailuresByPageId = { ...s.browserCertificateFailuresByPageId } + delete nextBrowserCertificateFailuresByPageId[plan.oldPage.id] + return { + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [plan.workspace.id]: plan.nextPages + }, + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [plan.workspace.worktreeId]: ( + s.browserTabsByWorktree[plan.workspace.worktreeId] ?? [] + ).map((tab) => (tab.id === plan.workspace.id ? plan.nextWorkspace : tab)) + }, + pendingAddressBarFocusByPageId: Object.fromEntries( + Object.entries(s.pendingAddressBarFocusByPageId).filter( + ([pendingPageId]) => pendingPageId !== plan.oldPage.id + ) + ), + pendingAddressBarFocusByTabId: Object.fromEntries( + Object.entries(s.pendingAddressBarFocusByTabId).filter( + ([pendingPageId]) => pendingPageId !== plan.oldPage.id + ) + ), + remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, + browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, + browserAnnotationsByPageId: nextBrowserAnnotationsByPageId + } + }) + // Why the casts: the assignments happen inside set()'s callback, which TS's flow analysis does + // not track, so the initializers' null narrowing would otherwise read these as never. + const newPage = converted as BrowserPage | null + const remoteClose = remotePageToClose as { + worktreeId: string + handle: RemoteBrowserPageHandle + } | null + if (!newPage) { + return null + } + if (remoteClose) { + closeRemoteBrowserPageInOwningEnvironment( + remoteClose.worktreeId, + remoteClose.handle, + get().recordClientHostedBrowserCloseIntents + ) + } + // Why after the reducer: a closed document must stop being readable, and the grant is the only + // authority the preview scheme honors — but the store row has to stop naming it first. + if (docPageIdToRelease) { + releaseDocPreviewGrant(docPageIdToRelease) + } + const workspaceAfter = findWorkspace(get().browserTabsByWorktree, newPage.workspaceId) + if (workspaceAfter?.activePageId === newPage.id) { + const item = Object.values(get().unifiedTabsByWorktree) + .flat() + .find( + (entry) => entry.contentType === 'browser' && entry.entityId === newPage.workspaceId + ) + if (item) { + get().setTabLabel(item.id, newPage.title) + } + } + return newPage + }, + + reopenClosedBrowserPage: (workspaceId) => { + // Why: read and pop atomically inside set() so two rapid Cmd+Shift+T presses can't both restore the same page (TOCTOU). + let pageToRestore: BrowserPage | undefined + + set((s) => { + const recentlyClosed = s.recentlyClosedBrowserPagesByWorkspace[workspaceId] ?? [] + pageToRestore = recentlyClosed[0] + if (!pageToRestore) { + return s + } + return { + recentlyClosedBrowserPagesByWorkspace: { + ...s.recentlyClosedBrowserPagesByWorkspace, + [workspaceId]: recentlyClosed.slice(1) + } + } + }) + + if (!pageToRestore) { + return null + } + + return get().createBrowserPage(workspaceId, pageToRestore.url, { + title: pageToRestore.title, + activate: true, + browserRuntimeEnvironmentId: pageToRestore.browserRuntimeEnvironmentId, + ...(pageToRestore.docLocation ? { docLocation: pageToRestore.docLocation } : {}) + }) + }, + + setActiveBrowserPage: (workspaceId, pageId) => { + set((s) => { + const workspace = findWorkspace(s.browserTabsByWorktree, workspaceId) + if (!workspace) { + return s + } + const pages = s.browserPagesByWorkspace[workspaceId] ?? [] + if (!pages.some((page) => page.id === pageId)) { + return s + } + const nextWorkspace = mirrorWorkspaceFromActivePage( + { + ...workspace, + activePageId: pageId + }, + pages + ) + return { + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( + (tab) => (tab.id === workspaceId ? nextWorkspace : tab) + ) + } + } + }) + + // Why: switching the active page changes which guest webContents the CDP bridge targets for agent commands. + const activePage = (get().browserPagesByWorkspace[workspaceId] ?? []).find( + (page) => page.id === pageId + ) + const workspace = findWorkspace(get().browserTabsByWorktree, workspaceId) + if ( + workspace && + isLocalBrowserPageOwner( + get(), + workspace.worktreeId, + activePage?.browserRuntimeEnvironmentId + ) && + typeof window !== 'undefined' && + window.api?.browser + ) { + window.api.browser.notifyActiveTabChanged({ browserPageId: pageId }).catch(() => {}) + } + if (!workspace) { + return + } + const item = Object.values(get().unifiedTabsByWorktree) + .flat() + .find((entry) => entry.contentType === 'browser' && entry.entityId === workspaceId) + if (item) { + get().setTabLabel(item.id, workspace.title) + } + } + } +} diff --git a/src/renderer/src/store/slices/browser/browser-page-create-actions.ts b/src/renderer/src/store/slices/browser/browser-page-create-actions.ts new file mode 100644 index 00000000000..7b15902ddee --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-page-create-actions.ts @@ -0,0 +1,202 @@ +import type { + BrowserSlice, + BrowserSliceGet, + BrowserSliceSet, + RemoteBrowserPageHandle +} from './browser-slice-contract' +import { + buildBrowserPage, + findPage, + findWorkspace, + mirrorWorkspaceFromActivePage +} from '../browser-page-records' +import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/constants' +import { closeRemoteBrowserPageInOwningEnvironment } from './browser-remote-close' +import { releaseDocPreviewGrant } from '@/lib/doc-preview-grants' + +export function createBrowserPageCreateActions( + set: BrowserSliceSet, + get: BrowserSliceGet +): Pick { + return { + createBrowserPage: (workspaceId, url, options) => { + const workspace = findWorkspace(get().browserTabsByWorktree, workspaceId) + if (!workspace) { + return null + } + const page = buildBrowserPage( + workspaceId, + workspace.worktreeId, + url, + options?.title, + options?.browserRuntimeEnvironmentId, + undefined, + options?.docLocation + ) + + set((s) => { + const pages = s.browserPagesByWorkspace[workspaceId] ?? [] + const shouldActivate = options?.activate ?? true + const nextPages = [...pages, page] + const nextWorkspace = mirrorWorkspaceFromActivePage( + { + ...workspace, + activePageId: shouldActivate ? page.id : (workspace.activePageId ?? page.id), + pageIds: nextPages.map((entry) => entry.id) + }, + nextPages + ) + const shouldUpdateGlobalActiveSurface = + shouldActivate && + s.activeWorktreeId === workspace.worktreeId && + s.activeBrowserTabIdByWorktree[workspace.worktreeId] === workspaceId + const shouldFocusAddressBar = + shouldUpdateGlobalActiveSurface && + !page.docLocation && + (page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL) + + return { + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [workspaceId]: nextPages + }, + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( + (tab) => (tab.id === workspaceId ? nextWorkspace : tab) + ) + }, + pendingAddressBarFocusByPageId: shouldFocusAddressBar + ? { + ...s.pendingAddressBarFocusByPageId, + [page.id]: true + } + : s.pendingAddressBarFocusByPageId, + pendingAddressBarFocusByTabId: shouldFocusAddressBar + ? { + ...s.pendingAddressBarFocusByTabId, + [page.id]: true + } + : s.pendingAddressBarFocusByTabId + } + }) + + const nextWorkspace = findWorkspace(get().browserTabsByWorktree, workspaceId) + if (nextWorkspace?.activePageId === page.id) { + const item = Object.values(get().unifiedTabsByWorktree) + .flat() + .find((entry) => entry.contentType === 'browser' && entry.entityId === workspaceId) + if (item) { + get().setTabLabel(item.id, page.title) + } + } + return page + }, + + closeBrowserPage: (pageId) => { + let closedWorkspaceIdForLabel: string | null = null + let docPageIdToRelease: string | null = null + const remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = [] + set((s) => { + const page = findPage(s.browserPagesByWorkspace, pageId) + if (!page) { + return s + } + const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId) + if (!workspace) { + return s + } + closedWorkspaceIdForLabel = page.workspaceId + docPageIdToRelease = page.docLocation ? page.id : null + const currentPages = s.browserPagesByWorkspace[workspace.id] ?? [] + const nextPages = currentPages.filter((entry) => entry.id !== pageId) + const closedIdx = currentPages.findIndex((entry) => entry.id === pageId) + const nextActivePageId = + workspace.activePageId === pageId + ? ((nextPages[closedIdx] ?? nextPages[closedIdx - 1] ?? null)?.id ?? null) + : workspace.activePageId + const nextWorkspace = mirrorWorkspaceFromActivePage( + { + ...workspace, + activePageId: nextActivePageId, + pageIds: nextPages.map((entry) => entry.id) + }, + nextPages + ) + const remoteHandle = s.remoteBrowserPageHandlesByPageId[pageId] + if (remoteHandle) { + remotePagesToClose.push({ worktreeId: page.worktreeId, handle: remoteHandle }) + } + const nextRemoteBrowserPageHandlesByPageId = { + ...s.remoteBrowserPageHandlesByPageId + } + delete nextRemoteBrowserPageHandlesByPageId[pageId] + const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } + delete nextBrowserAnnotationsByPageId[pageId] + const nextBrowserCertificateFailuresByPageId = { + ...s.browserCertificateFailuresByPageId + } + delete nextBrowserCertificateFailuresByPageId[pageId] + + return { + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [workspace.id]: nextPages + }, + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( + (tab) => (tab.id === workspace.id ? nextWorkspace : tab) + ) + }, + recentlyClosedBrowserPagesByWorkspace: { + ...s.recentlyClosedBrowserPagesByWorkspace, + [workspace.id]: [ + page, + ...(s.recentlyClosedBrowserPagesByWorkspace[workspace.id] ?? []).filter( + (entry) => entry.id !== page.id + ) + ].slice(0, 10) + }, + pendingAddressBarFocusByPageId: Object.fromEntries( + Object.entries(s.pendingAddressBarFocusByPageId).filter( + ([pendingPageId]) => pendingPageId !== pageId + ) + ), + pendingAddressBarFocusByTabId: Object.fromEntries( + Object.entries(s.pendingAddressBarFocusByTabId).filter( + ([pendingPageId]) => pendingPageId !== pageId + ) + ), + remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, + browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, + browserAnnotationsByPageId: nextBrowserAnnotationsByPageId + } + }) + + for (const remotePage of remotePagesToClose) { + closeRemoteBrowserPageInOwningEnvironment( + remotePage.worktreeId, + remotePage.handle, + get().recordClientHostedBrowserCloseIntents + ) + } + + if (docPageIdToRelease) { + releaseDocPreviewGrant(docPageIdToRelease) + } + + const closedWorkspaceId = closedWorkspaceIdForLabel + if (!closedWorkspaceId) { + return + } + const workspace = findWorkspace(get().browserTabsByWorktree, closedWorkspaceId) + const item = Object.values(get().unifiedTabsByWorktree) + .flat() + .find((entry) => entry.contentType === 'browser' && entry.entityId === closedWorkspaceId) + if (item && workspace) { + get().setTabLabel(item.id, workspace.title) + } + } + } +} diff --git a/src/renderer/src/store/slices/browser/browser-page-focus-actions.ts b/src/renderer/src/store/slices/browser/browser-page-focus-actions.ts new file mode 100644 index 00000000000..c10dd6abb2e --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-page-focus-actions.ts @@ -0,0 +1,94 @@ +import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract' +import { mirrorWorkspaceFromActivePage } from '../browser-page-records' +import { isLocalBrowserPageOwner } from './browser-host-state' + +export function createBrowserPageFocusActions( + set: BrowserSliceSet, + get: BrowserSliceGet +): Pick { + return { + focusBrowserTabInWorktree: (worktreeId, browserPageId, options) => { + // Why: bridge targets a browserPageId but tabs activate a workspace; find the owning workspace (they differ for multi-page tabs). + const tabsForWorktree = get().browserTabsByWorktree[worktreeId] ?? [] + const workspace = tabsForWorktree.find((tab) => (tab.pageIds ?? []).includes(browserPageId)) + if (!workspace) { + // Best-effort: worktree state may not be hydrated yet, or the page closed between bridge switch and this IPC arriving. + return + } + // Default true: the only caller (tab switch --focus) wants the pane surfaced; false is an opt-out for pre-staging callers. + const surfacePane = options?.surfacePane ?? true + const pages = get().browserPagesByWorkspace[workspace.id] ?? [] + const nextWorkspace = mirrorWorkspaceFromActivePage( + { ...workspace, activePageId: browserPageId }, + pages + ) + // TODO: duplicates setActiveBrowserTab/Page; can't reuse (they touch globals unconditionally). Extract a per-worktree-only helper. + set((s) => { + const isActiveWorktree = s.activeWorktreeId === worktreeId + // Per-worktree slots: always update — safe pre-staging, only visible when user navigates here. + const nextTabsByWorktree = { + ...s.browserTabsByWorktree, + [worktreeId]: tabsForWorktree.map((tab) => + tab.id === workspace.id ? nextWorkspace : tab + ) + } + const nextActiveTabIdByWorktree = { + ...s.activeBrowserTabIdByWorktree, + [worktreeId]: workspace.id + } + const nextActiveTabTypeByWorktree = surfacePane + ? { ...s.activeTabTypeByWorktree, [worktreeId]: 'browser' as const } + : s.activeTabTypeByWorktree + // Globals: mutate only when the targeted worktree is active — keeps cross-worktree --focus silent. + return { + browserTabsByWorktree: nextTabsByWorktree, + activeBrowserTabIdByWorktree: nextActiveTabIdByWorktree, + activeTabTypeByWorktree: nextActiveTabTypeByWorktree, + activeBrowserTabId: isActiveWorktree ? workspace.id : s.activeBrowserTabId, + activeTabType: isActiveWorktree && surfacePane ? 'browser' : s.activeTabType + } + }) + + // Why: notify the CDP bridge which guest webContents is active so agent commands target the correct page. + const focusedPage = pages.find((page) => page.id === browserPageId) + if ( + isLocalBrowserPageOwner(get(), worktreeId, focusedPage?.browserRuntimeEnvironmentId) && + typeof window !== 'undefined' && + window.api?.browser + ) { + window.api.browser.notifyActiveTabChanged({ browserPageId }).catch(() => {}) + } + + // Why: sync the unified-tab strip's active entry; activateTab only mutates per-worktree slices, so it's cross-worktree-safe. + const item = (get().unifiedTabsByWorktree[worktreeId] ?? []).find( + (entry) => entry.contentType === 'browser' && entry.entityId === workspace.id + ) + if (item) { + get().activateTab(item.id) + } + }, + + consumeAddressBarFocusRequest: (pageId) => { + const state = get() + if ( + !state.pendingAddressBarFocusByPageId[pageId] && + !state.pendingAddressBarFocusByTabId[pageId] + ) { + return false + } + + set((s) => { + const nextByPageId = { ...s.pendingAddressBarFocusByPageId } + delete nextByPageId[pageId] + const nextByTabId = { ...s.pendingAddressBarFocusByTabId } + delete nextByTabId[pageId] + return { + pendingAddressBarFocusByPageId: nextByPageId, + pendingAddressBarFocusByTabId: nextByTabId + } + }) + + return true + } + } +} diff --git a/src/renderer/src/store/slices/browser/browser-page-metadata-actions.ts b/src/renderer/src/store/slices/browser/browser-page-metadata-actions.ts new file mode 100644 index 00000000000..7be8393b130 --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-page-metadata-actions.ts @@ -0,0 +1,131 @@ +import { GRAB_BUDGET } from '../../../../../shared/browser-grab-types' +import type { + BrowserSlice, + BrowserSliceGet, + BrowserSliceSet, + RemoteBrowserPageHandle +} from './browser-slice-contract' +import { findPage, findWorkspace } from '../browser-page-records' +import { sanitizeBrowserPageAnnotation } from './browser-page-annotation' + +export function createBrowserPageMetadataActions( + set: BrowserSliceSet, + _get: BrowserSliceGet +): Pick< + BrowserSlice, + | 'setRemoteBrowserPageHandle' + | 'removeRemoteBrowserPageHandle' + | 'setBrowserPageViewportPreset' + | 'addBrowserPageAnnotation' + | 'updateBrowserPageAnnotation' + | 'deleteBrowserPageAnnotation' + | 'clearBrowserPageAnnotations' +> { + return { + setRemoteBrowserPageHandle: (pageId, handle) => { + set((s) => ({ + remoteBrowserPageHandlesByPageId: { + ...s.remoteBrowserPageHandlesByPageId, + [pageId]: handle + } + })) + }, + + removeRemoteBrowserPageHandle: (pageId, remotePageId) => { + let removedHandle: RemoteBrowserPageHandle | null = null + set((s) => { + const current = s.remoteBrowserPageHandlesByPageId[pageId] + if (!current || (remotePageId && current.remotePageId !== remotePageId)) { + return s + } + removedHandle = current + const nextRemoteBrowserPageHandlesByPageId = { + ...s.remoteBrowserPageHandlesByPageId + } + delete nextRemoteBrowserPageHandlesByPageId[pageId] + return { remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId } + }) + return removedHandle + }, + + // viewportPresetId is intentionally page-local (no workspace-layer UI consumer); do NOT add mirrorWorkspaceFromActivePage here. + setBrowserPageViewportPreset: (pageId, viewportPresetId) => + set((s) => { + const page = findPage(s.browserPagesByWorkspace, pageId) + if (!page) { + return s + } + const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId) + if (!workspace) { + return s + } + const nextPages = (s.browserPagesByWorkspace[workspace.id] ?? []).map((entry) => + entry.id === pageId ? { ...entry, viewportPresetId } : entry + ) + return { + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [workspace.id]: nextPages + } + } + }), + + addBrowserPageAnnotation: (annotation) => + set((s) => { + const existing = s.browserAnnotationsByPageId[annotation.browserPageId] ?? [] + const next = [...existing, sanitizeBrowserPageAnnotation(annotation)].slice( + -GRAB_BUDGET.annotationsMaxPerPage + ) + return { + browserAnnotationsByPageId: { + ...s.browserAnnotationsByPageId, + [annotation.browserPageId]: next + } + } + }), + + updateBrowserPageAnnotation: (pageId, annotationId, patch) => + set((s) => { + const existing = s.browserAnnotationsByPageId[pageId] ?? [] + const target = existing.find((annotation) => annotation.id === annotationId) + if (!target) { + return s + } + const updated = sanitizeBrowserPageAnnotation({ ...target, ...patch }) + return { + browserAnnotationsByPageId: { + ...s.browserAnnotationsByPageId, + [pageId]: existing.map((annotation) => + annotation.id === annotationId ? updated : annotation + ) + } + } + }), + + deleteBrowserPageAnnotation: (pageId, annotationId) => + set((s) => { + const existing = s.browserAnnotationsByPageId[pageId] ?? [] + const next = existing.filter((annotation) => annotation.id !== annotationId) + if (next.length === existing.length) { + return s + } + const nextByPageId = { ...s.browserAnnotationsByPageId } + if (next.length > 0) { + nextByPageId[pageId] = next + } else { + delete nextByPageId[pageId] + } + return { browserAnnotationsByPageId: nextByPageId } + }), + + clearBrowserPageAnnotations: (pageId) => + set((s) => { + if (!s.browserAnnotationsByPageId[pageId]?.length) { + return s + } + const nextByPageId = { ...s.browserAnnotationsByPageId } + delete nextByPageId[pageId] + return { browserAnnotationsByPageId: nextByPageId } + }) + } +} diff --git a/src/renderer/src/store/slices/browser/browser-page-state-actions.ts b/src/renderer/src/store/slices/browser/browser-page-state-actions.ts new file mode 100644 index 00000000000..15e4892b351 --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-page-state-actions.ts @@ -0,0 +1,219 @@ +import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract' +import type { AppState } from '../../types' +import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/constants' +import { + browserWorkspaceMirrorFieldsEqual, + findPage, + findWorkspace, + mirrorWorkspaceFromActivePage, + normalizeBrowserTitle, + normalizeUrl +} from '../browser-page-records' + +export function createBrowserPageStateActions( + set: BrowserSliceSet, + get: BrowserSliceGet +): Pick< + BrowserSlice, + | 'updateBrowserTabPageState' + | 'updateBrowserPageState' + | 'setBrowserPageCertificateFailure' + | 'setBrowserTabUrl' + | 'setBrowserPageUrl' +> { + return { + updateBrowserTabPageState: (pageId, updates) => get().updateBrowserPageState(pageId, updates), + + updateBrowserPageState: (pageId, updates) => { + set((s) => { + const page = findPage(s.browserPagesByWorkspace, pageId) + if (!page) { + return s + } + const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId) + if (!workspace) { + return s + } + const nextPage = { + ...page, + title: + updates.title === undefined + ? page.title + : normalizeBrowserTitle(updates.title, page.url, page.docLocation), + loading: updates.loading ?? page.loading, + faviconUrl: updates.faviconUrl === undefined ? page.faviconUrl : updates.faviconUrl, + canGoBack: updates.canGoBack ?? page.canGoBack, + canGoForward: updates.canGoForward ?? page.canGoForward, + loadError: updates.loadError === undefined ? page.loadError : updates.loadError + } + const unifiedTabs = s.unifiedTabsByWorktree[workspace.worktreeId] ?? [] + const unifiedIndex = + workspace.activePageId === pageId && updates.title !== undefined + ? unifiedTabs.findIndex( + (entry) => entry.contentType === 'browser' && entry.entityId === workspace.id + ) + : -1 + const unifiedLabelNeedsRepair = + unifiedIndex !== -1 && unifiedTabs[unifiedIndex]?.label !== nextPage.title + const pageStateUnchanged = + nextPage.title === page.title && + nextPage.loading === page.loading && + nextPage.faviconUrl === page.faviconUrl && + nextPage.canGoBack === page.canGoBack && + nextPage.canGoForward === page.canGoForward && + nextPage.loadError === page.loadError + const currentPages = s.browserPagesByWorkspace[workspace.id] ?? [] + const mirroredWorkspace = pageStateUnchanged + ? mirrorWorkspaceFromActivePage(workspace, currentPages) + : null + const workspaceNeedsRepair = + mirroredWorkspace !== null && + !browserWorkspaceMirrorFieldsEqual(workspace, mirroredWorkspace) + if (pageStateUnchanged && !unifiedLabelNeedsRepair && !workspaceNeedsRepair) { + return s + } + if (pageStateUnchanged) { + const nextState: Partial = {} + if (workspaceNeedsRepair && mirroredWorkspace) { + nextState.browserTabsByWorktree = { + ...s.browserTabsByWorktree, + [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( + (tab) => (tab.id === workspace.id ? mirroredWorkspace : tab) + ) + } + } + if (unifiedLabelNeedsRepair) { + nextState.unifiedTabsByWorktree = { + ...s.unifiedTabsByWorktree, + [workspace.worktreeId]: unifiedTabs.map((entry, index) => + index === unifiedIndex ? { ...entry, label: nextPage.title } : entry + ) + } + } + return nextState + } + const nextPages = currentPages.map((entry) => (entry.id === pageId ? nextPage : entry)) + const nextWorkspace = mirrorWorkspaceFromActivePage(workspace, nextPages) + const nextState: Partial = { + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [workspace.id]: nextPages + } + } + if (!browserWorkspaceMirrorFieldsEqual(workspace, nextWorkspace)) { + nextState.browserTabsByWorktree = { + ...s.browserTabsByWorktree, + [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( + (tab) => (tab.id === workspace.id ? nextWorkspace : tab) + ) + } + } + if ( + workspace.activePageId === pageId && + updates.title !== undefined && + unifiedIndex !== -1 + ) { + if (unifiedLabelNeedsRepair || unifiedTabs[unifiedIndex]?.label !== nextWorkspace.title) { + nextState.unifiedTabsByWorktree = { + ...s.unifiedTabsByWorktree, + [workspace.worktreeId]: unifiedTabs.map((entry, index) => + index === unifiedIndex ? { ...entry, label: nextWorkspace.title } : entry + ) + } + } + } + return nextState + }) + if (updates.loadError === null) { + get().setBrowserPageCertificateFailure(pageId, null) + } + }, + + setBrowserPageCertificateFailure: (pageId, failure) => { + set((s) => { + const current = s.browserCertificateFailuresByPageId[pageId] + if (failure === null) { + if (!current) { + return s + } + const nextFailures = { ...s.browserCertificateFailuresByPageId } + delete nextFailures[pageId] + return { browserCertificateFailuresByPageId: nextFailures } + } + if (!findPage(s.browserPagesByWorkspace, pageId) || current === failure) { + return s + } + return { + browserCertificateFailuresByPageId: { + ...s.browserCertificateFailuresByPageId, + [pageId]: failure + } + } + }) + }, + + setBrowserTabUrl: (pageId, url) => get().setBrowserPageUrl(pageId, url), + + setBrowserPageUrl: (pageId, url, options) => { + const nextUrl = normalizeUrl(url) + if (nextUrl !== 'about:blank' && nextUrl !== ORCA_BROWSER_BLANK_URL) { + const currentPage = findPage(get().browserPagesByWorkspace, pageId) + if (currentPage) { + get().recordFeatureInteraction?.('browser') + } + } + set((s) => { + const page = findPage(s.browserPagesByWorkspace, pageId) + if (!page) { + return s + } + const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId) + if (!workspace) { + return s + } + // Why a document page keeps its blank url here too: this is the third door onto a page's url, + // and a document's url is blank by construction. A grant committed here would reach + // persistence, the publish boundary and the address bar, exactly as at the other two doors. + const nextPageUrl = page.docLocation ? ORCA_BROWSER_BLANK_URL : nextUrl + // Why: annotations point at DOM coords of the loaded document; a real URL change invalidates those markers. + const shouldClearAnnotations = normalizeUrl(page.url) !== nextPageUrl + const nextPages = (s.browserPagesByWorkspace[workspace.id] ?? []).map((entry) => + entry.id === pageId + ? { + ...entry, + url: nextPageUrl, + title: normalizeBrowserTitle(entry.title, nextPageUrl, entry.docLocation), + // Why not simply true: a document page's guest is inert, so there is nothing to wait + // for and the loading affordance would never clear. + loading: !entry.docLocation, + loadError: options?.preserveLoadError ? entry.loadError : null + } + : entry + ) + const nextWorkspace = mirrorWorkspaceFromActivePage(workspace, nextPages) + const nextBrowserAnnotationsByPageId = shouldClearAnnotations + ? { ...s.browserAnnotationsByPageId } + : s.browserAnnotationsByPageId + if (shouldClearAnnotations) { + delete nextBrowserAnnotationsByPageId[pageId] + } + return { + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [workspace.id]: nextPages + }, + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( + (tab) => (tab.id === workspace.id ? nextWorkspace : tab) + ) + }, + ...(shouldClearAnnotations + ? { browserAnnotationsByPageId: nextBrowserAnnotationsByPageId } + : {}) + } + }) + get().setBrowserPageCertificateFailure(pageId, null) + } + } +} diff --git a/src/renderer/src/store/slices/browser/browser-profile-import-actions.ts b/src/renderer/src/store/slices/browser/browser-profile-import-actions.ts new file mode 100644 index 00000000000..ec03690bf8e --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-profile-import-actions.ts @@ -0,0 +1,172 @@ +import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract' +import type { BrowserCookieImportResult } from '../../../../../shared/browser-workspace-types' +import type { BrowserDetectProfilesResult } from '../../../../../shared/runtime-types' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { translate } from '@/i18n/i18n' +import { selectExecutionHostDisplayLabel } from '@/lib/execution-host-display-label' +import { + getBrowserSettingsHostId, + getBrowserSettingsRuntimeEnvironmentId, + browserImportStateForHostUpdate, + retainCookieImportExecutionHost +} from './browser-host-state' + +export function createBrowserProfileImportActions( + set: BrowserSliceSet, + get: BrowserSliceGet +): Pick< + BrowserSlice, + 'importCookiesToProfile' | 'clearBrowserSessionImportState' | 'fetchDetectedBrowsers' +> { + return { + importCookiesToProfile: async (profileId) => { + const initialState = get() + const hostId = getBrowserSettingsHostId(initialState) + const executionHostLabel = selectExecutionHostDisplayLabel(initialState, hostId) + if (getBrowserSettingsRuntimeEnvironmentId(initialState)) { + const reason = translate( + 'auto.store.slices.browser.remoteCookieImportUnavailable', + 'Manual cookie file import is unavailable while a remote runtime is active.' + ) + set((state) => + browserImportStateForHostUpdate(state, hostId, { + profileId, + status: 'error', + summary: null, + error: reason + }) + ) + return retainCookieImportExecutionHost( + { ok: false as const, reason }, + hostId, + executionHostLabel, + 'client' + ) + } + set((state) => + browserImportStateForHostUpdate(state, hostId, { + profileId, + status: 'importing', + summary: null, + error: null + }) + ) + try { + const result = (await window.api.browser.sessionImportCookies({ + profileId + })) as BrowserCookieImportResult + if (result.ok) { + get().recordFeatureInteraction?.('cookie-import') + set((state) => + browserImportStateForHostUpdate(state, hostId, { + profileId, + status: 'success', + summary: result.summary, + error: null + }) + ) + if (getBrowserSettingsHostId(get()) === hostId) { + await get() + .fetchBrowserSessionProfiles() + .catch(() => {}) + } + } else { + set((state) => + browserImportStateForHostUpdate(state, hostId, { + profileId, + status: result.reason === 'canceled' ? 'idle' : 'error', + summary: null, + error: result.reason === 'canceled' ? null : result.reason + }) + ) + } + return retainCookieImportExecutionHost(result, hostId, executionHostLabel, 'client') + } catch (err) { + const reason = String((err as Error)?.message ?? err) + set((state) => + browserImportStateForHostUpdate(state, hostId, { + profileId, + status: 'error', + summary: null, + error: reason + }) + ) + return retainCookieImportExecutionHost( + { ok: false as const, reason }, + hostId, + executionHostLabel, + 'client' + ) + } + }, + + clearBrowserSessionImportState: () => { + set({ browserSessionImportState: null }) + }, + + fetchDetectedBrowsers: async () => { + const hostId = getBrowserSettingsHostId(get()) + const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) + if (runtimeEnvironmentId) { + const hostLabel = selectExecutionHostDisplayLabel(get(), hostId) + try { + // Why: the import runs on whichever machine hosts the pages, so the picker must offer that + // machine's browsers -- client-hosted means this desktop, not the (usually headless) remote. + const clientHostBrowsers = await window.api.browser.sessionDetectBrowsersForClientHost({ + environmentId: runtimeEnvironmentId + }) + const browsers = + clientHostBrowsers ?? + ( + await callRuntimeRpc( + { kind: 'environment', environmentId: runtimeEnvironmentId }, + 'browser.profileDetectBrowsers', + undefined, + { timeoutMs: 15_000 } + ) + ).browsers + // Why: retain which machine answered so import menus can say where imports read and store. + const detectedBrowsersHost = { + machine: clientHostBrowsers ? ('client' as const) : ('remote' as const), + hostLabel + } + set((s) => + getBrowserSettingsHostId(s) === hostId + ? { detectedBrowsers: browsers, detectedBrowsersLoaded: true, detectedBrowsersHost } + : {} + ) + } catch { + set((s) => + getBrowserSettingsHostId(s) === hostId + ? { detectedBrowsers: [], detectedBrowsersLoaded: true, detectedBrowsersHost: null } + : {} + ) + } + return + } + if (get().detectedBrowsersLoaded) { + return + } + try { + const browsers = (await window.api.browser.sessionDetectBrowsers()) as { + family: string + label: string + profiles: { name: string; directory: string }[] + selectedProfile: string + }[] + set((s) => + getBrowserSettingsHostId(s) === hostId + ? { + detectedBrowsers: browsers, + detectedBrowsersLoaded: true, + detectedBrowsersHost: null + } + : {} + ) + } catch { + /* best-effort — empty list is acceptable fallback */ + set((s) => (getBrowserSettingsHostId(s) === hostId ? { detectedBrowsersLoaded: true } : {})) + } + } + } +} diff --git a/src/renderer/src/store/slices/browser/browser-profile-list-actions.ts b/src/renderer/src/store/slices/browser/browser-profile-list-actions.ts new file mode 100644 index 00000000000..59340831411 --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-profile-list-actions.ts @@ -0,0 +1,172 @@ +import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract' +import type { BrowserSessionProfile } from '../../../../../shared/browser-workspace-types' +import type { + BrowserProfileCreateResult, + BrowserProfileDeleteResult, + BrowserProfileListResult +} from '../../../../../shared/runtime-types' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { + getBrowserProfilesForHost, + getBrowserSettingsHostId, + getBrowserSettingsRuntimeEnvironmentId, + profileListByHostUpdate, + getDefaultBrowserProfileForHost +} from './browser-host-state' + +export function createBrowserProfileListActions( + set: BrowserSliceSet, + get: BrowserSliceGet +): Pick< + BrowserSlice, + 'fetchBrowserSessionProfiles' | 'createBrowserSessionProfile' | 'deleteBrowserSessionProfile' +> { + return { + fetchBrowserSessionProfiles: async () => { + const hostId = getBrowserSettingsHostId(get()) + const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) + if (runtimeEnvironmentId) { + try { + const result = await callRuntimeRpc( + { kind: 'environment', environmentId: runtimeEnvironmentId }, + 'browser.profileList', + undefined, + { timeoutMs: 15_000 } + ) + // Why: client-hosted imports never touch the server, so the server's + // records can't carry the "imported from Chrome" badge — this desktop + // remembers what it imported into each environment's jar and overlays it. + const clientImportSources = await window.api.browser + .sessionClientRouteImportSources?.({ environmentId: runtimeEnvironmentId }) + .catch(() => ({})) + const profiles = result.profiles.map((profile) => + !profile.source && clientImportSources?.[profile.id] + ? { ...profile, source: clientImportSources[profile.id] } + : profile + ) + set((s) => profileListByHostUpdate(s, profiles, hostId)) + } catch { + set((s) => profileListByHostUpdate(s, [], hostId)) + } + return + } + try { + const profiles = (await window.api.browser.sessionListProfiles()) as BrowserSessionProfile[] + set((s) => profileListByHostUpdate(s, profiles, hostId)) + } catch { + /* best-effort — stale profile list is preferable to a crash */ + } + }, + + createBrowserSessionProfile: async (scope, label, options) => { + const hostId = getBrowserSettingsHostId(get()) + const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) + if (runtimeEnvironmentId) { + try { + const result = await callRuntimeRpc( + { kind: 'environment', environmentId: runtimeEnvironmentId }, + 'browser.profileCreate', + { scope, label, ...options }, + { timeoutMs: 15_000 } + ) + const profile = result.profile + if (profile) { + set((s) => ({ + ...profileListByHostUpdate( + s, + [...getBrowserProfilesForHost(s, hostId), profile], + hostId + ) + })) + } + return profile + } catch { + return null + } + } + try { + const profile = (await window.api.browser.sessionCreateProfile({ + scope, + label, + ...options + })) as BrowserSessionProfile | null + if (profile) { + set((s) => ({ + ...profileListByHostUpdate( + s, + [...getBrowserProfilesForHost(s, hostId), profile], + hostId + ) + })) + } + return profile + } catch { + return null + } + }, + + deleteBrowserSessionProfile: async (profileId) => { + const hostId = getBrowserSettingsHostId(get()) + const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) + if (runtimeEnvironmentId) { + try { + const result = await callRuntimeRpc( + { kind: 'environment', environmentId: runtimeEnvironmentId }, + 'browser.profileDelete', + { profileId }, + { timeoutMs: 15_000 } + ) + if (result.deleted) { + set((s) => ({ + ...profileListByHostUpdate( + s, + getBrowserProfilesForHost(s, hostId).filter((profile) => profile.id !== profileId), + hostId + ), + ...(getDefaultBrowserProfileForHost(s, hostId) === profileId + ? { + ...(getBrowserSettingsHostId(s) === hostId + ? { defaultBrowserSessionProfileId: null } + : {}), + defaultBrowserSessionProfileIdByHostId: { + ...s.defaultBrowserSessionProfileIdByHostId, + [hostId]: null + } + } + : {}) + })) + } + return result.deleted + } catch { + return false + } + } + try { + const ok = await window.api.browser.sessionDeleteProfile({ profileId }) + if (ok) { + set((s) => ({ + ...profileListByHostUpdate( + s, + getBrowserProfilesForHost(s, hostId).filter((profile) => profile.id !== profileId), + hostId + ), + ...(getDefaultBrowserProfileForHost(s, hostId) === profileId + ? { + ...(getBrowserSettingsHostId(s) === hostId + ? { defaultBrowserSessionProfileId: null } + : {}), + defaultBrowserSessionProfileIdByHostId: { + ...s.defaultBrowserSessionProfileIdByHostId, + [hostId]: null + } + } + : {}) + })) + } + return ok + } catch { + return false + } + } + } +} diff --git a/src/renderer/src/store/slices/browser/browser-remote-close.ts b/src/renderer/src/store/slices/browser/browser-remote-close.ts new file mode 100644 index 00000000000..1bc03c1257f --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-remote-close.ts @@ -0,0 +1,33 @@ +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { isBrowserPageDefinitivelyGone } from '@/runtime/client-hosted-browser-close-intent-replay' +import { isDurableClientHostedBrowserHandle } from '@/runtime/client-hosted-browser-close-intents' +import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import type { PendingClientHostedBrowserClose } from '@/runtime/client-hosted-browser-close-intents' +import type { RemoteBrowserPageHandle } from './browser-slice-contract' + +export function closeRemoteBrowserPageInOwningEnvironment( + worktreeId: string, + handle: RemoteBrowserPageHandle, + // Why a callback and not the store: this is module scope, and the recording action lives on the + // slice this file is still defining. + recordCloseIntents: (closes: readonly PendingClientHostedBrowserClose[]) => void +): void { + const target: RuntimeClientTarget = { kind: 'environment', environmentId: handle.environmentId } + void callRuntimeRpc( + target, + 'browser.tabClose', + { worktree: toRuntimeWorktreeSelector(worktreeId), page: handle.remotePageId }, + { timeoutMs: 15_000 } + ).catch((error) => { + // A close the runtime never heard is a resurrection waiting to happen: the runtime persists + // client-hosted pages, so a durable intent replays this same RPC on the next reconnect. + // A definitive page-unknown answer means there is nothing left to resurrect. + if (!isDurableClientHostedBrowserHandle(handle) || isBrowserPageDefinitivelyGone(error)) { + return + } + recordCloseIntents([ + { environmentId: handle.environmentId, browserPageId: handle.remotePageId, worktreeId } + ]) + }) +} diff --git a/src/renderer/src/store/slices/browser/browser-slice-contract.ts b/src/renderer/src/store/slices/browser/browser-slice-contract.ts new file mode 100644 index 00000000000..c5577aaf0c4 --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-slice-contract.ts @@ -0,0 +1,262 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../../types' +import type { + BrowserCertificateFailure, + BrowserCookieImportResult, + BrowserCookieImportSummary, + BrowserHistoryEntry, + BrowserLoadError, + BrowserPage, + BrowserPageDocLocation, + BrowserSessionProfile, + BrowserSessionProfileCreateOptions, + BrowserViewportPresetId, + BrowserWorkspace +} from '../../../../../shared/browser-workspace-types' +import type { WorkspaceSessionState } from '../../../../../shared/workspace-session-state-types' +import type { WorkspaceDocHistoryEntry } from '../../../../../shared/workspace-doc-history' +import type { + BrowserAnnotationIntent, + BrowserPageAnnotation +} from '../../../../../shared/browser-grab-types' +import type { + BrowserPageConversionLeg, + BrowserPageConversionTarget +} from '../browser-page-conversion' +import type { RecentlyClosedTabPosition } from '../recently-closed-tabs' +import type { + ClientHostedBrowserCloseIntentsByEnvironment, + PendingClientHostedBrowserClose +} from '@/runtime/client-hosted-browser-close-intents' +import type { WorkspaceSessionHydrationOptions } from '@/lib/workspace-session-hydration-keys' +import type { ExecutionHostId } from '../../../../../shared/execution-host' +import type { RuntimeBrowserPlacement } from '../../../../../shared/runtime-browser-placement' + +export type CreateBrowserTabOptions = { + activate?: boolean + browserPageId?: string + title?: string + sessionProfileId?: string | null + sessionPartition?: string | null + // Place the new tab in a specific group (e.g. "Open Preview to the Side"); defaults to the worktree's active group. + targetGroupId?: string + // Explicit "New Tab" focuses the address bar even with a real home URL; link-opened tabs leave it unset. + focusAddressBar?: boolean + browserRuntimeEnvironmentId?: string | null + /** Creates a page that shows a workspace document instead of a URL. */ + docLocation?: BrowserPageDocLocation +} + +export type CreateBrowserPageOptions = { + activate?: boolean + title?: string + browserRuntimeEnvironmentId?: string | null + /** Creates a page that shows a workspace document instead of a URL. */ + docLocation?: BrowserPageDocLocation +} + +export type BrowserTabPageState = { + title?: string + loading?: boolean + faviconUrl?: string | null + canGoBack?: boolean + canGoForward?: boolean + loadError?: BrowserLoadError | null +} + +export type SetBrowserPageUrlOptions = { + preserveLoadError?: boolean +} + +export type ClosedBrowserWorkspaceSnapshot = { + workspace: BrowserWorkspace + pages: BrowserPage[] + position?: RecentlyClosedTabPosition +} + +export type RemoteBrowserPageHandle = { + environmentId: string + remotePageId: string + placement?: RuntimeBrowserPlacement + /** Optimistically staged by this client; the host has not published the page yet. */ + staged?: true + /** + * This client expects to host the staged page itself. The real placement is minted host-side and + * only arrives with the snapshot, so the pane needs this to mount as the right kind of pane from + * the first frame instead of swapping components at adoption. + */ + stagedClientHosted?: true + /** + * Rebuilt at hydration from the persisted page row rather than observed from a host snapshot. The + * page id is real, but nothing has confirmed the host still has it, so the row is exempt from the + * absent-from-snapshot cull until the first snapshot that publishes it clears this. + */ + restoredFromSession?: true + /** The restored page was hosted by this desktop, so it must not restore as a streamed pane. */ + restoredClientHosted?: true +} + +export type BrowserCookieImportExecutionResult = BrowserCookieImportResult & { + executionHostId: ExecutionHostId + executionHostLabel: string + // Why: for a remote environment the import silently runs on either machine; toasts must say which. + executionMachine: 'client' | 'remote' + executionRemoteEnvironment: boolean +} + +export type BrowserSlice = { + browserTabsByWorktree: Record + browserPagesByWorkspace: Record + browserCertificateFailuresByPageId: Record + browserAnnotationsByPageId: Record + remoteBrowserPageHandlesByPageId: Record + /** + * Closes of client-hosted pages their owning runtime never heard, keyed by environment. + * + * Durable because the runtime persists the pages themselves: a close swallowed while the host + * was down would otherwise be undone by that host's next start. + */ + clientHostedBrowserCloseIntentsByEnvironment: ClientHostedBrowserCloseIntentsByEnvironment + recordClientHostedBrowserCloseIntents: ( + closes: readonly PendingClientHostedBrowserClose[] + ) => void + clearClientHostedBrowserCloseIntents: ( + environmentId: string, + browserPageIds: readonly string[] + ) => void + activeBrowserTabId: string | null + activeBrowserTabIdByWorktree: Record + recentlyClosedBrowserTabsByWorktree: Record + recentlyClosedBrowserPagesByWorkspace: Record + pendingAddressBarFocusByTabId: Record + pendingAddressBarFocusByPageId: Record + createBrowserTab: ( + worktreeId: string, + url: string, + options?: CreateBrowserTabOptions + ) => BrowserWorkspace + openNewBrowserTabInActiveWorkspace: (groupId: string) => Promise + /** `profileId: null` uses the workspace default profile. */ + openBrowserProfileTabInActiveWorkspace: ( + url: string, + profileId: string | null + ) => Promise + closeBrowserTab: (tabId: string, options?: { reason?: 'cleanup' }) => void + shutdownWorktreeBrowsers: (worktreeId: string) => Promise + reopenClosedBrowserTab: (worktreeId: string) => BrowserWorkspace | null + setActiveBrowserTab: (tabId: string) => void + createBrowserPage: ( + workspaceId: string, + url: string, + options?: CreateBrowserPageOptions + ) => BrowserPage | null + closeBrowserPage: (pageId: string) => void + // Why replacement and not mutation: a page's kind (doc vs web) is immutable for its life, so the + // address bar converts by replacing the page — fresh id, same workspace row — keeping the two + // main-process registry halves disjoint by construction. + convertBrowserPage: ( + pageId: string, + target: BrowserPageConversionTarget, + options?: { leg?: BrowserPageConversionLeg } + ) => BrowserPage | null + reopenClosedBrowserPage: (workspaceId: string) => BrowserPage | null + setActiveBrowserPage: (workspaceId: string, pageId: string) => void + // Focus that never yanks the user across worktrees: per-worktree slots always update, globals only when targeting the active worktree. + focusBrowserTabInWorktree: ( + worktreeId: string, + browserPageId: string, + options?: { surfacePane?: boolean } + ) => void + consumeAddressBarFocusRequest: (pageId: string) => boolean + updateBrowserTabPageState: (pageId: string, updates: BrowserTabPageState) => void + updateBrowserPageState: (pageId: string, updates: BrowserTabPageState) => void + setBrowserPageCertificateFailure: ( + pageId: string, + failure: BrowserCertificateFailure | null + ) => void + setBrowserTabUrl: (pageId: string, url: string) => void + setBrowserPageUrl: (pageId: string, url: string, options?: SetBrowserPageUrlOptions) => void + setRemoteBrowserPageHandle: (pageId: string, handle: RemoteBrowserPageHandle) => void + removeRemoteBrowserPageHandle: ( + pageId: string, + remotePageId?: string + ) => RemoteBrowserPageHandle | null + setBrowserPageViewportPreset: ( + pageId: string, + viewportPresetId: BrowserViewportPresetId | null + ) => void + addBrowserPageAnnotation: (annotation: BrowserPageAnnotation) => void + updateBrowserPageAnnotation: ( + pageId: string, + annotationId: string, + patch: { comment: string; intent: BrowserAnnotationIntent } + ) => void + deleteBrowserPageAnnotation: (pageId: string, annotationId: string) => void + clearBrowserPageAnnotations: (pageId: string) => void + hydrateBrowserSession: ( + session: WorkspaceSessionState, + options?: WorkspaceSessionHydrationOptions + ) => void + switchBrowserTabProfile: ( + workspaceId: string, + profileId: string | null, + sessionPartition?: string | null + ) => void + browserSessionProfiles: BrowserSessionProfile[] + browserSessionProfilesByHostId: Partial> + browserSessionHostIdOverride: ExecutionHostId | null + setBrowserSessionHostId: (hostId: ExecutionHostId) => Promise + browserSessionImportState: { + profileId: string + status: 'idle' | 'importing' | 'success' | 'error' + summary: BrowserCookieImportSummary | null + error: string | null + } | null + fetchBrowserSessionProfiles: () => Promise + createBrowserSessionProfile: ( + scope: 'isolated' | 'imported', + label: string, + options?: BrowserSessionProfileCreateOptions + ) => Promise + deleteBrowserSessionProfile: (profileId: string) => Promise + importCookiesToProfile: (profileId: string) => Promise + clearBrowserSessionImportState: () => void + detectedBrowsers: { + family: string + label: string + profiles: { name: string; directory: string }[] + selectedProfile: string + }[] + detectedBrowsersLoaded: boolean + // Why: which machine answered detection for a remote environment, so import menus can say where + // the import will read and store; null while browser settings target the local host. + detectedBrowsersHost: { machine: 'client' | 'remote'; hostLabel: string } | null + fetchDetectedBrowsers: () => Promise + importCookiesFromBrowser: ( + profileId: string, + browserFamily: string, + browserProfile?: string + ) => Promise + clearDefaultSessionCookies: () => Promise + browserUrlHistory: BrowserHistoryEntry[] + addBrowserHistoryEntry: (url: string, title: string) => void + workspaceDocHistory: WorkspaceDocHistoryEntry[] + /** A visit bumps recency and count; a title-only refresh (bump: false) renames the row. */ + recordWorkspaceDocVisit: ( + docLocation: BrowserPageDocLocation, + title: string | null, + options?: { bump?: boolean } + ) => void + clearBrowserHistory: () => void + defaultBrowserSessionProfileId: string | null + defaultBrowserSessionProfileIdByHostId: Partial> + setDefaultBrowserSessionProfileId: (profileId: string | null) => void +} + +type BrowserStateCreator = StateCreator +export type BrowserSliceSet = Parameters[0] +export type BrowserSliceGet = Parameters[1] + +// Keep the public slice surface's domain types available to existing consumers while the +// implementation is split across focused modules. +export type { BrowserPageConversionLeg, BrowserPageConversionTarget, BrowserSessionProfile } diff --git a/src/renderer/src/store/slices/browser/browser-tab-actions.ts b/src/renderer/src/store/slices/browser/browser-tab-actions.ts new file mode 100644 index 00000000000..e388716e405 --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-tab-actions.ts @@ -0,0 +1,253 @@ +import { createBrowserUuid } from '@/lib/browser-uuid' +import { translate } from '@/i18n/i18n' +import { + FLOATING_TERMINAL_WORKTREE_ID, + ORCA_BROWSER_BLANK_URL +} from '../../../../../shared/constants' +import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract' +import { + assertManagedBrowserMaterializationAllowed, + getClientCreationActionPolicy +} from '@/lib/client-creation-action-policy' +import { + buildBrowserPage, + buildWorkspaceFromPage, + findPage, + findWorkspace +} from '../browser-page-records' +import { getBrowserSessionProfileHostId } from './browser-host-state' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' + +export function createBrowserTabActions( + set: BrowserSliceSet, + get: BrowserSliceGet +): Pick< + BrowserSlice, + | 'createBrowserTab' + | 'openNewBrowserTabInActiveWorkspace' + | 'openBrowserProfileTabInActiveWorkspace' +> { + return { + createBrowserTab: (worktreeId, url, options) => { + assertManagedBrowserMaterializationAllowed(get(), options?.browserRuntimeEnvironmentId) + const workspaceId = createBrowserUuid() + const browserPageId = options?.browserPageId + if ( + browserPageId && + (findWorkspace(get().browserTabsByWorktree, browserPageId) || + findPage(get().browserPagesByWorkspace, browserPageId)) + ) { + throw new Error(`Browser page ${browserPageId} already exists`) + } + const page = buildBrowserPage( + workspaceId, + worktreeId, + url, + options?.title, + options?.browserRuntimeEnvironmentId, + browserPageId, + options?.docLocation + ) + // Why: with no explicit profile, inherit the user's default so a Settings preference applies to new tabs. + const sessionProfileId = + options?.sessionProfileId !== undefined + ? options.sessionProfileId + : (get().defaultBrowserSessionProfileIdByHostId[ + getBrowserSessionProfileHostId( + get(), + worktreeId, + options?.browserRuntimeEnvironmentId + ) + ] ?? get().defaultBrowserSessionProfileId) + const browserTab = buildWorkspaceFromPage( + workspaceId, + worktreeId, + page, + [page.id], + sessionProfileId, + options?.sessionPartition + ) + + set((s) => { + const existingTabs = s.browserTabsByWorktree[worktreeId] ?? [] + const nextTabBarOrder = (() => { + const currentOrder = s.tabBarOrderByWorktree[worktreeId] ?? [] + const terminalIds = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id) + const editorIds = s.openFiles + .filter((file) => file.worktreeId === worktreeId) + .map((file) => file.id) + const browserIds = existingTabs.map((tab) => tab.id) + const allExistingIds = new Set([...terminalIds, ...editorIds, ...browserIds]) + const base = currentOrder.filter((entryId) => allExistingIds.has(entryId)) + const inBase = new Set(base) + for (const entryId of [...terminalIds, ...editorIds, ...browserIds]) { + if (!inBase.has(entryId)) { + base.push(entryId) + inBase.add(entryId) + } + } + base.push(workspaceId) + return base + })() + + const shouldActivate = options?.activate ?? true + const shouldUpdateGlobalActiveSurface = shouldActivate && s.activeWorktreeId === worktreeId + const shouldFocusFloatingTab = + shouldActivate && worktreeId === FLOATING_TERMINAL_WORKTREE_ID + const shouldFocusAddressBar = + (shouldUpdateGlobalActiveSurface || shouldFocusFloatingTab) && + // Why the doc check and not just the url: a document page is blank by construction, and + // the blank url is exactly what marks a New Tab as wanting the address bar it does not have. + !page.docLocation && + (options?.focusAddressBar ?? + (page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL)) + + return { + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [worktreeId]: [...existingTabs, browserTab] + }, + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [workspaceId]: [page] + }, + tabBarOrderByWorktree: { + ...s.tabBarOrderByWorktree, + [worktreeId]: nextTabBarOrder + }, + activeBrowserTabId: shouldUpdateGlobalActiveSurface ? workspaceId : s.activeBrowserTabId, + activeBrowserTabIdByWorktree: { + ...s.activeBrowserTabIdByWorktree, + [worktreeId]: shouldActivate + ? workspaceId + : (s.activeBrowserTabIdByWorktree[worktreeId] ?? null) + }, + activeTabType: shouldUpdateGlobalActiveSurface ? 'browser' : s.activeTabType, + activeTabTypeByWorktree: shouldActivate + ? { ...s.activeTabTypeByWorktree, [worktreeId]: 'browser' } + : s.activeTabTypeByWorktree, + pendingAddressBarFocusByPageId: shouldFocusAddressBar + ? { + ...s.pendingAddressBarFocusByPageId, + [page.id]: true + } + : s.pendingAddressBarFocusByPageId, + pendingAddressBarFocusByTabId: shouldFocusAddressBar + ? { + ...s.pendingAddressBarFocusByTabId, + [workspaceId]: true, + [page.id]: true + } + : s.pendingAddressBarFocusByTabId + } + }) + + const state = get() + const alreadyHasUnifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).some( + (t) => t.contentType === 'browser' && t.entityId === workspaceId + ) + if (!alreadyHasUnifiedTab) { + state.createUnifiedTab(worktreeId, 'browser', { + entityId: workspaceId, + label: browserTab.title, + targetGroupId: options?.targetGroupId, + activate: options?.activate ?? true + }) + } + return browserTab + }, + + openNewBrowserTabInActiveWorkspace: async (groupId) => { + const state = get() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + return + } + const browserAvailability = getClientCreationActionPolicy(state, worktreeId)[ + 'managed-browser' + ] + if (browserAvailability.state !== 'enabled') { + throw new Error(browserAvailability.reason) + } + const defaultUrl = state.browserDefaultUrl ?? 'about:blank' + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) + if (browserAvailability.provider === 'paired-runtime') { + if (!runtimeEnvironmentId) { + throw new Error('The paired runtime browser provider is unavailable.') + } + const { createWebRuntimeSessionBrowserTab } = await import('@/runtime/web-runtime-session') + try { + const created = await createWebRuntimeSessionBrowserTab({ + worktreeId, + environmentId: runtimeEnvironmentId, + url: defaultUrl, + // Why: desktop pane groups are client-owned — the local reconciler only honors a + // recorded clientTargetGroupId; targetGroupId steers snapshot-driven clients instead. + targetGroupId: groupId, + clientTargetGroupId: groupId + }) + if (created) { + get().recordFeatureInteraction('browser-tab-created') + return + } + } catch (error) { + // Why: browser.headless.v1 remotes succeed above, so a failure here is real; surface it instead of a confusing local-tab fallback (split ownership). + console.warn( + '[browser] remote browser tab creation failed:', + error instanceof Error ? error.message : String(error) + ) + throw error + } + throw new Error('The paired runtime could not create a managed browser tab.') + } + get().createBrowserTab(worktreeId, defaultUrl, { + title: translate('auto.store.slices.browser.d175274b6d', 'New Browser Tab'), + focusAddressBar: true, + ...(runtimeEnvironmentId ? { browserRuntimeEnvironmentId: null } : {}), + targetGroupId: groupId + }) + get().recordFeatureInteraction('browser-tab-created') + }, + + openBrowserProfileTabInActiveWorkspace: async (url, profileId) => { + const state = get() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + return false + } + const browserAvailability = getClientCreationActionPolicy(state, worktreeId)[ + 'managed-browser' + ] + if (browserAvailability.state !== 'enabled') { + return false + } + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) + if (browserAvailability.provider === 'paired-runtime') { + if (!runtimeEnvironmentId) { + return false + } + const { createWebRuntimeSessionBrowserTab } = await import('@/runtime/web-runtime-session') + try { + return await createWebRuntimeSessionBrowserTab({ + worktreeId, + environmentId: runtimeEnvironmentId, + url, + profileId + }) + } catch (error) { + console.warn( + '[browser] remote profile tab creation failed:', + error instanceof Error ? error.message : String(error) + ) + return false + } + } + get().createBrowserTab(worktreeId, url, { + activate: true, + sessionProfileId: profileId, + ...(runtimeEnvironmentId ? { browserRuntimeEnvironmentId: null } : {}) + }) + return true + } + } +} diff --git a/src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts b/src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts new file mode 100644 index 00000000000..53ba2faae2e --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts @@ -0,0 +1,141 @@ +import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract' +import type { ClosedBrowserWorkspaceSnapshot } from './browser-slice-contract' +import { findWorkspace } from '../browser-page-records' +import { restoreRecentlyClosedTabPosition } from '../recently-closed-tabs' +import { isLocalBrowserPageOwner } from './browser-host-state' + +export function createBrowserTabFocusActions( + set: BrowserSliceSet, + get: BrowserSliceGet +): Pick { + return { + reopenClosedBrowserTab: (worktreeId) => { + // Why: read and pop atomically inside set() so two rapid Cmd+Shift+T presses can't both restore the same entry (TOCTOU). + let entryToRestore: ClosedBrowserWorkspaceSnapshot | undefined + + set((s) => { + const recentlyClosed = s.recentlyClosedBrowserTabsByWorktree[worktreeId] ?? [] + entryToRestore = recentlyClosed[0] + if (!entryToRestore) { + return s + } + return { + recentlyClosedBrowserTabsByWorktree: { + ...s.recentlyClosedBrowserTabsByWorktree, + [worktreeId]: recentlyClosed.slice(1) + } + } + }) + + if (!entryToRestore) { + return null + } + + const snap = entryToRestore.workspace + const pages = entryToRestore.pages + const sessionProfileId = snap.sessionProfileId ?? null + const sessionPartition = snap.sessionPartition ?? null + + if (pages.length === 0) { + const restored = get().createBrowserTab(worktreeId, snap.url, { + title: snap.title, + activate: true, + sessionProfileId, + sessionPartition, + ...(snap.docLocation ? { docLocation: snap.docLocation } : {}), + targetGroupId: entryToRestore.position?.groupId + }) + restoreRecentlyClosedTabPosition(get, worktreeId, restored.id, entryToRestore.position) + return ( + get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ?? null + ) + } + + // Why: append remaining pages in original order so multi-page workspaces preserve their page sequence. + const [firstPage, ...restPages] = pages + const restored = get().createBrowserTab(worktreeId, firstPage.url, { + title: firstPage.title, + activate: true, + sessionProfileId, + sessionPartition, + targetGroupId: entryToRestore.position?.groupId, + browserRuntimeEnvironmentId: firstPage.browserRuntimeEnvironmentId, + ...(firstPage.docLocation ? { docLocation: firstPage.docLocation } : {}) + }) + + for (const p of restPages) { + get().createBrowserPage(restored.id, p.url, { + activate: false, + title: p.title, + browserRuntimeEnvironmentId: p.browserRuntimeEnvironmentId, + ...(p.docLocation ? { docLocation: p.docLocation } : {}) + }) + } + + // Why: duplicate URLs are valid, so matching by URL can pick the wrong copy; restore preserves order, so map by index. + const activePageId = snap.activePageId + if (activePageId) { + const restoredPages = get().browserPagesByWorkspace[restored.id] ?? [] + const activePageIndex = pages.findIndex((orig) => orig.id === activePageId) + const targetPage = activePageIndex !== -1 ? restoredPages[activePageIndex] : null + if (targetPage && targetPage.id !== restoredPages[0]?.id) { + get().setActiveBrowserPage(restored.id, targetPage.id) + } + } + + restoreRecentlyClosedTabPosition(get, worktreeId, restored.id, entryToRestore.position) + + return get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ?? null + }, + + setActiveBrowserTab: (tabId) => { + set((s) => { + const browserTab = findWorkspace(s.browserTabsByWorktree, tabId) + if (!browserTab) { + return s + } + return { + activeBrowserTabId: tabId, + activeBrowserTabIdByWorktree: { + ...s.activeBrowserTabIdByWorktree, + [browserTab.worktreeId]: tabId + }, + activeTabType: 'browser', + activeTabTypeByWorktree: { + ...s.activeTabTypeByWorktree, + [browserTab.worktreeId]: 'browser' + } + } + }) + + // Why: notify the CDP bridge of the active guest; it keys on page IDs not workspace IDs, so resolve the workspace's active page. + const workspace = findWorkspace(get().browserTabsByWorktree, tabId) + const activePage = workspace?.activePageId + ? (get().browserPagesByWorkspace[workspace.id] ?? []).find( + (page) => page.id === workspace.activePageId + ) + : undefined + if ( + workspace?.activePageId && + isLocalBrowserPageOwner( + get(), + workspace.worktreeId, + activePage?.browserRuntimeEnvironmentId + ) && + typeof window !== 'undefined' && + window.api?.browser + ) { + window.api.browser + .notifyActiveTabChanged({ browserPageId: workspace.activePageId }) + .catch(() => {}) + } + + const item = Object.values(get().unifiedTabsByWorktree) + .flat() + .find((entry) => entry.contentType === 'browser' && entry.entityId === tabId) + if (item) { + get().activateTab(item.id) + } + } + } +} diff --git a/src/renderer/src/store/slices/browser/create-browser-slice.ts b/src/renderer/src/store/slices/browser/create-browser-slice.ts new file mode 100644 index 00000000000..13519fc5b3a --- /dev/null +++ b/src/renderer/src/store/slices/browser/create-browser-slice.ts @@ -0,0 +1,57 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../../types' +import type { BrowserSlice } from './browser-slice-contract' +import { createBrowserHostActions } from './browser-host-actions' +import { createBrowserTabActions } from './browser-tab-actions' +import { createBrowserCloseActions } from './browser-close-actions' +import { createBrowserTabFocusActions } from './browser-tab-focus-actions' +import { createBrowserPageCreateActions } from './browser-page-create-actions' +import { createBrowserPageConversionActions } from './browser-page-conversion-actions' +import { createBrowserPageFocusActions } from './browser-page-focus-actions' +import { createBrowserPageStateActions } from './browser-page-state-actions' +import { createBrowserPageMetadataActions } from './browser-page-metadata-actions' +import { createBrowserHydrationActions } from './browser-hydration-actions' +import { createBrowserProfileListActions } from './browser-profile-list-actions' +import { createBrowserProfileImportActions } from './browser-profile-import-actions' +import { createBrowserCookieImportActions } from './browser-cookie-import-actions' +import { createBrowserHistoryActions } from './browser-history-actions' + +export const createBrowserSlice: StateCreator = (set, get) => ({ + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + browserCertificateFailuresByPageId: {}, + browserAnnotationsByPageId: {}, + remoteBrowserPageHandlesByPageId: {}, + clientHostedBrowserCloseIntentsByEnvironment: {}, + activeBrowserTabId: null, + activeBrowserTabIdByWorktree: {}, + recentlyClosedBrowserTabsByWorktree: {}, + recentlyClosedBrowserPagesByWorkspace: {}, + pendingAddressBarFocusByTabId: {}, + pendingAddressBarFocusByPageId: {}, + browserSessionProfiles: [], + browserSessionProfilesByHostId: {}, + browserSessionHostIdOverride: null, + browserSessionImportState: null, + browserUrlHistory: [], + workspaceDocHistory: [], + defaultBrowserSessionProfileId: null, + defaultBrowserSessionProfileIdByHostId: {}, + detectedBrowsers: [], + detectedBrowsersLoaded: false, + detectedBrowsersHost: null, + ...createBrowserHostActions(set, get), + ...createBrowserTabActions(set, get), + ...createBrowserCloseActions(set, get), + ...createBrowserTabFocusActions(set, get), + ...createBrowserPageCreateActions(set, get), + ...createBrowserPageConversionActions(set, get), + ...createBrowserPageFocusActions(set, get), + ...createBrowserPageStateActions(set, get), + ...createBrowserPageMetadataActions(set, get), + ...createBrowserHydrationActions(set, get), + ...createBrowserProfileListActions(set, get), + ...createBrowserProfileImportActions(set, get), + ...createBrowserCookieImportActions(set, get), + ...createBrowserHistoryActions(set, get) +}) diff --git a/src/renderer/src/store/slices/linear.ts b/src/renderer/src/store/slices/linear.ts index 0f86363b6ef..d9f3fe44dcf 100644 --- a/src/renderer/src/store/slices/linear.ts +++ b/src/renderer/src/store/slices/linear.ts @@ -1,589 +1,29 @@ -/* eslint-disable max-lines -- Why: the Linear slice owns status, workspace - selection, issue caches, and optimistic patch propagation as one store - boundary so cache invalidation stays coherent. */ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import type { LinearIssue } from '../../../../shared/linear/issue-types' -import type { - LinearCustomViewModel, - LinearCustomViewSummary, - LinearProjectDetail, - LinearProjectSummary -} from '../../../../shared/linear/project-types' -import type { - LinearCollectionResult, - LinearConnectionStatus, - LinearTeam, - LinearViewer, - LinearWorkspace, - LinearWorkspaceError, - LinearWorkspaceSelection -} from '../../../../shared/linear/workspace-types' -import type { CacheEntry } from '../github/cache-model' -import { clampLinearIssueListLimit } from '../../../../shared/linear/issue-read-limits' -import { isIntegrationCredentialDecryptionError } from '../../../../shared/integration-credential-errors' -import { clearLinearMetadataCache } from '../../hooks/useIssueMetadata' -import { - isLinearIssueAttributeFilterUnsupportedError, - linearConnect, - linearDisconnect, - linearDisconnectWorkspace, - linearListIssues, - linearSearchIssues, - linearSelectWorkspace, - linearStatus, - linearTestConnection -} from '@/runtime/runtime-linear-client' -import { linearGetIssue } from '@/runtime/runtime-linear-issue-mutations' -import { - linearGetCustomView, - linearGetProject, - linearListCustomViewIssues, - linearListCustomViewProjects, - linearListCustomViews, - linearListProjectIssues, - linearListProjects, - linearListTeams -} from '@/runtime/runtime-linear-project-client' -import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' -import { translate } from '@/i18n/i18n' -import { - getTaskSourceCacheScope, - getTaskSourceRuntimeSettings, - type TaskSourceContext -} from '../../../../shared/task-source-context' -import { - canonicalizeLinearIssueAttributeFilter, - isEmptyLinearIssueAttributeFilter, - linearIssueAttributeFilterSignature, - type LinearIssueAttributeFilter -} from '../../../../shared/linear/issue-attribute-filter' +import { linearListInvalidationToken } from './linear/linear-cache' +import { createLinearConnectionActions } from './linear/linear-slice-connection-actions' +import { createLinearCustomViewDetailActions } from './linear/linear-custom-view-detail-actions' +import { createLinearCustomViewIssueActions } from './linear/linear-custom-view-issue-actions' +import { createLinearCustomViewListActions } from './linear/linear-custom-view-list-actions' +import { createLinearCustomViewProjectActions } from './linear/linear-custom-view-project-actions' +import { createLinearInvalidationActions } from './linear/linear-invalidation-actions' +import { createLinearIssueCacheActions } from './linear/linear-issue-cache-actions' +import { createLinearIssueDetailActions } from './linear/linear-issue-detail-actions' +import { createLinearIssueListActions } from './linear/linear-issue-list-actions' +import { createLinearProjectDetailActions } from './linear/linear-project-detail-actions' +import { createLinearProjectIssueActions } from './linear/linear-project-issue-actions' +import { createLinearProjectListActions } from './linear/linear-project-list-actions' +import { createLinearStatusActions } from './linear/linear-slice-status-actions' +import { createLinearTeamActions } from './linear/linear-team-actions' +import type { LinearSlice } from './linear/linear-slice-contract' -const CACHE_TTL = 60_000 // 60s — same as GitHub work-items revalidation TTL -const TEAM_CACHE_TTL = 10 * 60_000 // Teams change rarely and block visible Linear rows. -const MAX_CACHE_ENTRIES = 500 - -function isFresh(entry: CacheEntry | undefined, ttl = CACHE_TTL): entry is CacheEntry { - return entry !== undefined && Date.now() - entry.fetchedAt < ttl -} - -function evictStaleEntries( - cache: Record>, - maxEntries = MAX_CACHE_ENTRIES -): Record> { - const keys = Object.keys(cache) - if (keys.length <= maxEntries) { - return cache - } - const sorted = keys.sort((a, b) => (cache[a]?.fetchedAt ?? 0) - (cache[b]?.fetchedAt ?? 0)) - const pruned: Record> = {} - for (const key of sorted.slice(sorted.length - maxEntries)) { - pruned[key] = cache[key] - } - return pruned -} - -function looksLikeAuthError(error: unknown): boolean { - const msg = error instanceof Error ? error.message : String(error) - return /authenticat|unauthorized|401/i.test(msg) -} - -type InflightLinearIssueRequest = { - promise: Promise - generation: number - contextKey: string - mutationGeneration: number -} - -function workspaceErrorType(error: unknown): LinearWorkspaceError['type'] { - const record = error as { name?: string; message?: string; status?: number; response?: unknown } - const message = record.message ?? String(error) - const status = - typeof record.status === 'number' - ? record.status - : typeof (record.response as { status?: unknown } | undefined)?.status === 'number' - ? ((record.response as { status: number }).status as number) - : undefined - if (looksLikeAuthError(error)) { - return 'auth' - } - if (status === 429 || /rate/i.test(record.name ?? '')) { - return 'rate_limited' - } - if ((typeof status === 'number' && status >= 500) || /network/i.test(record.name ?? message)) { - return 'network' - } - return 'unknown' -} - -function workspaceErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - -const inflightIssueRequests = new Map() -type InflightLinearListRequest = { - promise: Promise - force: boolean - generation: number - contextKey: string - mutationGeneration: number -} -type InflightLinearPlainListRequest = { - promise: Promise> - force: boolean - generation: number - contextKey: string - mutationGeneration: number -} -type InflightLinearCollectionRequest = { - promise: Promise> - force: boolean - generation: number - contextKey: string - mutationGeneration: number -} -type InflightLinearDetailRequest = { - promise: Promise - force: boolean - contextKey: string - mutationGeneration: number -} - -const inflightSearchRequests = new Map() -const inflightListRequests = new Map() -type InflightLinearTeamRequest = { - promise: Promise - force: boolean - generation: number - contextKey: string - mutationGeneration: number -} - -const inflightTeamRequests = new Map() -const inflightProjectRequests = new Map< - string, - InflightLinearCollectionRequest ->() -const inflightProjectDetailRequests = new Map< - string, - InflightLinearDetailRequest ->() -const inflightProjectIssueRequests = new Map>() -const inflightCustomViewRequests = new Map< - string, - InflightLinearCollectionRequest ->() -const inflightCustomViewDetailRequests = new Map< - string, - InflightLinearDetailRequest ->() -const inflightCustomViewIssueRequests = new Map< - string, - InflightLinearCollectionRequest ->() -const inflightCustomViewProjectRequests = new Map< - string, - InflightLinearCollectionRequest ->() -let inflightStatusRequest: { contextKey: string; promise: Promise } | null = null -let linearStatusReadGeneration = 0 -let linearMutationGeneration = 0 -let linearCacheGeneration = 0 - -function getSelectedWorkspaceId(status: LinearConnectionStatus): LinearWorkspaceSelection | null { - return status.selectedWorkspaceId ?? status.activeWorkspaceId ?? null -} - -function linearSearchCacheKey( - workspaceId: LinearWorkspaceSelection | null | undefined, - query: string, - limit: number -): string { - return `${workspaceId ?? 'default'}::search::${query}::${limit}` -} - -function linearListCacheKey( - workspaceId: LinearWorkspaceSelection | null | undefined, - filter: 'assigned' | 'created' | 'all' | 'completed', - limit: number, - attributeFilter?: LinearIssueAttributeFilter | null -): string { - const attributeSignature = linearIssueAttributeFilterSignature(attributeFilter) - return `${workspaceId ?? 'default'}::list::${filter}::${limit}::${attributeSignature}` -} - -// Why: facet mutations need one source-scoped token TaskPage can observe without -// scattering membership guesses through board/detail components. -const LINEAR_LIST_INVALIDATION_VERSION_CAP = 10_000 -let linearListInvalidationToken: { scope: string; version: number } = { - scope: '', - version: 0 -} - -function linearTeamsCacheKey(workspaceId: LinearWorkspaceSelection | null | undefined): string { - return `${workspaceId ?? 'default'}::teams` -} - -function linearWorkspaceSignature(workspace: LinearWorkspace): string { - return [ - workspace.id, - workspace.organizationId, - workspace.organizationName, - workspace.organizationUrlKey ?? '', - workspace.displayName, - workspace.email ?? '', - workspace.credentialRevision ?? 0 - ].join('\u001f') -} - -function linearStatusScopeSignature(status: LinearConnectionStatus): string { - return JSON.stringify({ - connected: status.connected, - credentialError: status.credentialError ?? null, - activeWorkspaceId: status.activeWorkspaceId ?? null, - selectedWorkspaceId: getSelectedWorkspaceId(status), - viewer: status.viewer - ? [ - status.viewer.organizationId ?? '', - status.viewer.organizationName, - status.viewer.organizationUrlKey ?? '', - status.viewer.displayName, - status.viewer.email ?? '' - ] - : null, - workspaces: (status.workspaces ?? []).map(linearWorkspaceSignature) - }) -} - -function clearLinearRequestMaps(): void { - inflightIssueRequests.clear() - inflightSearchRequests.clear() - inflightListRequests.clear() - inflightTeamRequests.clear() - inflightProjectRequests.clear() - inflightProjectDetailRequests.clear() - inflightProjectIssueRequests.clear() - inflightCustomViewRequests.clear() - inflightCustomViewDetailRequests.clear() - inflightCustomViewIssueRequests.clear() - inflightCustomViewProjectRequests.clear() -} - -function invalidateLinearCaches(): void { - linearCacheGeneration += 1 - clearLinearRequestMaps() - clearLinearMetadataCache() -} - -function clearLinearIssueCollectionRequestMaps(): void { - inflightSearchRequests.clear() - inflightListRequests.clear() - inflightProjectIssueRequests.clear() - inflightCustomViewIssueRequests.clear() -} - -function shouldRefreshStatusAfterRead( - workspaceId: LinearWorkspaceSelection | null | undefined, - status: LinearConnectionStatus -): boolean { - // Why: 'all' reads can hide per-workspace decrypt failures, and a visible - // credential error may have been cleared by a successful credential read. - return workspaceId === 'all' || status.credentialError !== undefined -} - -function linearCollectionCacheKey( - workspaceId: LinearWorkspaceSelection | null | undefined, - mode: string, - ...parts: (string | number | null | undefined)[] -): string { - return [workspaceId ?? 'default', mode, ...parts.map((part) => part ?? '')].join('::') -} - -function emptyLinearCollection(): LinearCollectionResult { - return { items: [] } -} - -function collectionWithWorkspaceError( - fallback: LinearCollectionResult, - workspaceId: string, - error: unknown -): LinearCollectionResult { - const existingErrors = (fallback.errors ?? []).filter((item) => item.workspaceId !== workspaceId) - return { - ...fallback, - errors: [ - ...existingErrors, - { - workspaceId, - type: workspaceErrorType(error), - message: workspaceErrorMessage(error) || 'Linear request failed.' - } - ] - } -} - -function largestCachedCollectionBelowLimit( - cache: Record>>, - workspaceId: LinearWorkspaceSelection | null | undefined, - mode: string, - scopeId: string, - limit: number -): LinearCollectionResult | null { - const keyPrefix = `${linearCollectionCacheKey(workspaceId, mode, scopeId)}::` - let best: { limit: number; data: LinearCollectionResult } | null = null - for (const [key, entry] of Object.entries(cache)) { - if (!entry?.data || !key.startsWith(keyPrefix)) { - continue - } - const cachedLimit = Number(key.slice(keyPrefix.length)) - if (!Number.isFinite(cachedLimit) || cachedLimit >= limit) { - continue - } - if (!best || cachedLimit > best.limit) { - best = { limit: cachedLimit, data: entry.data } - } - } - return best?.data ?? null -} - -function patchLinearIssueCollectionCache( - cache: Record>>, - issueId: string, - patch: Partial, - canPatchCacheKey: (key: string) => boolean -): { - cache: Record>> - changed: boolean -} { - let changed = false - const nextCache = { ...cache } - for (const [key, entry] of Object.entries(nextCache)) { - if (!canPatchCacheKey(key) || !entry?.data) { - continue - } - const idx = entry.data.items.findIndex((item) => item.id === issueId) - if (idx === -1) { - continue - } - const updatedItems = [...entry.data.items] - updatedItems[idx] = { ...updatedItems[idx], ...patch } - nextCache[key] = { - ...entry, - data: { ...entry.data, items: updatedItems } - } - changed = true - } - return { cache: nextCache, changed } -} - -export type LinearIssueListReadArgs = { - kind: 'list' - filter?: 'assigned' | 'created' | 'all' | 'completed' - limit?: number - attributeFilter?: LinearIssueAttributeFilter | null -} - -export type LinearIssueReadArgs = - | { kind: 'search'; query: string; limit?: number } - | LinearIssueListReadArgs - -type LinearFetchOptions = { force?: boolean; sourceContext?: TaskSourceContext | null } -type LinearPatchOptions = { sourceContext?: TaskSourceContext | null } - -function normalizeListAttributeFilter( - attributeFilter?: LinearIssueAttributeFilter | null -): LinearIssueAttributeFilter | undefined { - if (!attributeFilter || isEmptyLinearIssueAttributeFilter(attributeFilter)) { - return undefined - } - return canonicalizeLinearIssueAttributeFilter(attributeFilter) -} - -type LinearReadScope = { - settings: AppState['settings'] | TaskSourceContext | null - contextKey: string - cachePrefix: string | null - explicitSource: boolean -} - -function beginLinearMutation(): number { - linearMutationGeneration += 1 - inflightStatusRequest = null - return linearMutationGeneration -} - -function isCurrentLinearMutation(generation: number): boolean { - return generation === linearMutationGeneration -} - -function isCurrentLinearRuntimeContext( - contextKey: string, - settings: AppState['settings'] -): boolean { - return getProviderRuntimeContextKey(settings) === contextKey -} - -function canWriteLinearReadResult( - contextKey: string, - generation: number, - mutationGeneration: number, - settings: AppState['settings'], - explicitSource = false -): boolean { - return ( - generation === linearCacheGeneration && - mutationGeneration === linearMutationGeneration && - (explicitSource || isCurrentLinearRuntimeContext(contextKey, settings)) - ) -} - -function getLinearReadScope( - settings: AppState['settings'], - sourceContext?: TaskSourceContext | null -): LinearReadScope { - if (!sourceContext) { - return { - settings, - contextKey: getProviderRuntimeContextKey(settings), - cachePrefix: null, - explicitSource: false - } - } - const runtimeSettings = getTaskSourceRuntimeSettings(sourceContext) - return { - settings: sourceContext, - contextKey: `${getProviderRuntimeContextKey(runtimeSettings)}::${getTaskSourceCacheScope(sourceContext)}`, - cachePrefix: getTaskSourceCacheScope(sourceContext), - explicitSource: true - } -} - -function scopedLinearCacheKey(scope: LinearReadScope, key: string): string { - return scope.cachePrefix ? `${scope.cachePrefix}::${key}` : key -} - -export type LinearSlice = { - linearStatus: LinearConnectionStatus - linearStatusChecked: boolean - linearStatusContextKey: string | null - linearIssueCache: Record> - linearSearchCache: Record> - linearListCache: Record>> - linearTeamCache: Record> - linearProjectCache: Record>> - linearProjectDetailCache: Record> - linearProjectIssueCache: Record>> - linearCustomViewCache: Record>> - linearCustomViewDetailCache: Record> - linearCustomViewIssueCache: Record>> - linearCustomViewProjectCache: Record< - string, - CacheEntry> - > - - checkLinearConnection: (force?: boolean) => Promise - connectLinear: ( - apiKey: string - ) => Promise<{ ok: true; viewer: LinearViewer } | { ok: false; error: string }> - testLinearConnection: ( - workspaceId?: string | null - ) => Promise<{ ok: true; viewer: LinearViewer } | { ok: false; error: string }> - selectLinearWorkspace: (workspaceId: LinearWorkspaceSelection) => Promise - disconnectLinear: () => Promise - disconnectLinearWorkspace: (workspaceId: string) => Promise - fetchLinearIssue: ( - id: string, - workspaceId?: string | null, - options?: LinearFetchOptions - ) => Promise - refreshLinearIssue: ( - id: string, - workspaceId?: string | null, - options?: LinearFetchOptions - ) => Promise - getCachedLinearIssues: ( - args: LinearIssueReadArgs, - options?: Pick - ) => LinearIssue[] | LinearCollectionResult | null - prefetchLinearIssues: (args: LinearIssueReadArgs, options?: LinearFetchOptions) => void - searchLinearIssues: ( - query: string, - limit?: number, - options?: LinearFetchOptions - ) => Promise - listLinearIssues: ( - args: LinearIssueListReadArgs, - options?: LinearFetchOptions - ) => Promise> - linearListInvalidationToken: { scope: string; version: number } - invalidateLinearIssueLists: (options?: Pick) => void - getCachedLinearTeams: ( - workspaceId?: LinearWorkspaceSelection | null, - options?: Pick - ) => LinearTeam[] | null - listLinearTeams: ( - workspaceId?: LinearWorkspaceSelection | null, - options?: LinearFetchOptions - ) => Promise - getCachedLinearProjects: ( - query?: string, - limit?: number, - workspaceId?: LinearWorkspaceSelection | null, - options?: Pick - ) => LinearCollectionResult | null - listLinearProjects: ( - query?: string, - limit?: number, - workspaceId?: LinearWorkspaceSelection | null, - options?: LinearFetchOptions - ) => Promise> - fetchLinearProject: ( - id: string, - workspaceId: string, - options?: LinearFetchOptions - ) => Promise - listLinearProjectIssues: ( - projectId: string, - workspaceId: string, - limit?: number, - options?: LinearFetchOptions - ) => Promise> - getCachedLinearCustomViews: ( - model: LinearCustomViewModel, - limit?: number, - workspaceId?: LinearWorkspaceSelection | null, - options?: Pick - ) => LinearCollectionResult | null - listLinearCustomViews: ( - model: LinearCustomViewModel, - limit?: number, - workspaceId?: LinearWorkspaceSelection | null, - options?: LinearFetchOptions - ) => Promise> - fetchLinearCustomView: ( - viewId: string, - workspaceId: string, - model: LinearCustomViewModel, - options?: LinearFetchOptions - ) => Promise - listLinearCustomViewIssues: ( - viewId: string, - workspaceId: string, - limit?: number, - options?: LinearFetchOptions - ) => Promise> - listLinearCustomViewProjects: ( - viewId: string, - workspaceId: string, - limit?: number, - options?: LinearFetchOptions - ) => Promise> - patchLinearIssue: ( - issueId: string, - patch: Partial, - options?: LinearPatchOptions - ) => void -} +export type { + LinearFetchOptions, + LinearIssueListReadArgs, + LinearIssueReadArgs, + LinearPatchOptions, + LinearSlice +} from './linear/linear-slice-contract' export const createLinearSlice: StateCreator = (set, get) => ({ linearStatus: { connected: false, viewer: null }, @@ -600,1649 +40,19 @@ export const createLinearSlice: StateCreator = (s linearCustomViewDetailCache: {}, linearCustomViewIssueCache: {}, linearCustomViewProjectCache: {}, - linearListInvalidationToken: linearListInvalidationToken, - - checkLinearConnection: async (force = false) => { - const contextKey = getProviderRuntimeContextKey(get().settings) - if (inflightStatusRequest && !force && inflightStatusRequest.contextKey === contextKey) { - return inflightStatusRequest.promise - } - if (get().linearStatusContextKey !== contextKey) { - set({ linearStatusChecked: false }) - } - - const mutationGeneration = linearMutationGeneration - const statusReadGeneration = (linearStatusReadGeneration += 1) - const request = linearStatus(get().settings) - .then((status) => { - if ( - mutationGeneration !== linearMutationGeneration || - statusReadGeneration !== linearStatusReadGeneration || - !isCurrentLinearRuntimeContext(contextKey, get().settings) - ) { - return - } - const typedStatus = status as LinearConnectionStatus - const prev = get().linearStatus - const prevScopeSignature = linearStatusScopeSignature(prev) - const nextScopeSignature = linearStatusScopeSignature(typedStatus) - if (prevScopeSignature !== nextScopeSignature) { - invalidateLinearCaches() - set({ - linearStatus: typedStatus, - linearIssueCache: {}, - linearSearchCache: {}, - linearListCache: {}, - linearTeamCache: {}, - linearProjectCache: {}, - linearProjectDetailCache: {}, - linearProjectIssueCache: {}, - linearCustomViewCache: {}, - linearCustomViewDetailCache: {}, - linearCustomViewIssueCache: {}, - linearCustomViewProjectCache: {}, - linearStatusChecked: true, - linearStatusContextKey: contextKey - }) - } else if (!get().linearStatusChecked) { - set({ linearStatusChecked: true, linearStatusContextKey: contextKey }) - } else if (get().linearStatusContextKey !== contextKey) { - set({ linearStatusContextKey: contextKey }) - } - }) - .catch(() => { - if ( - mutationGeneration !== linearMutationGeneration || - statusReadGeneration !== linearStatusReadGeneration || - !isCurrentLinearRuntimeContext(contextKey, get().settings) - ) { - return - } - if (get().linearStatus.connected) { - invalidateLinearCaches() - set({ - linearStatus: { connected: false, viewer: null }, - linearIssueCache: {}, - linearSearchCache: {}, - linearListCache: {}, - linearTeamCache: {}, - linearProjectCache: {}, - linearProjectDetailCache: {}, - linearProjectIssueCache: {}, - linearCustomViewCache: {}, - linearCustomViewDetailCache: {}, - linearCustomViewIssueCache: {}, - linearCustomViewProjectCache: {}, - linearStatusChecked: true, - linearStatusContextKey: contextKey - }) - } else if (!get().linearStatusChecked) { - set({ linearStatusChecked: true, linearStatusContextKey: contextKey }) - } else if (get().linearStatusContextKey !== contextKey) { - set({ linearStatusContextKey: contextKey }) - } - }) - .finally(() => { - if ( - statusReadGeneration === linearStatusReadGeneration && - inflightStatusRequest?.promise === request - ) { - inflightStatusRequest = null - } - }) - inflightStatusRequest = { contextKey, promise: request } - - return request - }, - - testLinearConnection: async (workspaceId) => { - const requestGeneration = beginLinearMutation() - const contextKey = getProviderRuntimeContextKey(get().settings) - try { - const result = (await linearTestConnection(get().settings, workspaceId)) as - | { ok: true; viewer: LinearViewer } - | { ok: false; error: string } - if ( - !isCurrentLinearMutation(requestGeneration) || - !isCurrentLinearRuntimeContext(contextKey, get().settings) - ) { - return result - } - const status = await linearStatus(get().settings) - if ( - isCurrentLinearMutation(requestGeneration) && - isCurrentLinearRuntimeContext(contextKey, get().settings) - ) { - const prev = get().linearStatus - if (linearStatusScopeSignature(prev) !== linearStatusScopeSignature(status)) { - invalidateLinearCaches() - set({ - linearStatus: status, - linearIssueCache: {}, - linearSearchCache: {}, - linearListCache: {}, - linearTeamCache: {}, - linearProjectCache: {}, - linearProjectDetailCache: {}, - linearProjectIssueCache: {}, - linearCustomViewCache: {}, - linearCustomViewDetailCache: {}, - linearCustomViewIssueCache: {}, - linearCustomViewProjectCache: {}, - linearStatusChecked: true, - linearStatusContextKey: contextKey - }) - } else { - set({ - linearStatus: status, - linearStatusChecked: true, - linearStatusContextKey: contextKey - }) - } - } - return result - } catch (error) { - const message = error instanceof Error ? error.message : 'Test failed' - return { ok: false as const, error: message } - } - }, - - connectLinear: async (apiKey: string) => { - const requestGeneration = beginLinearMutation() - const contextKey = getProviderRuntimeContextKey(get().settings) - try { - const result = await linearConnect(get().settings, apiKey) - if ( - result.ok && - isCurrentLinearMutation(requestGeneration) && - isCurrentLinearRuntimeContext(contextKey, get().settings) - ) { - invalidateLinearCaches() - set({ - linearIssueCache: {}, - linearSearchCache: {}, - linearListCache: {}, - linearTeamCache: {}, - linearProjectCache: {}, - linearProjectDetailCache: {}, - linearProjectIssueCache: {}, - linearCustomViewCache: {}, - linearCustomViewDetailCache: {}, - linearCustomViewIssueCache: {}, - linearCustomViewProjectCache: {} - }) - const status = await linearStatus(get().settings) - if ( - !isCurrentLinearMutation(requestGeneration) || - !isCurrentLinearRuntimeContext(contextKey, get().settings) - ) { - return { - ok: false as const, - error: translate( - 'auto.store.slices.linear.37d36984d0', - 'Linear connection was superseded by a newer request.' - ) - } - } - set({ - linearStatus: status, - linearStatusChecked: true, - linearStatusContextKey: contextKey - }) - } else if (result.ok) { - return { - ok: false as const, - error: translate( - 'auto.store.slices.linear.37d36984d0', - 'Linear connection was superseded by a newer request.' - ) - } - } - return result as { ok: true; viewer: LinearViewer } | { ok: false; error: string } - } catch (error) { - const message = error instanceof Error ? error.message : 'Connection failed' - return { ok: false as const, error: message } - } - }, - - selectLinearWorkspace: async (workspaceId) => { - const requestGeneration = beginLinearMutation() - const contextKey = getProviderRuntimeContextKey(get().settings) - const status = await linearSelectWorkspace(get().settings, workspaceId) - if ( - !isCurrentLinearMutation(requestGeneration) || - !isCurrentLinearRuntimeContext(contextKey, get().settings) - ) { - return - } - invalidateLinearCaches() - set({ - linearStatus: status, - linearIssueCache: {}, - linearSearchCache: {}, - linearListCache: {}, - linearTeamCache: {}, - linearProjectCache: {}, - linearProjectDetailCache: {}, - linearProjectIssueCache: {}, - linearCustomViewCache: {}, - linearCustomViewDetailCache: {}, - linearCustomViewIssueCache: {}, - linearCustomViewProjectCache: {}, - linearStatusChecked: true, - linearStatusContextKey: contextKey - }) - }, - - disconnectLinear: async () => { - const requestGeneration = beginLinearMutation() - const contextKey = getProviderRuntimeContextKey(get().settings) - await linearDisconnect(get().settings) - if ( - !isCurrentLinearMutation(requestGeneration) || - !isCurrentLinearRuntimeContext(contextKey, get().settings) - ) { - return - } - invalidateLinearCaches() - set({ - linearStatus: { connected: false, viewer: null }, - linearIssueCache: {}, - linearSearchCache: {}, - linearListCache: {}, - linearTeamCache: {}, - linearProjectCache: {}, - linearProjectDetailCache: {}, - linearProjectIssueCache: {}, - linearCustomViewCache: {}, - linearCustomViewDetailCache: {}, - linearCustomViewIssueCache: {}, - linearCustomViewProjectCache: {}, - linearStatusChecked: true, - linearStatusContextKey: contextKey - }) - }, - - disconnectLinearWorkspace: async (workspaceId) => { - const requestGeneration = beginLinearMutation() - const contextKey = getProviderRuntimeContextKey(get().settings) - await linearDisconnectWorkspace(get().settings, workspaceId) - if ( - !isCurrentLinearMutation(requestGeneration) || - !isCurrentLinearRuntimeContext(contextKey, get().settings) - ) { - return - } - const status = await linearStatus(get().settings) - if ( - !isCurrentLinearMutation(requestGeneration) || - !isCurrentLinearRuntimeContext(contextKey, get().settings) - ) { - return - } - invalidateLinearCaches() - set({ - linearStatus: status, - linearIssueCache: {}, - linearSearchCache: {}, - linearListCache: {}, - linearTeamCache: {}, - linearProjectCache: {}, - linearProjectDetailCache: {}, - linearProjectIssueCache: {}, - linearCustomViewCache: {}, - linearCustomViewDetailCache: {}, - linearCustomViewIssueCache: {}, - linearCustomViewProjectCache: {}, - linearStatusChecked: true, - linearStatusContextKey: contextKey - }) - }, - - fetchLinearIssue: async ( - id: string, - workspaceId?: string | null, - options?: LinearFetchOptions - ) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const { contextKey } = scope - const issueCacheKey = scopedLinearCacheKey(scope, `${workspaceId ?? 'selected'}::${id}`) - const cached = get().linearIssueCache[issueCacheKey] ?? get().linearIssueCache[id] - if (isFresh(cached)) { - return cached.data - } - - const inflight = inflightIssueRequests.get(issueCacheKey) - if ( - inflight && - inflight.contextKey === contextKey && - inflight.mutationGeneration === linearMutationGeneration - ) { - return inflight.promise - } - - let entry: InflightLinearIssueRequest - const requestCacheGeneration = linearCacheGeneration - const requestMutationGeneration = linearMutationGeneration - const promise = linearGetIssue(scope.settings, id, workspaceId) - .then((issue) => { - const data = issue as LinearIssue | null - if ( - inflightIssueRequests.get(issueCacheKey) === entry && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - set((s) => ({ - linearIssueCache: evictStaleEntries({ - ...s.linearIssueCache, - [issueCacheKey]: { data, fetchedAt: Date.now() } - }) - })) - } - return data - }) - .catch((error) => { - console.warn('[linear] fetchLinearIssue failed:', error) - if ( - (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - return null - }) - .finally(() => { - if (inflightIssueRequests.get(issueCacheKey) === entry) { - inflightIssueRequests.delete(issueCacheKey) - } - if ( - shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - }) - - entry = { - promise, - generation: requestCacheGeneration, - contextKey, - mutationGeneration: requestMutationGeneration - } - inflightIssueRequests.set(issueCacheKey, entry) - return promise - }, - - refreshLinearIssue: async ( - id: string, - workspaceId?: string | null, - options?: LinearFetchOptions - ) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const issueCacheKey = scopedLinearCacheKey(scope, `${workspaceId ?? 'selected'}::${id}`) - inflightIssueRequests.delete(issueCacheKey) - clearLinearIssueCollectionRequestMaps() - set((s) => { - const nextIssueCache = { ...s.linearIssueCache } - for (const [key, entry] of Object.entries(nextIssueCache)) { - if ( - key === issueCacheKey || - key === id || - entry?.data?.id === id || - entry?.data?.identifier === id - ) { - delete nextIssueCache[key] - } - } - return { - linearIssueCache: nextIssueCache, - linearSearchCache: {}, - linearListCache: {}, - linearProjectIssueCache: {}, - linearCustomViewIssueCache: {} - } - }) - return get().fetchLinearIssue(id, workspaceId, options) - }, - - getCachedLinearIssues: (args, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const workspaceId = getSelectedWorkspaceId(get().linearStatus) - if (args.kind === 'search') { - const cacheKey = scopedLinearCacheKey( - scope, - linearSearchCacheKey(workspaceId, args.query, args.limit ?? 20) - ) - return get().linearSearchCache[cacheKey]?.data ?? null - } - const limit = clampLinearIssueListLimit(args.limit) - const attributeFilter = normalizeListAttributeFilter(args.attributeFilter) - const cacheKey = scopedLinearCacheKey( - scope, - linearListCacheKey(workspaceId, args.filter ?? 'assigned', limit, attributeFilter) - ) - return get().linearListCache[cacheKey]?.data ?? null - }, - - prefetchLinearIssues: (args, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const { contextKey } = scope - const workspaceId = getSelectedWorkspaceId(get().linearStatus) - if (args.kind === 'search') { - const limit = args.limit ?? 20 - const cacheKey = scopedLinearCacheKey( - scope, - linearSearchCacheKey(workspaceId, args.query, limit) - ) - const inflight = inflightSearchRequests.get(cacheKey) - if ( - isFresh(get().linearSearchCache[cacheKey]) || - (inflight && - inflight.contextKey === contextKey && - inflight.mutationGeneration === linearMutationGeneration) - ) { - return - } - void get() - .searchLinearIssues(args.query, limit, options) - .catch(() => {}) - return - } - const limit = clampLinearIssueListLimit(args.limit) - const attributeFilter = normalizeListAttributeFilter(args.attributeFilter) - const listArgs: LinearIssueListReadArgs = { - kind: 'list', - filter: args.filter, - limit, - attributeFilter - } - const cacheKey = scopedLinearCacheKey( - scope, - linearListCacheKey(workspaceId, args.filter ?? 'assigned', limit, attributeFilter) - ) - const inflight = inflightListRequests.get(cacheKey) - if ( - isFresh(get().linearListCache[cacheKey]) || - (inflight && - inflight.contextKey === contextKey && - inflight.mutationGeneration === linearMutationGeneration) - ) { - return - } - void get() - .listLinearIssues(listArgs, options) - .catch(() => {}) - }, - - searchLinearIssues: async (query: string, limit = 20, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const { contextKey } = scope - const workspaceId = getSelectedWorkspaceId(get().linearStatus) - const cacheKey = scopedLinearCacheKey(scope, linearSearchCacheKey(workspaceId, query, limit)) - const cached = get().linearSearchCache[cacheKey] - if (!options?.force && isFresh(cached)) { - return cached.data ?? [] - } - - const inflight = inflightSearchRequests.get(cacheKey) - if ( - inflight && - inflight.contextKey === contextKey && - inflight.mutationGeneration === linearMutationGeneration && - (!options?.force || inflight.force) - ) { - return inflight.promise - } - - let entry: InflightLinearListRequest - const requestCacheGeneration = linearCacheGeneration - const requestMutationGeneration = linearMutationGeneration - const promise = linearSearchIssues(scope.settings, query, limit, workspaceId) - .then((issues) => { - const data = issues as LinearIssue[] - if ( - inflightSearchRequests.get(cacheKey) === entry && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - set((s) => ({ - linearSearchCache: evictStaleEntries({ - ...s.linearSearchCache, - [cacheKey]: { data, fetchedAt: Date.now() } - }) - })) - } - return data - }) - .catch((error) => { - console.warn('[linear] searchLinearIssues failed:', error) - if ( - (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - if (!shouldRefreshStatusAfterRead(workspaceId, get().linearStatus)) { - void get().checkLinearConnection(true) - } - return [] - } - return get().linearSearchCache[cacheKey]?.data ?? [] - }) - .finally(() => { - if (inflightSearchRequests.get(cacheKey) === entry) { - inflightSearchRequests.delete(cacheKey) - } - if ( - shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - }) - - entry = { - promise, - force: Boolean(options?.force), - generation: requestCacheGeneration, - contextKey, - mutationGeneration: requestMutationGeneration - } - inflightSearchRequests.set(cacheKey, entry) - return promise - }, - - listLinearIssues: async (args, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const { contextKey } = scope - const workspaceId = getSelectedWorkspaceId(get().linearStatus) - const filter = args.filter ?? 'assigned' - const effectiveLimit = clampLinearIssueListLimit(args.limit) - const attributeFilter = normalizeListAttributeFilter(args.attributeFilter) - const cacheKey = scopedLinearCacheKey( - scope, - linearListCacheKey(workspaceId, filter, effectiveLimit, attributeFilter) - ) - const cached = get().linearListCache[cacheKey] - if (!options?.force && isFresh(cached)) { - return cached.data ?? emptyLinearCollection() - } - - const inflight = inflightListRequests.get(cacheKey) - if ( - inflight && - inflight.contextKey === contextKey && - inflight.mutationGeneration === linearMutationGeneration && - (!options?.force || inflight.force) - ) { - return inflight.promise - } - - let entry: InflightLinearPlainListRequest - const requestCacheGeneration = linearCacheGeneration - const requestMutationGeneration = linearMutationGeneration - const promise: Promise> = linearListIssues( - scope.settings, - filter, - effectiveLimit, - workspaceId, - attributeFilter - ) - .then((result) => { - const data = result as LinearCollectionResult - if ( - inflightListRequests.get(cacheKey) === entry && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - set((s) => ({ - linearListCache: evictStaleEntries({ - ...s.linearListCache, - [cacheKey]: { data, fetchedAt: Date.now() } - }) - })) - } - return data - }) - .catch((error) => { - console.warn('[linear] listLinearIssues failed:', error) - // Why: capability mismatch is actionable (update remote runtime). Swallowing - // it as [] would look like "no issues match filters" and hide the fix. - if (isLinearIssueAttributeFilterUnsupportedError(error)) { - throw error - } - if ( - (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - if (!shouldRefreshStatusAfterRead(workspaceId, get().linearStatus)) { - void get().checkLinearConnection(true) - } - return emptyLinearCollection() - } - return get().linearListCache[cacheKey]?.data ?? emptyLinearCollection() - }) - .finally(() => { - if (inflightListRequests.get(cacheKey) === entry) { - inflightListRequests.delete(cacheKey) - } - if ( - shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - }) - - entry = { - promise, - force: Boolean(options?.force), - generation: requestCacheGeneration, - contextKey, - mutationGeneration: requestMutationGeneration - } - inflightListRequests.set(cacheKey, entry) - return promise - }, - - getCachedLinearTeams: (workspaceId, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const key = linearTeamsCacheKey(workspaceId ?? getSelectedWorkspaceId(get().linearStatus)) - return get().linearTeamCache[scopedLinearCacheKey(scope, key)]?.data ?? null - }, - - listLinearTeams: async (workspaceId, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const { contextKey } = scope - const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) - const cacheKey = scopedLinearCacheKey(scope, linearTeamsCacheKey(resolvedWorkspaceId)) - const cached = get().linearTeamCache[cacheKey] - if (!options?.force && isFresh(cached, TEAM_CACHE_TTL)) { - return cached.data ?? [] - } - - const inflight = inflightTeamRequests.get(cacheKey) - if ( - inflight && - inflight.contextKey === contextKey && - inflight.mutationGeneration === linearMutationGeneration && - (!options?.force || inflight.force) - ) { - return inflight.promise - } - - let entry: InflightLinearTeamRequest - const requestCacheGeneration = linearCacheGeneration - const requestMutationGeneration = linearMutationGeneration - const promise = linearListTeams(scope.settings, resolvedWorkspaceId) - .then((teams) => { - const data = teams as LinearTeam[] - if ( - inflightTeamRequests.get(cacheKey) === entry && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - set((s) => ({ - linearTeamCache: evictStaleEntries({ - ...s.linearTeamCache, - [cacheKey]: { data, fetchedAt: Date.now() } - }) - })) - } - return data - }) - .catch((error) => { - console.warn('[linear] listLinearTeams failed:', error) - if ( - (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - if (!shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus)) { - void get().checkLinearConnection(true) - } - return [] - } - return get().linearTeamCache[cacheKey]?.data ?? [] - }) - .finally(() => { - if (inflightTeamRequests.get(cacheKey) === entry) { - inflightTeamRequests.delete(cacheKey) - } - if ( - shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - }) - - entry = { - promise, - force: Boolean(options?.force), - generation: requestCacheGeneration, - contextKey, - mutationGeneration: requestMutationGeneration - } - inflightTeamRequests.set(cacheKey, entry) - return promise - }, - - getCachedLinearProjects: (query, limit = 20, workspaceId, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) - const cacheKey = linearCollectionCacheKey(resolvedWorkspaceId, 'projects', query?.trim(), limit) - return get().linearProjectCache[scopedLinearCacheKey(scope, cacheKey)]?.data ?? null - }, - - listLinearProjects: async (query, limit = 20, workspaceId, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const { contextKey } = scope - const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) - const trimmed = query?.trim() || undefined - const cacheKey = scopedLinearCacheKey( - scope, - linearCollectionCacheKey(resolvedWorkspaceId, 'projects', trimmed, limit) - ) - const cached = get().linearProjectCache[cacheKey] - if (!options?.force && isFresh(cached)) { - return cached.data ?? emptyLinearCollection() - } - - const inflight = inflightProjectRequests.get(cacheKey) - if ( - inflight && - inflight.contextKey === contextKey && - inflight.mutationGeneration === linearMutationGeneration && - (!options?.force || inflight.force) - ) { - return inflight.promise - } - - let entry: InflightLinearCollectionRequest - const requestCacheGeneration = linearCacheGeneration - const requestMutationGeneration = linearMutationGeneration - const promise = linearListProjects(scope.settings, trimmed, limit, resolvedWorkspaceId, { - force: options?.force - }) - .then((result) => { - if ( - inflightProjectRequests.get(cacheKey) === entry && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - set((s) => ({ - linearProjectCache: evictStaleEntries({ - ...s.linearProjectCache, - [cacheKey]: { data: result, fetchedAt: Date.now() } - }) - })) - } - return result - }) - .catch((error) => { - console.warn('[linear] listLinearProjects failed:', error) - if ( - (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - const fallback = - get().linearProjectCache[cacheKey]?.data ?? emptyLinearCollection() - return collectionWithWorkspaceError(fallback, resolvedWorkspaceId ?? 'default', error) - }) - .finally(() => { - if (inflightProjectRequests.get(cacheKey) === entry) { - inflightProjectRequests.delete(cacheKey) - } - if ( - shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - }) - - entry = { - promise, - force: Boolean(options?.force), - generation: requestCacheGeneration, - contextKey, - mutationGeneration: requestMutationGeneration - } - inflightProjectRequests.set(cacheKey, entry) - return promise - }, - - fetchLinearProject: async (id, workspaceId, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const { contextKey } = scope - const cacheKey = scopedLinearCacheKey( - scope, - linearCollectionCacheKey(workspaceId, 'project-detail', id) - ) - const cached = get().linearProjectDetailCache[cacheKey] - if (!options?.force && isFresh(cached)) { - return cached.data - } - - const inflight = inflightProjectDetailRequests.get(cacheKey) - if ( - inflight && - inflight.contextKey === contextKey && - inflight.mutationGeneration === linearMutationGeneration && - (!options?.force || inflight.force) - ) { - return inflight.promise - } - - let entry: InflightLinearDetailRequest - const requestCacheGeneration = linearCacheGeneration - const requestMutationGeneration = linearMutationGeneration - const promise = linearGetProject(scope.settings, id, workspaceId, { - force: options?.force - }) - .then((project) => { - if ( - inflightProjectDetailRequests.get(cacheKey) === entry && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - set((s) => ({ - linearProjectDetailCache: evictStaleEntries({ - ...s.linearProjectDetailCache, - [cacheKey]: { data: project, fetchedAt: Date.now() } - }) - })) - } - return project - }) - .catch((error) => { - console.warn('[linear] fetchLinearProject failed:', error) - if ( - (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - if (options?.force) { - throw error - } - const cachedResult = get().linearProjectDetailCache[cacheKey] - if (cachedResult) { - return cachedResult.data - } - throw error - }) - .finally(() => { - if (inflightProjectDetailRequests.get(cacheKey) === entry) { - inflightProjectDetailRequests.delete(cacheKey) - } - if ( - shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - }) - - entry = { - promise, - force: Boolean(options?.force), - contextKey, - mutationGeneration: requestMutationGeneration - } - inflightProjectDetailRequests.set(cacheKey, entry) - return promise - }, - - listLinearProjectIssues: async (projectId, workspaceId, limit = 20, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const { contextKey } = scope - const effectiveLimit = clampLinearIssueListLimit(limit) - const cacheKey = scopedLinearCacheKey( - scope, - linearCollectionCacheKey(workspaceId, 'project-issues', projectId, effectiveLimit) - ) - const cached = get().linearProjectIssueCache[cacheKey] - if (!options?.force && isFresh(cached)) { - return cached.data ?? emptyLinearCollection() - } - - const inflight = inflightProjectIssueRequests.get(cacheKey) - if ( - inflight && - inflight.contextKey === contextKey && - inflight.mutationGeneration === linearMutationGeneration && - (!options?.force || inflight.force) - ) { - return inflight.promise - } - - let entry: InflightLinearCollectionRequest - const requestCacheGeneration = linearCacheGeneration - const requestMutationGeneration = linearMutationGeneration - const promise = linearListProjectIssues( - scope.settings, - projectId, - effectiveLimit, - workspaceId, - { - force: options?.force - } - ) - .then((result) => { - if ( - inflightProjectIssueRequests.get(cacheKey) === entry && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - set((s) => ({ - linearProjectIssueCache: evictStaleEntries({ - ...s.linearProjectIssueCache, - [cacheKey]: { data: result, fetchedAt: Date.now() } - }) - })) - } - return result - }) - .catch((error) => { - console.warn('[linear] listLinearProjectIssues failed:', error) - if ( - (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - const fallback = - get().linearProjectIssueCache[cacheKey]?.data ?? - largestCachedCollectionBelowLimit( - get().linearProjectIssueCache, - workspaceId, - 'project-issues', - projectId, - effectiveLimit - ) ?? - emptyLinearCollection() - return collectionWithWorkspaceError(fallback, workspaceId, error) - }) - .finally(() => { - if (inflightProjectIssueRequests.get(cacheKey) === entry) { - inflightProjectIssueRequests.delete(cacheKey) - } - if ( - shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - }) - - entry = { - promise, - force: Boolean(options?.force), - generation: requestCacheGeneration, - contextKey, - mutationGeneration: requestMutationGeneration - } - inflightProjectIssueRequests.set(cacheKey, entry) - return promise - }, - - getCachedLinearCustomViews: (model, limit = 20, workspaceId, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) - const cacheKey = linearCollectionCacheKey(resolvedWorkspaceId, 'custom-views', model, limit) - return get().linearCustomViewCache[scopedLinearCacheKey(scope, cacheKey)]?.data ?? null - }, - - listLinearCustomViews: async (model, limit = 20, workspaceId, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const { contextKey } = scope - const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) - const cacheKey = scopedLinearCacheKey( - scope, - linearCollectionCacheKey(resolvedWorkspaceId, 'custom-views', model, limit) - ) - const cached = get().linearCustomViewCache[cacheKey] - if (!options?.force && isFresh(cached)) { - return cached.data ?? emptyLinearCollection() - } - - const inflight = inflightCustomViewRequests.get(cacheKey) - if ( - inflight && - inflight.contextKey === contextKey && - inflight.mutationGeneration === linearMutationGeneration && - (!options?.force || inflight.force) - ) { - return inflight.promise - } - - let entry: InflightLinearCollectionRequest - const requestCacheGeneration = linearCacheGeneration - const requestMutationGeneration = linearMutationGeneration - const promise = linearListCustomViews(scope.settings, model, limit, resolvedWorkspaceId, { - force: options?.force - }) - .then((result) => { - if ( - inflightCustomViewRequests.get(cacheKey) === entry && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - set((s) => ({ - linearCustomViewCache: evictStaleEntries({ - ...s.linearCustomViewCache, - [cacheKey]: { data: result, fetchedAt: Date.now() } - }) - })) - } - return result - }) - .catch((error) => { - console.warn('[linear] listLinearCustomViews failed:', error) - if ( - (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - const fallback = - get().linearCustomViewCache[cacheKey]?.data ?? - emptyLinearCollection() - return collectionWithWorkspaceError(fallback, resolvedWorkspaceId ?? 'default', error) - }) - .finally(() => { - if (inflightCustomViewRequests.get(cacheKey) === entry) { - inflightCustomViewRequests.delete(cacheKey) - } - if ( - shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - }) - - entry = { - promise, - force: Boolean(options?.force), - generation: requestCacheGeneration, - contextKey, - mutationGeneration: requestMutationGeneration - } - inflightCustomViewRequests.set(cacheKey, entry) - return promise - }, - - fetchLinearCustomView: async (viewId, workspaceId, model, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const { contextKey } = scope - const cacheKey = scopedLinearCacheKey( - scope, - linearCollectionCacheKey(workspaceId, 'custom-view-detail', model, viewId) - ) - const cached = get().linearCustomViewDetailCache[cacheKey] - if (!options?.force && isFresh(cached)) { - return cached.data - } - - const inflight = inflightCustomViewDetailRequests.get(cacheKey) - if ( - inflight && - inflight.contextKey === contextKey && - inflight.mutationGeneration === linearMutationGeneration && - (!options?.force || inflight.force) - ) { - return inflight.promise - } - - let entry: InflightLinearDetailRequest - const requestCacheGeneration = linearCacheGeneration - const requestMutationGeneration = linearMutationGeneration - const promise = linearGetCustomView(scope.settings, viewId, model, workspaceId, { - force: options?.force - }) - .then((view) => { - if ( - inflightCustomViewDetailRequests.get(cacheKey) === entry && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - set((s) => ({ - linearCustomViewDetailCache: evictStaleEntries({ - ...s.linearCustomViewDetailCache, - [cacheKey]: { data: view, fetchedAt: Date.now() } - }) - })) - } - return view - }) - .catch((error) => { - console.warn('[linear] fetchLinearCustomView failed:', error) - if ( - (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - if (options?.force) { - throw error - } - const cachedResult = get().linearCustomViewDetailCache[cacheKey] - if (cachedResult) { - return cachedResult.data - } - throw error - }) - .finally(() => { - if (inflightCustomViewDetailRequests.get(cacheKey) === entry) { - inflightCustomViewDetailRequests.delete(cacheKey) - } - if ( - shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - }) - - entry = { - promise, - force: Boolean(options?.force), - contextKey, - mutationGeneration: requestMutationGeneration - } - inflightCustomViewDetailRequests.set(cacheKey, entry) - return promise - }, - - listLinearCustomViewIssues: async (viewId, workspaceId, limit = 20, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const { contextKey } = scope - const effectiveLimit = clampLinearIssueListLimit(limit) - const cacheKey = scopedLinearCacheKey( - scope, - linearCollectionCacheKey(workspaceId, 'custom-view-issues', viewId, effectiveLimit) - ) - const cached = get().linearCustomViewIssueCache[cacheKey] - if (!options?.force && isFresh(cached)) { - return cached.data ?? emptyLinearCollection() - } - - const inflight = inflightCustomViewIssueRequests.get(cacheKey) - if ( - inflight && - inflight.contextKey === contextKey && - inflight.mutationGeneration === linearMutationGeneration && - (!options?.force || inflight.force) - ) { - return inflight.promise - } - - let entry: InflightLinearCollectionRequest - const requestCacheGeneration = linearCacheGeneration - const requestMutationGeneration = linearMutationGeneration - const promise = linearListCustomViewIssues( - scope.settings, - viewId, - effectiveLimit, - workspaceId, - { - force: options?.force - } - ) - .then((result) => { - if ( - inflightCustomViewIssueRequests.get(cacheKey) === entry && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - set((s) => ({ - linearCustomViewIssueCache: evictStaleEntries({ - ...s.linearCustomViewIssueCache, - [cacheKey]: { data: result, fetchedAt: Date.now() } - }) - })) - } - return result - }) - .catch((error) => { - console.warn('[linear] listLinearCustomViewIssues failed:', error) - if ( - (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - const fallback = - get().linearCustomViewIssueCache[cacheKey]?.data ?? - largestCachedCollectionBelowLimit( - get().linearCustomViewIssueCache, - workspaceId, - 'custom-view-issues', - viewId, - effectiveLimit - ) ?? - emptyLinearCollection() - return collectionWithWorkspaceError(fallback, workspaceId, error) - }) - .finally(() => { - if (inflightCustomViewIssueRequests.get(cacheKey) === entry) { - inflightCustomViewIssueRequests.delete(cacheKey) - } - if ( - shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - }) - - entry = { - promise, - force: Boolean(options?.force), - generation: requestCacheGeneration, - contextKey, - mutationGeneration: requestMutationGeneration - } - inflightCustomViewIssueRequests.set(cacheKey, entry) - return promise - }, - - listLinearCustomViewProjects: async (viewId, workspaceId, limit = 20, options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const { contextKey } = scope - const cacheKey = scopedLinearCacheKey( - scope, - linearCollectionCacheKey(workspaceId, 'custom-view-projects', viewId, limit) - ) - const cached = get().linearCustomViewProjectCache[cacheKey] - if (!options?.force && isFresh(cached)) { - return cached.data ?? emptyLinearCollection() - } - - const inflight = inflightCustomViewProjectRequests.get(cacheKey) - if ( - inflight && - inflight.contextKey === contextKey && - inflight.mutationGeneration === linearMutationGeneration && - (!options?.force || inflight.force) - ) { - return inflight.promise - } - - let entry: InflightLinearCollectionRequest - const requestCacheGeneration = linearCacheGeneration - const requestMutationGeneration = linearMutationGeneration - const promise = linearListCustomViewProjects(scope.settings, viewId, limit, workspaceId, { - force: options?.force - }) - .then((result) => { - if ( - inflightCustomViewProjectRequests.get(cacheKey) === entry && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - set((s) => ({ - linearCustomViewProjectCache: evictStaleEntries({ - ...s.linearCustomViewProjectCache, - [cacheKey]: { data: result, fetchedAt: Date.now() } - }) - })) - } - return result - }) - .catch((error) => { - console.warn('[linear] listLinearCustomViewProjects failed:', error) - if ( - (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - const fallback = - get().linearCustomViewProjectCache[cacheKey]?.data ?? - emptyLinearCollection() - return collectionWithWorkspaceError(fallback, workspaceId, error) - }) - .finally(() => { - if (inflightCustomViewProjectRequests.get(cacheKey) === entry) { - inflightCustomViewProjectRequests.delete(cacheKey) - } - if ( - shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && - canWriteLinearReadResult( - contextKey, - requestCacheGeneration, - requestMutationGeneration, - get().settings, - scope.explicitSource - ) - ) { - void get().checkLinearConnection(true) - } - }) - - entry = { - promise, - force: Boolean(options?.force), - generation: requestCacheGeneration, - contextKey, - mutationGeneration: requestMutationGeneration - } - inflightCustomViewProjectRequests.set(cacheKey, entry) - return promise - }, - - invalidateLinearIssueLists: (options) => { - const scope = getLinearReadScope(get().settings, options?.sourceContext) - const tokenScope = scope.cachePrefix ?? 'local' - const nextVersion = - linearListInvalidationToken.scope === tokenScope - ? (linearListInvalidationToken.version + 1) % LINEAR_LIST_INVALIDATION_VERSION_CAP || 1 - : 1 - linearListInvalidationToken = { scope: tokenScope, version: nextVersion } - - // Why: drop only attribute-filtered plain list entries in this source scope - // so the next TaskPage read is forced current without wiping search/unrelated - // collections. - set((s) => { - const nextListCache = { ...s.linearListCache } - let changed = false - for (const key of Object.keys(nextListCache)) { - const parts = key.split('::') - // Cache keys end with the attribute signature; unfiltered entries end empty. - const attributeSignature = parts.at(-1) ?? '' - if (!attributeSignature) { - continue - } - if (scope.cachePrefix) { - if (!key.startsWith(`${scope.cachePrefix}::`)) { - continue - } - } else if (parts[1] !== 'list') { - // Why: unscoped invalidation must not touch other runtimes' scoped list keys. - continue - } - delete nextListCache[key] - inflightListRequests.delete(key) - changed = true - } - if (!changed) { - return { linearListInvalidationToken: linearListInvalidationToken } - } - return { - linearListCache: nextListCache, - linearListInvalidationToken: linearListInvalidationToken - } - }) - }, - - patchLinearIssue: (issueId, patch, options) => { - const sourceScope = - options?.sourceContext?.provider === 'linear' - ? getTaskSourceCacheScope(options.sourceContext) - : null - const canPatchCacheKey = (key: string): boolean => - sourceScope === null || key.startsWith(`${sourceScope}::`) - set((s) => { - let changed = false - - const nextIssueCache = { ...s.linearIssueCache } - for (const [key, issueEntry] of Object.entries(nextIssueCache)) { - if (!canPatchCacheKey(key) || issueEntry?.data?.id !== issueId) { - continue - } - // Why: set fetchedAt to 0 so the next fetchLinearIssue call - // actually hits IPC instead of returning the stale optimistic data. - nextIssueCache[key] = { - ...issueEntry, - data: { ...issueEntry.data, ...patch }, - fetchedAt: 0 - } - changed = true - } - - const nextSearchCache = { ...s.linearSearchCache } - for (const key of Object.keys(nextSearchCache)) { - const entry = nextSearchCache[key] - if (!canPatchCacheKey(key) || !entry?.data) { - continue - } - const idx = entry.data.findIndex((item) => item.id === issueId) - if (idx === -1) { - continue - } - const updatedItems = [...entry.data] - updatedItems[idx] = { ...updatedItems[idx], ...patch } - nextSearchCache[key] = { ...entry, data: updatedItems } - changed = true - } - - const nextListCache = patchLinearIssueCollectionCache( - s.linearListCache, - issueId, - patch, - canPatchCacheKey - ) - if (nextListCache.changed) { - changed = true - } - - const nextProjectIssueCache = patchLinearIssueCollectionCache( - s.linearProjectIssueCache, - issueId, - patch, - canPatchCacheKey - ) - if (nextProjectIssueCache.changed) { - changed = true - } - - const nextCustomViewIssueCache = patchLinearIssueCollectionCache( - s.linearCustomViewIssueCache, - issueId, - patch, - canPatchCacheKey - ) - if (nextCustomViewIssueCache.changed) { - changed = true - } - - return changed - ? { - linearIssueCache: nextIssueCache, - linearSearchCache: nextSearchCache, - linearListCache: nextListCache.changed ? nextListCache.cache : s.linearListCache, - linearProjectIssueCache: nextProjectIssueCache.changed - ? nextProjectIssueCache.cache - : s.linearProjectIssueCache, - linearCustomViewIssueCache: nextCustomViewIssueCache.changed - ? nextCustomViewIssueCache.cache - : s.linearCustomViewIssueCache - } - : {} - }) - } + linearListInvalidationToken, + ...createLinearStatusActions(set, get), + ...createLinearConnectionActions(set, get), + ...createLinearIssueDetailActions(set, get), + ...createLinearIssueCacheActions(set, get), + ...createLinearIssueListActions(set, get), + ...createLinearTeamActions(set, get), + ...createLinearProjectListActions(set, get), + ...createLinearProjectDetailActions(set, get), + ...createLinearProjectIssueActions(set, get), + ...createLinearCustomViewListActions(set, get), + ...createLinearCustomViewDetailActions(set, get), + ...createLinearCustomViewIssueActions(set, get), + ...createLinearCustomViewProjectActions(set, get), + ...createLinearInvalidationActions(set, get) }) diff --git a/src/renderer/src/store/slices/linear/linear-cache.ts b/src/renderer/src/store/slices/linear/linear-cache.ts new file mode 100644 index 00000000000..f60a0f3be97 --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-cache.ts @@ -0,0 +1,233 @@ +import type { CacheEntry } from '../../github/cache-model' +import type { LinearIssue } from '../../../../../shared/linear/issue-types' +import type { + LinearCollectionResult, + LinearConnectionStatus, + LinearWorkspace, + LinearWorkspaceError, + LinearWorkspaceSelection +} from '../../../../../shared/linear/workspace-types' +import type { LinearIssueAttributeFilter } from '../../../../../shared/linear/issue-attribute-filter' +import { linearIssueAttributeFilterSignature } from '../../../../../shared/linear/issue-attribute-filter' + +export const CACHE_TTL = 60_000 // 60s — same as GitHub work-items revalidation TTL +export const TEAM_CACHE_TTL = 10 * 60_000 // Teams change rarely and block visible Linear rows. +export const MAX_CACHE_ENTRIES = 500 + +export function isFresh( + entry: CacheEntry | undefined, + ttl = CACHE_TTL +): entry is CacheEntry { + return entry !== undefined && Date.now() - entry.fetchedAt < ttl +} + +export function evictStaleEntries( + cache: Record>, + maxEntries = MAX_CACHE_ENTRIES +): Record> { + const keys = Object.keys(cache) + if (keys.length <= maxEntries) { + return cache + } + const sorted = keys.sort((a, b) => (cache[a]?.fetchedAt ?? 0) - (cache[b]?.fetchedAt ?? 0)) + const pruned: Record> = {} + for (const key of sorted.slice(sorted.length - maxEntries)) { + pruned[key] = cache[key] + } + return pruned +} + +export function looksLikeAuthError(error: unknown): boolean { + const msg = error instanceof Error ? error.message : String(error) + return /authenticat|unauthorized|401/i.test(msg) +} + +export function workspaceErrorType(error: unknown): LinearWorkspaceError['type'] { + const record = error as { name?: string; message?: string; status?: number; response?: unknown } + const message = record.message ?? String(error) + const status = + typeof record.status === 'number' + ? record.status + : typeof (record.response as { status?: unknown } | undefined)?.status === 'number' + ? ((record.response as { status: number }).status as number) + : undefined + if (looksLikeAuthError(error)) { + return 'auth' + } + if (status === 429 || /rate/i.test(record.name ?? '')) { + return 'rate_limited' + } + if ((typeof status === 'number' && status >= 500) || /network/i.test(record.name ?? message)) { + return 'network' + } + return 'unknown' +} + +export function workspaceErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +export function getSelectedWorkspaceId( + status: LinearConnectionStatus +): LinearWorkspaceSelection | null { + return status.selectedWorkspaceId ?? status.activeWorkspaceId ?? null +} + +export function linearSearchCacheKey( + workspaceId: LinearWorkspaceSelection | null | undefined, + query: string, + limit: number +): string { + return `${workspaceId ?? 'default'}::search::${query}::${limit}` +} + +export function linearListCacheKey( + workspaceId: LinearWorkspaceSelection | null | undefined, + filter: 'assigned' | 'created' | 'all' | 'completed', + limit: number, + attributeFilter?: LinearIssueAttributeFilter | null +): string { + const attributeSignature = linearIssueAttributeFilterSignature(attributeFilter) + return `${workspaceId ?? 'default'}::list::${filter}::${limit}::${attributeSignature}` +} + +export const LINEAR_LIST_INVALIDATION_VERSION_CAP = 10_000 +export let linearListInvalidationToken: { scope: string; version: number } = { + scope: '', + version: 0 +} + +export function setLinearListInvalidationToken(next: { scope: string; version: number }): void { + linearListInvalidationToken = next +} + +export function linearTeamsCacheKey( + workspaceId: LinearWorkspaceSelection | null | undefined +): string { + return `${workspaceId ?? 'default'}::teams` +} + +function linearWorkspaceSignature(workspace: LinearWorkspace): string { + return [ + workspace.id, + workspace.organizationId, + workspace.organizationName, + workspace.organizationUrlKey ?? '', + workspace.displayName, + workspace.email ?? '', + workspace.credentialRevision ?? 0 + ].join('\u001f') +} + +export function linearStatusScopeSignature(status: LinearConnectionStatus): string { + return JSON.stringify({ + connected: status.connected, + credentialError: status.credentialError ?? null, + activeWorkspaceId: status.activeWorkspaceId ?? null, + selectedWorkspaceId: getSelectedWorkspaceId(status), + viewer: status.viewer + ? [ + status.viewer.organizationId ?? '', + status.viewer.organizationName, + status.viewer.organizationUrlKey ?? '', + status.viewer.displayName, + status.viewer.email ?? '' + ] + : null, + workspaces: (status.workspaces ?? []).map(linearWorkspaceSignature) + }) +} + +export function shouldRefreshStatusAfterRead( + workspaceId: LinearWorkspaceSelection | null | undefined, + status: LinearConnectionStatus +): boolean { + // Why: 'all' reads can hide per-workspace decrypt failures, and a visible + // credential error may have been cleared by a successful credential read. + return workspaceId === 'all' || status.credentialError !== undefined +} + +export function linearCollectionCacheKey( + workspaceId: LinearWorkspaceSelection | null | undefined, + mode: string, + ...parts: (string | number | null | undefined)[] +): string { + return [workspaceId ?? 'default', mode, ...parts.map((part) => part ?? '')].join('::') +} + +export function emptyLinearCollection(): LinearCollectionResult { + return { items: [] } +} + +export function collectionWithWorkspaceError( + fallback: LinearCollectionResult, + workspaceId: string, + error: unknown +): LinearCollectionResult { + const existingErrors = (fallback.errors ?? []).filter((item) => item.workspaceId !== workspaceId) + return { + ...fallback, + errors: [ + ...existingErrors, + { + workspaceId, + type: workspaceErrorType(error), + message: workspaceErrorMessage(error) || 'Linear request failed.' + } + ] + } +} + +export function largestCachedCollectionBelowLimit( + cache: Record>>, + workspaceId: LinearWorkspaceSelection | null | undefined, + mode: string, + scopeId: string, + limit: number +): LinearCollectionResult | null { + const keyPrefix = `${linearCollectionCacheKey(workspaceId, mode, scopeId)}::` + let best: { limit: number; data: LinearCollectionResult } | null = null + for (const [key, entry] of Object.entries(cache)) { + if (!entry?.data || !key.startsWith(keyPrefix)) { + continue + } + const cachedLimit = Number(key.slice(keyPrefix.length)) + if (!Number.isFinite(cachedLimit) || cachedLimit >= limit) { + continue + } + if (!best || cachedLimit > best.limit) { + best = { limit: cachedLimit, data: entry.data } + } + } + return best?.data ?? null +} + +export function patchLinearIssueCollectionCache( + cache: Record>>, + issueId: string, + patch: Partial, + canPatchCacheKey: (key: string) => boolean +): { + cache: Record>> + changed: boolean +} { + let changed = false + const nextCache = { ...cache } + for (const [key, entry] of Object.entries(nextCache)) { + if (!canPatchCacheKey(key) || !entry?.data) { + continue + } + const idx = entry.data.items.findIndex((item) => item.id === issueId) + if (idx === -1) { + continue + } + const updatedItems = [...entry.data.items] + updatedItems[idx] = { ...updatedItems[idx], ...patch } + nextCache[key] = { + ...entry, + data: { ...entry.data, items: updatedItems } + } + changed = true + } + return { cache: nextCache, changed } +} diff --git a/src/renderer/src/store/slices/linear/linear-custom-view-detail-actions.ts b/src/renderer/src/store/slices/linear/linear-custom-view-detail-actions.ts new file mode 100644 index 00000000000..28cf7a07483 --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-custom-view-detail-actions.ts @@ -0,0 +1,141 @@ +import type { + LinearCustomViewModel, + LinearCustomViewSummary +} from '../../../../../shared/linear/project-types' +import type { + LinearFetchOptions, + LinearSlice, + LinearSliceGet, + LinearSliceSet +} from './linear-slice-contract' +import { isIntegrationCredentialDecryptionError } from '../../../../../shared/integration-credential-errors' +import { linearGetCustomView } from '@/runtime/runtime-linear-project-client' +import { + evictStaleEntries, + isFresh, + linearCollectionCacheKey, + looksLikeAuthError, + shouldRefreshStatusAfterRead +} from './linear-cache' +import { + getLinearCacheGeneration, + getLinearMutationGeneration, + inflightCustomViewDetailRequests, + type InflightLinearDetailRequest +} from './linear-slice-request-state' +import { + canWriteLinearReadResult, + getLinearReadScope, + scopedLinearCacheKey +} from './linear-slice-scope' + +export function createLinearCustomViewDetailActions( + set: LinearSliceSet, + get: LinearSliceGet +): Pick { + return { + fetchLinearCustomView: async ( + viewId, + workspaceId, + model: LinearCustomViewModel, + options?: LinearFetchOptions + ) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(workspaceId, 'custom-view-detail', model, viewId) + ) + const cached = get().linearCustomViewDetailCache[cacheKey] + if (!options?.force && isFresh(cached)) { + return cached.data + } + + const inflight = inflightCustomViewDetailRequests.get(cacheKey) + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === getLinearMutationGeneration() && + (!options?.force || inflight.force) + ) { + return inflight.promise + } + + let entry: InflightLinearDetailRequest + const requestCacheGeneration = getLinearCacheGeneration() + const requestMutationGeneration = getLinearMutationGeneration() + const promise = linearGetCustomView(scope.settings, viewId, model, workspaceId, { + force: options?.force + }) + .then((view) => { + if ( + inflightCustomViewDetailRequests.get(cacheKey) === entry && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + set((s) => ({ + linearCustomViewDetailCache: evictStaleEntries({ + ...s.linearCustomViewDetailCache, + [cacheKey]: { data: view, fetchedAt: Date.now() } + }) + })) + } + return view + }) + .catch((error) => { + console.warn('[linear] fetchLinearCustomView failed:', error) + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + if (options?.force) { + throw error + } + const cachedResult = get().linearCustomViewDetailCache[cacheKey] + if (cachedResult) { + return cachedResult.data + } + throw error + }) + .finally(() => { + if (inflightCustomViewDetailRequests.get(cacheKey) === entry) { + inflightCustomViewDetailRequests.delete(cacheKey) + } + if ( + shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + }) + + entry = { + promise, + force: Boolean(options?.force), + contextKey, + mutationGeneration: requestMutationGeneration + } + inflightCustomViewDetailRequests.set(cacheKey, entry) + return promise + } + } +} diff --git a/src/renderer/src/store/slices/linear/linear-custom-view-issue-actions.ts b/src/renderer/src/store/slices/linear/linear-custom-view-issue-actions.ts new file mode 100644 index 00000000000..65280c66e30 --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-custom-view-issue-actions.ts @@ -0,0 +1,153 @@ +import type { LinearIssue } from '../../../../../shared/linear/issue-types' +import type { + LinearFetchOptions, + LinearSlice, + LinearSliceGet, + LinearSliceSet +} from './linear-slice-contract' +import { clampLinearIssueListLimit } from '../../../../../shared/linear/issue-read-limits' +import { isIntegrationCredentialDecryptionError } from '../../../../../shared/integration-credential-errors' +import { linearListCustomViewIssues } from '@/runtime/runtime-linear-project-client' +import { + collectionWithWorkspaceError, + emptyLinearCollection, + evictStaleEntries, + largestCachedCollectionBelowLimit, + isFresh, + linearCollectionCacheKey, + looksLikeAuthError, + shouldRefreshStatusAfterRead +} from './linear-cache' +import { + getLinearCacheGeneration, + getLinearMutationGeneration, + inflightCustomViewIssueRequests, + type InflightLinearCollectionRequest +} from './linear-slice-request-state' +import { + canWriteLinearReadResult, + getLinearReadScope, + scopedLinearCacheKey +} from './linear-slice-scope' + +export function createLinearCustomViewIssueActions( + set: LinearSliceSet, + get: LinearSliceGet +): Pick { + return { + listLinearCustomViewIssues: async ( + viewId, + workspaceId, + limit = 20, + options?: LinearFetchOptions + ) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const effectiveLimit = clampLinearIssueListLimit(limit) + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(workspaceId, 'custom-view-issues', viewId, effectiveLimit) + ) + const cached = get().linearCustomViewIssueCache[cacheKey] + if (!options?.force && isFresh(cached)) { + return cached.data ?? emptyLinearCollection() + } + + const inflight = inflightCustomViewIssueRequests.get(cacheKey) + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === getLinearMutationGeneration() && + (!options?.force || inflight.force) + ) { + return inflight.promise + } + + let entry: InflightLinearCollectionRequest + const requestCacheGeneration = getLinearCacheGeneration() + const requestMutationGeneration = getLinearMutationGeneration() + const promise = linearListCustomViewIssues( + scope.settings, + viewId, + effectiveLimit, + workspaceId, + { + force: options?.force + } + ) + .then((result) => { + if ( + inflightCustomViewIssueRequests.get(cacheKey) === entry && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + set((s) => ({ + linearCustomViewIssueCache: evictStaleEntries({ + ...s.linearCustomViewIssueCache, + [cacheKey]: { data: result, fetchedAt: Date.now() } + }) + })) + } + return result + }) + .catch((error) => { + console.warn('[linear] listLinearCustomViewIssues failed:', error) + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + const fallback = + get().linearCustomViewIssueCache[cacheKey]?.data ?? + largestCachedCollectionBelowLimit( + get().linearCustomViewIssueCache, + workspaceId, + 'custom-view-issues', + viewId, + effectiveLimit + ) ?? + emptyLinearCollection() + return collectionWithWorkspaceError(fallback, workspaceId, error) + }) + .finally(() => { + if (inflightCustomViewIssueRequests.get(cacheKey) === entry) { + inflightCustomViewIssueRequests.delete(cacheKey) + } + if ( + shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + }) + + entry = { + promise, + force: Boolean(options?.force), + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } + inflightCustomViewIssueRequests.set(cacheKey, entry) + return promise + } + } +} diff --git a/src/renderer/src/store/slices/linear/linear-custom-view-list-actions.ts b/src/renderer/src/store/slices/linear/linear-custom-view-list-actions.ts new file mode 100644 index 00000000000..5a54247cc7a --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-custom-view-list-actions.ts @@ -0,0 +1,149 @@ +import type { + LinearCustomViewModel, + LinearCustomViewSummary +} from '../../../../../shared/linear/project-types' +import type { + LinearFetchOptions, + LinearSlice, + LinearSliceGet, + LinearSliceSet +} from './linear-slice-contract' +import { isIntegrationCredentialDecryptionError } from '../../../../../shared/integration-credential-errors' +import { linearListCustomViews } from '@/runtime/runtime-linear-project-client' +import { + collectionWithWorkspaceError, + emptyLinearCollection, + evictStaleEntries, + getSelectedWorkspaceId, + isFresh, + linearCollectionCacheKey, + looksLikeAuthError, + shouldRefreshStatusAfterRead +} from './linear-cache' +import { + getLinearCacheGeneration, + getLinearMutationGeneration, + inflightCustomViewRequests, + type InflightLinearCollectionRequest +} from './linear-slice-request-state' +import { + canWriteLinearReadResult, + getLinearReadScope, + scopedLinearCacheKey +} from './linear-slice-scope' + +export function createLinearCustomViewListActions( + set: LinearSliceSet, + get: LinearSliceGet +): Pick { + return { + getCachedLinearCustomViews: (model, limit = 20, workspaceId, options) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) + const cacheKey = linearCollectionCacheKey(resolvedWorkspaceId, 'custom-views', model, limit) + return get().linearCustomViewCache[scopedLinearCacheKey(scope, cacheKey)]?.data ?? null + }, + + listLinearCustomViews: async ( + model: LinearCustomViewModel, + limit = 20, + workspaceId, + options?: LinearFetchOptions + ) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(resolvedWorkspaceId, 'custom-views', model, limit) + ) + const cached = get().linearCustomViewCache[cacheKey] + if (!options?.force && isFresh(cached)) { + return cached.data ?? emptyLinearCollection() + } + + const inflight = inflightCustomViewRequests.get(cacheKey) + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === getLinearMutationGeneration() && + (!options?.force || inflight.force) + ) { + return inflight.promise + } + + let entry: InflightLinearCollectionRequest + const requestCacheGeneration = getLinearCacheGeneration() + const requestMutationGeneration = getLinearMutationGeneration() + const promise = linearListCustomViews(scope.settings, model, limit, resolvedWorkspaceId, { + force: options?.force + }) + .then((result) => { + if ( + inflightCustomViewRequests.get(cacheKey) === entry && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + set((s) => ({ + linearCustomViewCache: evictStaleEntries({ + ...s.linearCustomViewCache, + [cacheKey]: { data: result, fetchedAt: Date.now() } + }) + })) + } + return result + }) + .catch((error) => { + console.warn('[linear] listLinearCustomViews failed:', error) + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + const fallback = + get().linearCustomViewCache[cacheKey]?.data ?? + emptyLinearCollection() + return collectionWithWorkspaceError(fallback, resolvedWorkspaceId ?? 'default', error) + }) + .finally(() => { + if (inflightCustomViewRequests.get(cacheKey) === entry) { + inflightCustomViewRequests.delete(cacheKey) + } + if ( + shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + }) + + entry = { + promise, + force: Boolean(options?.force), + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } + inflightCustomViewRequests.set(cacheKey, entry) + return promise + } + } +} diff --git a/src/renderer/src/store/slices/linear/linear-custom-view-project-actions.ts b/src/renderer/src/store/slices/linear/linear-custom-view-project-actions.ts new file mode 100644 index 00000000000..c00001341eb --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-custom-view-project-actions.ts @@ -0,0 +1,137 @@ +import type { LinearProjectSummary } from '../../../../../shared/linear/project-types' +import type { + LinearFetchOptions, + LinearSlice, + LinearSliceGet, + LinearSliceSet +} from './linear-slice-contract' +import { isIntegrationCredentialDecryptionError } from '../../../../../shared/integration-credential-errors' +import { linearListCustomViewProjects } from '@/runtime/runtime-linear-project-client' +import { + collectionWithWorkspaceError, + emptyLinearCollection, + evictStaleEntries, + isFresh, + linearCollectionCacheKey, + looksLikeAuthError, + shouldRefreshStatusAfterRead +} from './linear-cache' +import { + getLinearCacheGeneration, + getLinearMutationGeneration, + inflightCustomViewProjectRequests, + type InflightLinearCollectionRequest +} from './linear-slice-request-state' +import { + canWriteLinearReadResult, + getLinearReadScope, + scopedLinearCacheKey +} from './linear-slice-scope' + +export function createLinearCustomViewProjectActions( + set: LinearSliceSet, + get: LinearSliceGet +): Pick { + return { + listLinearCustomViewProjects: async ( + viewId, + workspaceId, + limit = 20, + options?: LinearFetchOptions + ) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(workspaceId, 'custom-view-projects', viewId, limit) + ) + const cached = get().linearCustomViewProjectCache[cacheKey] + if (!options?.force && isFresh(cached)) { + return cached.data ?? emptyLinearCollection() + } + + const inflight = inflightCustomViewProjectRequests.get(cacheKey) + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === getLinearMutationGeneration() && + (!options?.force || inflight.force) + ) { + return inflight.promise + } + + let entry: InflightLinearCollectionRequest + const requestCacheGeneration = getLinearCacheGeneration() + const requestMutationGeneration = getLinearMutationGeneration() + const promise = linearListCustomViewProjects(scope.settings, viewId, limit, workspaceId, { + force: options?.force + }) + .then((result) => { + if ( + inflightCustomViewProjectRequests.get(cacheKey) === entry && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + set((s) => ({ + linearCustomViewProjectCache: evictStaleEntries({ + ...s.linearCustomViewProjectCache, + [cacheKey]: { data: result, fetchedAt: Date.now() } + }) + })) + } + return result + }) + .catch((error) => { + console.warn('[linear] listLinearCustomViewProjects failed:', error) + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + const fallback = + get().linearCustomViewProjectCache[cacheKey]?.data ?? + emptyLinearCollection() + return collectionWithWorkspaceError(fallback, workspaceId, error) + }) + .finally(() => { + if (inflightCustomViewProjectRequests.get(cacheKey) === entry) { + inflightCustomViewProjectRequests.delete(cacheKey) + } + if ( + shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + }) + + entry = { + promise, + force: Boolean(options?.force), + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } + inflightCustomViewProjectRequests.set(cacheKey, entry) + return promise + } + } +} diff --git a/src/renderer/src/store/slices/linear/linear-invalidation-actions.ts b/src/renderer/src/store/slices/linear/linear-invalidation-actions.ts new file mode 100644 index 00000000000..b515ae1d83e --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-invalidation-actions.ts @@ -0,0 +1,155 @@ +import type { LinearIssue } from '../../../../../shared/linear/issue-types' +import type { + LinearFetchOptions, + LinearSlice, + LinearSliceGet, + LinearSliceSet +} from './linear-slice-contract' +import { + LINEAR_LIST_INVALIDATION_VERSION_CAP, + linearListInvalidationToken, + patchLinearIssueCollectionCache, + setLinearListInvalidationToken +} from './linear-cache' +import { inflightListRequests } from './linear-slice-request-state' +import { getTaskSourceCacheScope } from '../../../../../shared/task-source-context' +import { getLinearReadScope } from './linear-slice-scope' + +export function createLinearInvalidationActions( + set: LinearSliceSet, + get: LinearSliceGet +): Pick { + return { + invalidateLinearIssueLists: (options?: Pick) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const tokenScope = scope.cachePrefix ?? 'local' + const nextVersion = + linearListInvalidationToken.scope === tokenScope + ? (linearListInvalidationToken.version + 1) % LINEAR_LIST_INVALIDATION_VERSION_CAP || 1 + : 1 + const nextToken = { scope: tokenScope, version: nextVersion } + setLinearListInvalidationToken(nextToken) + + // Why: drop only attribute-filtered plain list entries in this source scope + // so the next TaskPage read is forced current without wiping search/unrelated + // collections. + set((s) => { + const nextListCache = { ...s.linearListCache } + let changed = false + for (const key of Object.keys(nextListCache)) { + const parts = key.split('::') + // Cache keys end with the attribute signature; unfiltered entries end empty. + const attributeSignature = parts.at(-1) ?? '' + if (!attributeSignature) { + continue + } + if (scope.cachePrefix) { + if (!key.startsWith(`${scope.cachePrefix}::`)) { + continue + } + } else if (parts[1] !== 'list') { + // Why: unscoped invalidation must not touch other runtimes' scoped list keys. + continue + } + delete nextListCache[key] + inflightListRequests.delete(key) + changed = true + } + if (!changed) { + return { linearListInvalidationToken: nextToken } + } + return { + linearListCache: nextListCache, + linearListInvalidationToken: nextToken + } + }) + }, + + patchLinearIssue: (issueId: string, patch: Partial, options) => { + const sourceScope = + options?.sourceContext?.provider === 'linear' + ? getTaskSourceCacheScope(options.sourceContext) + : null + const canPatchCacheKey = (key: string): boolean => + sourceScope === null || key.startsWith(`${sourceScope}::`) + set((s) => { + let changed = false + + const nextIssueCache = { ...s.linearIssueCache } + for (const [key, issueEntry] of Object.entries(nextIssueCache)) { + if (!canPatchCacheKey(key) || issueEntry?.data?.id !== issueId) { + continue + } + // Why: set fetchedAt to 0 so the next fetchLinearIssue call + // actually hits IPC instead of returning the stale optimistic data. + nextIssueCache[key] = { + ...issueEntry, + data: { ...issueEntry.data, ...patch }, + fetchedAt: 0 + } + changed = true + } + + const nextSearchCache = { ...s.linearSearchCache } + for (const key of Object.keys(nextSearchCache)) { + const entry = nextSearchCache[key] + if (!canPatchCacheKey(key) || !entry?.data) { + continue + } + const idx = entry.data.findIndex((item) => item.id === issueId) + if (idx === -1) { + continue + } + const updatedItems = [...entry.data] + updatedItems[idx] = { ...updatedItems[idx], ...patch } + nextSearchCache[key] = { ...entry, data: updatedItems } + changed = true + } + + const nextListCache = patchLinearIssueCollectionCache( + s.linearListCache, + issueId, + patch, + canPatchCacheKey + ) + if (nextListCache.changed) { + changed = true + } + + const nextProjectIssueCache = patchLinearIssueCollectionCache( + s.linearProjectIssueCache, + issueId, + patch, + canPatchCacheKey + ) + if (nextProjectIssueCache.changed) { + changed = true + } + + const nextCustomViewIssueCache = patchLinearIssueCollectionCache( + s.linearCustomViewIssueCache, + issueId, + patch, + canPatchCacheKey + ) + if (nextCustomViewIssueCache.changed) { + changed = true + } + + return changed + ? { + linearIssueCache: nextIssueCache, + linearSearchCache: nextSearchCache, + linearListCache: nextListCache.changed ? nextListCache.cache : s.linearListCache, + linearProjectIssueCache: nextProjectIssueCache.changed + ? nextProjectIssueCache.cache + : s.linearProjectIssueCache, + linearCustomViewIssueCache: nextCustomViewIssueCache.changed + ? nextCustomViewIssueCache.cache + : s.linearCustomViewIssueCache + } + : {} + }) + } + } +} diff --git a/src/renderer/src/store/slices/linear/linear-issue-cache-actions.ts b/src/renderer/src/store/slices/linear/linear-issue-cache-actions.ts new file mode 100644 index 00000000000..32c3116aaac --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-issue-cache-actions.ts @@ -0,0 +1,101 @@ +import { clampLinearIssueListLimit } from '../../../../../shared/linear/issue-read-limits' +import type { + LinearIssueListReadArgs, + LinearIssueReadArgs, + LinearSlice, + LinearSliceGet, + LinearSliceSet, + LinearFetchOptions +} from './linear-slice-contract' +import { + getSelectedWorkspaceId, + isFresh, + linearListCacheKey, + linearSearchCacheKey +} from './linear-cache' +import { + getLinearMutationGeneration, + inflightListRequests, + inflightSearchRequests +} from './linear-slice-request-state' +import { + getLinearReadScope, + normalizeListAttributeFilter, + scopedLinearCacheKey +} from './linear-slice-scope' + +export function createLinearIssueCacheActions( + _set: LinearSliceSet, + get: LinearSliceGet +): Pick { + return { + getCachedLinearIssues: (args: LinearIssueReadArgs, options) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const workspaceId = getSelectedWorkspaceId(get().linearStatus) + if (args.kind === 'search') { + const cacheKey = scopedLinearCacheKey( + scope, + linearSearchCacheKey(workspaceId, args.query, args.limit ?? 20) + ) + return get().linearSearchCache[cacheKey]?.data ?? null + } + const limit = clampLinearIssueListLimit(args.limit) + const attributeFilter = normalizeListAttributeFilter(args.attributeFilter) + const cacheKey = scopedLinearCacheKey( + scope, + linearListCacheKey(workspaceId, args.filter ?? 'assigned', limit, attributeFilter) + ) + return get().linearListCache[cacheKey]?.data ?? null + }, + + prefetchLinearIssues: (args: LinearIssueReadArgs, options?: LinearFetchOptions) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const workspaceId = getSelectedWorkspaceId(get().linearStatus) + if (args.kind === 'search') { + const limit = args.limit ?? 20 + const cacheKey = scopedLinearCacheKey( + scope, + linearSearchCacheKey(workspaceId, args.query, limit) + ) + const inflight = inflightSearchRequests.get(cacheKey) + if ( + isFresh(get().linearSearchCache[cacheKey]) || + (inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === getLinearMutationGeneration()) + ) { + return + } + void get() + .searchLinearIssues(args.query, limit, options) + .catch(() => {}) + return + } + const limit = clampLinearIssueListLimit(args.limit) + const attributeFilter = normalizeListAttributeFilter(args.attributeFilter) + const listArgs: LinearIssueListReadArgs = { + kind: 'list', + filter: args.filter, + limit, + attributeFilter + } + const cacheKey = scopedLinearCacheKey( + scope, + linearListCacheKey(workspaceId, args.filter ?? 'assigned', limit, attributeFilter) + ) + const inflight = inflightListRequests.get(cacheKey) + if ( + isFresh(get().linearListCache[cacheKey]) || + (inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === getLinearMutationGeneration()) + ) { + return + } + void get() + .listLinearIssues(listArgs, options) + .catch(() => {}) + } + } +} diff --git a/src/renderer/src/store/slices/linear/linear-issue-detail-actions.ts b/src/renderer/src/store/slices/linear/linear-issue-detail-actions.ts new file mode 100644 index 00000000000..94b4f3fde07 --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-issue-detail-actions.ts @@ -0,0 +1,157 @@ +import type { LinearIssue } from '../../../../../shared/linear/issue-types' +import type { + LinearSlice, + LinearSliceGet, + LinearSliceSet, + LinearFetchOptions +} from './linear-slice-contract' +import { isIntegrationCredentialDecryptionError } from '../../../../../shared/integration-credential-errors' +import { linearGetIssue } from '@/runtime/runtime-linear-issue-mutations' +import { + clearLinearIssueCollectionRequestMaps, + getLinearCacheGeneration, + getLinearMutationGeneration, + inflightIssueRequests +} from './linear-slice-request-state' +import { + canWriteLinearReadResult, + getLinearReadScope, + scopedLinearCacheKey +} from './linear-slice-scope' +import { + evictStaleEntries, + isFresh, + looksLikeAuthError, + shouldRefreshStatusAfterRead +} from './linear-cache' +import type { InflightLinearIssueRequest } from './linear-slice-request-state' + +export function createLinearIssueDetailActions( + set: LinearSliceSet, + get: LinearSliceGet +): Pick { + return { + fetchLinearIssue: async ( + id: string, + workspaceId?: string | null, + options?: LinearFetchOptions + ) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const issueCacheKey = scopedLinearCacheKey(scope, `${workspaceId ?? 'selected'}::${id}`) + const cached = get().linearIssueCache[issueCacheKey] ?? get().linearIssueCache[id] + if (isFresh(cached)) { + return cached.data + } + + const inflight = inflightIssueRequests.get(issueCacheKey) + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === getLinearMutationGeneration() + ) { + return inflight.promise + } + + let entry: InflightLinearIssueRequest + const requestCacheGeneration = getLinearCacheGeneration() + const requestMutationGeneration = getLinearMutationGeneration() + const promise = linearGetIssue(scope.settings, id, workspaceId) + .then((issue) => { + const data = issue as LinearIssue | null + if ( + inflightIssueRequests.get(issueCacheKey) === entry && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + set((s) => ({ + linearIssueCache: evictStaleEntries({ + ...s.linearIssueCache, + [issueCacheKey]: { data, fetchedAt: Date.now() } + }) + })) + } + return data + }) + .catch((error) => { + console.warn('[linear] fetchLinearIssue failed:', error) + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + return null + }) + .finally(() => { + if (inflightIssueRequests.get(issueCacheKey) === entry) { + inflightIssueRequests.delete(issueCacheKey) + } + if ( + shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + }) + + entry = { + promise, + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } + inflightIssueRequests.set(issueCacheKey, entry) + return promise + }, + + refreshLinearIssue: async ( + id: string, + workspaceId?: string | null, + options?: LinearFetchOptions + ) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const issueCacheKey = scopedLinearCacheKey(scope, `${workspaceId ?? 'selected'}::${id}`) + inflightIssueRequests.delete(issueCacheKey) + clearLinearIssueCollectionRequestMaps() + set((s) => { + const nextIssueCache = { ...s.linearIssueCache } + for (const [key, entry] of Object.entries(nextIssueCache)) { + if ( + key === issueCacheKey || + key === id || + entry?.data?.id === id || + entry?.data?.identifier === id + ) { + delete nextIssueCache[key] + } + } + return { + linearIssueCache: nextIssueCache, + linearSearchCache: {}, + linearListCache: {}, + linearProjectIssueCache: {}, + linearCustomViewIssueCache: {} + } + }) + return get().fetchLinearIssue(id, workspaceId, options) + } + } +} diff --git a/src/renderer/src/store/slices/linear/linear-issue-list-actions.ts b/src/renderer/src/store/slices/linear/linear-issue-list-actions.ts new file mode 100644 index 00000000000..20fdd8b70c4 --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-issue-list-actions.ts @@ -0,0 +1,250 @@ +import type { LinearIssue } from '../../../../../shared/linear/issue-types' +import type { LinearCollectionResult } from '../../../../../shared/linear/workspace-types' +import type { + LinearIssueListReadArgs, + LinearFetchOptions, + LinearSlice, + LinearSliceGet, + LinearSliceSet +} from './linear-slice-contract' +import { clampLinearIssueListLimit } from '../../../../../shared/linear/issue-read-limits' +import { isIntegrationCredentialDecryptionError } from '../../../../../shared/integration-credential-errors' +import { + isLinearIssueAttributeFilterUnsupportedError, + linearListIssues, + linearSearchIssues +} from '@/runtime/runtime-linear-client' +import { + evictStaleEntries, + emptyLinearCollection, + getSelectedWorkspaceId, + isFresh, + linearListCacheKey, + linearSearchCacheKey, + looksLikeAuthError, + shouldRefreshStatusAfterRead +} from './linear-cache' +import { + getLinearCacheGeneration, + getLinearMutationGeneration, + inflightListRequests, + inflightSearchRequests, + type InflightLinearListRequest, + type InflightLinearPlainListRequest +} from './linear-slice-request-state' +import { + canWriteLinearReadResult, + getLinearReadScope, + normalizeListAttributeFilter, + scopedLinearCacheKey +} from './linear-slice-scope' + +export function createLinearIssueListActions( + set: LinearSliceSet, + get: LinearSliceGet +): Pick { + return { + searchLinearIssues: async (query: string, limit = 20, options?: LinearFetchOptions) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const workspaceId = getSelectedWorkspaceId(get().linearStatus) + const cacheKey = scopedLinearCacheKey(scope, linearSearchCacheKey(workspaceId, query, limit)) + const cached = get().linearSearchCache[cacheKey] + if (!options?.force && isFresh(cached)) { + return cached.data ?? [] + } + + const inflight = inflightSearchRequests.get(cacheKey) + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === getLinearMutationGeneration() && + (!options?.force || inflight.force) + ) { + return inflight.promise + } + + let entry: InflightLinearListRequest + const requestCacheGeneration = getLinearCacheGeneration() + const requestMutationGeneration = getLinearMutationGeneration() + const promise = linearSearchIssues(scope.settings, query, limit, workspaceId) + .then((issues) => { + const data = issues as LinearIssue[] + if ( + inflightSearchRequests.get(cacheKey) === entry && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + set((s) => ({ + linearSearchCache: evictStaleEntries({ + ...s.linearSearchCache, + [cacheKey]: { data, fetchedAt: Date.now() } + }) + })) + } + return data + }) + .catch((error) => { + console.warn('[linear] searchLinearIssues failed:', error) + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + if (!shouldRefreshStatusAfterRead(workspaceId, get().linearStatus)) { + void get().checkLinearConnection(true) + } + return [] + } + return get().linearSearchCache[cacheKey]?.data ?? [] + }) + .finally(() => { + if (inflightSearchRequests.get(cacheKey) === entry) { + inflightSearchRequests.delete(cacheKey) + } + if ( + shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + }) + + entry = { + promise, + force: Boolean(options?.force), + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } + inflightSearchRequests.set(cacheKey, entry) + return promise + }, + + listLinearIssues: async (args: LinearIssueListReadArgs, options?: LinearFetchOptions) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const workspaceId = getSelectedWorkspaceId(get().linearStatus) + const filter = args.filter ?? 'assigned' + const effectiveLimit = clampLinearIssueListLimit(args.limit) + const attributeFilter = normalizeListAttributeFilter(args.attributeFilter) + const cacheKey = scopedLinearCacheKey( + scope, + linearListCacheKey(workspaceId, filter, effectiveLimit, attributeFilter) + ) + const cached = get().linearListCache[cacheKey] + if (!options?.force && isFresh(cached)) { + return cached.data ?? emptyLinearCollection() + } + + const inflight = inflightListRequests.get(cacheKey) + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === getLinearMutationGeneration() && + (!options?.force || inflight.force) + ) { + return inflight.promise + } + + let entry: InflightLinearPlainListRequest + const requestCacheGeneration = getLinearCacheGeneration() + const requestMutationGeneration = getLinearMutationGeneration() + const promise: Promise> = linearListIssues( + scope.settings, + filter, + effectiveLimit, + workspaceId, + attributeFilter + ) + .then((result) => { + const data = result as LinearCollectionResult + if ( + inflightListRequests.get(cacheKey) === entry && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + set((s) => ({ + linearListCache: evictStaleEntries({ + ...s.linearListCache, + [cacheKey]: { data, fetchedAt: Date.now() } + }) + })) + } + return data + }) + .catch((error) => { + console.warn('[linear] listLinearIssues failed:', error) + // Why: capability mismatch is actionable (update remote runtime). Swallowing + // it as [] would look like "no issues match filters" and hide the fix. + if (isLinearIssueAttributeFilterUnsupportedError(error)) { + throw error + } + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + if (!shouldRefreshStatusAfterRead(workspaceId, get().linearStatus)) { + void get().checkLinearConnection(true) + } + return emptyLinearCollection() + } + return get().linearListCache[cacheKey]?.data ?? emptyLinearCollection() + }) + .finally(() => { + if (inflightListRequests.get(cacheKey) === entry) { + inflightListRequests.delete(cacheKey) + } + if ( + shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + }) + + entry = { + promise, + force: Boolean(options?.force), + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } + inflightListRequests.set(cacheKey, entry) + return promise + } + } +} diff --git a/src/renderer/src/store/slices/linear/linear-project-detail-actions.ts b/src/renderer/src/store/slices/linear/linear-project-detail-actions.ts new file mode 100644 index 00000000000..e8e97b8c4c1 --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-project-detail-actions.ts @@ -0,0 +1,133 @@ +import type { LinearProjectDetail } from '../../../../../shared/linear/project-types' +import type { + LinearFetchOptions, + LinearSlice, + LinearSliceGet, + LinearSliceSet +} from './linear-slice-contract' +import { isIntegrationCredentialDecryptionError } from '../../../../../shared/integration-credential-errors' +import { linearGetProject } from '@/runtime/runtime-linear-project-client' +import { + evictStaleEntries, + isFresh, + linearCollectionCacheKey, + looksLikeAuthError, + shouldRefreshStatusAfterRead +} from './linear-cache' +import { + getLinearCacheGeneration, + getLinearMutationGeneration, + inflightProjectDetailRequests, + type InflightLinearDetailRequest +} from './linear-slice-request-state' +import { + canWriteLinearReadResult, + getLinearReadScope, + scopedLinearCacheKey +} from './linear-slice-scope' + +export function createLinearProjectDetailActions( + set: LinearSliceSet, + get: LinearSliceGet +): Pick { + return { + fetchLinearProject: async (id, workspaceId, options?: LinearFetchOptions) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(workspaceId, 'project-detail', id) + ) + const cached = get().linearProjectDetailCache[cacheKey] + if (!options?.force && isFresh(cached)) { + return cached.data + } + + const inflight = inflightProjectDetailRequests.get(cacheKey) + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === getLinearMutationGeneration() && + (!options?.force || inflight.force) + ) { + return inflight.promise + } + + let entry: InflightLinearDetailRequest + const requestCacheGeneration = getLinearCacheGeneration() + const requestMutationGeneration = getLinearMutationGeneration() + const promise = linearGetProject(scope.settings, id, workspaceId, { + force: options?.force + }) + .then((project) => { + if ( + inflightProjectDetailRequests.get(cacheKey) === entry && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + set((s) => ({ + linearProjectDetailCache: evictStaleEntries({ + ...s.linearProjectDetailCache, + [cacheKey]: { data: project, fetchedAt: Date.now() } + }) + })) + } + return project + }) + .catch((error) => { + console.warn('[linear] fetchLinearProject failed:', error) + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + if (options?.force) { + throw error + } + const cachedResult = get().linearProjectDetailCache[cacheKey] + if (cachedResult) { + return cachedResult.data + } + throw error + }) + .finally(() => { + if (inflightProjectDetailRequests.get(cacheKey) === entry) { + inflightProjectDetailRequests.delete(cacheKey) + } + if ( + shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + }) + + entry = { + promise, + force: Boolean(options?.force), + contextKey, + mutationGeneration: requestMutationGeneration + } + inflightProjectDetailRequests.set(cacheKey, entry) + return promise + } + } +} diff --git a/src/renderer/src/store/slices/linear/linear-project-issue-actions.ts b/src/renderer/src/store/slices/linear/linear-project-issue-actions.ts new file mode 100644 index 00000000000..1fbdb093ba0 --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-project-issue-actions.ts @@ -0,0 +1,153 @@ +import type { LinearIssue } from '../../../../../shared/linear/issue-types' +import type { + LinearFetchOptions, + LinearSlice, + LinearSliceGet, + LinearSliceSet +} from './linear-slice-contract' +import { clampLinearIssueListLimit } from '../../../../../shared/linear/issue-read-limits' +import { isIntegrationCredentialDecryptionError } from '../../../../../shared/integration-credential-errors' +import { linearListProjectIssues } from '@/runtime/runtime-linear-project-client' +import { + collectionWithWorkspaceError, + emptyLinearCollection, + evictStaleEntries, + largestCachedCollectionBelowLimit, + isFresh, + linearCollectionCacheKey, + looksLikeAuthError, + shouldRefreshStatusAfterRead +} from './linear-cache' +import { + getLinearCacheGeneration, + getLinearMutationGeneration, + inflightProjectIssueRequests, + type InflightLinearCollectionRequest +} from './linear-slice-request-state' +import { + canWriteLinearReadResult, + getLinearReadScope, + scopedLinearCacheKey +} from './linear-slice-scope' + +export function createLinearProjectIssueActions( + set: LinearSliceSet, + get: LinearSliceGet +): Pick { + return { + listLinearProjectIssues: async ( + projectId, + workspaceId, + limit = 20, + options?: LinearFetchOptions + ) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const effectiveLimit = clampLinearIssueListLimit(limit) + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(workspaceId, 'project-issues', projectId, effectiveLimit) + ) + const cached = get().linearProjectIssueCache[cacheKey] + if (!options?.force && isFresh(cached)) { + return cached.data ?? emptyLinearCollection() + } + + const inflight = inflightProjectIssueRequests.get(cacheKey) + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === getLinearMutationGeneration() && + (!options?.force || inflight.force) + ) { + return inflight.promise + } + + let entry: InflightLinearCollectionRequest + const requestCacheGeneration = getLinearCacheGeneration() + const requestMutationGeneration = getLinearMutationGeneration() + const promise = linearListProjectIssues( + scope.settings, + projectId, + effectiveLimit, + workspaceId, + { + force: options?.force + } + ) + .then((result) => { + if ( + inflightProjectIssueRequests.get(cacheKey) === entry && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + set((s) => ({ + linearProjectIssueCache: evictStaleEntries({ + ...s.linearProjectIssueCache, + [cacheKey]: { data: result, fetchedAt: Date.now() } + }) + })) + } + return result + }) + .catch((error) => { + console.warn('[linear] listLinearProjectIssues failed:', error) + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + const fallback = + get().linearProjectIssueCache[cacheKey]?.data ?? + largestCachedCollectionBelowLimit( + get().linearProjectIssueCache, + workspaceId, + 'project-issues', + projectId, + effectiveLimit + ) ?? + emptyLinearCollection() + return collectionWithWorkspaceError(fallback, workspaceId, error) + }) + .finally(() => { + if (inflightProjectIssueRequests.get(cacheKey) === entry) { + inflightProjectIssueRequests.delete(cacheKey) + } + if ( + shouldRefreshStatusAfterRead(workspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + }) + + entry = { + promise, + force: Boolean(options?.force), + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } + inflightProjectIssueRequests.set(cacheKey, entry) + return promise + } + } +} diff --git a/src/renderer/src/store/slices/linear/linear-project-list-actions.ts b/src/renderer/src/store/slices/linear/linear-project-list-actions.ts new file mode 100644 index 00000000000..ac7a6fbb07e --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-project-list-actions.ts @@ -0,0 +1,147 @@ +import type { LinearProjectSummary } from '../../../../../shared/linear/project-types' +import type { + LinearFetchOptions, + LinearSlice, + LinearSliceGet, + LinearSliceSet +} from './linear-slice-contract' +import { isIntegrationCredentialDecryptionError } from '../../../../../shared/integration-credential-errors' +import { linearListProjects } from '@/runtime/runtime-linear-project-client' +import { + collectionWithWorkspaceError, + emptyLinearCollection, + evictStaleEntries, + getSelectedWorkspaceId, + isFresh, + linearCollectionCacheKey, + looksLikeAuthError, + shouldRefreshStatusAfterRead +} from './linear-cache' +import { + getLinearCacheGeneration, + getLinearMutationGeneration, + inflightProjectRequests, + type InflightLinearCollectionRequest +} from './linear-slice-request-state' +import { + canWriteLinearReadResult, + getLinearReadScope, + scopedLinearCacheKey +} from './linear-slice-scope' + +export function createLinearProjectListActions( + set: LinearSliceSet, + get: LinearSliceGet +): Pick { + return { + getCachedLinearProjects: (query, limit = 20, workspaceId, options) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) + const cacheKey = linearCollectionCacheKey( + resolvedWorkspaceId, + 'projects', + query?.trim(), + limit + ) + return get().linearProjectCache[scopedLinearCacheKey(scope, cacheKey)]?.data ?? null + }, + + listLinearProjects: async (query, limit = 20, workspaceId, options?: LinearFetchOptions) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) + const trimmed = query?.trim() || undefined + const cacheKey = scopedLinearCacheKey( + scope, + linearCollectionCacheKey(resolvedWorkspaceId, 'projects', trimmed, limit) + ) + const cached = get().linearProjectCache[cacheKey] + if (!options?.force && isFresh(cached)) { + return cached.data ?? emptyLinearCollection() + } + + const inflight = inflightProjectRequests.get(cacheKey) + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === getLinearMutationGeneration() && + (!options?.force || inflight.force) + ) { + return inflight.promise + } + + let entry: InflightLinearCollectionRequest + const requestCacheGeneration = getLinearCacheGeneration() + const requestMutationGeneration = getLinearMutationGeneration() + const promise = linearListProjects(scope.settings, trimmed, limit, resolvedWorkspaceId, { + force: options?.force + }) + .then((result) => { + if ( + inflightProjectRequests.get(cacheKey) === entry && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + set((s) => ({ + linearProjectCache: evictStaleEntries({ + ...s.linearProjectCache, + [cacheKey]: { data: result, fetchedAt: Date.now() } + }) + })) + } + return result + }) + .catch((error) => { + console.warn('[linear] listLinearProjects failed:', error) + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + const fallback = + get().linearProjectCache[cacheKey]?.data ?? + emptyLinearCollection() + return collectionWithWorkspaceError(fallback, resolvedWorkspaceId ?? 'default', error) + }) + .finally(() => { + if (inflightProjectRequests.get(cacheKey) === entry) { + inflightProjectRequests.delete(cacheKey) + } + if ( + shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + }) + + entry = { + promise, + force: Boolean(options?.force), + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } + inflightProjectRequests.set(cacheKey, entry) + return promise + } + } +} diff --git a/src/renderer/src/store/slices/linear/linear-slice-connection-actions.ts b/src/renderer/src/store/slices/linear/linear-slice-connection-actions.ts new file mode 100644 index 00000000000..c469ca99412 --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-slice-connection-actions.ts @@ -0,0 +1,237 @@ +import type { LinearViewer } from '../../../../../shared/linear/workspace-types' +import type { LinearSlice, LinearSliceGet, LinearSliceSet } from './linear-slice-contract' +import { + linearConnect, + linearDisconnect, + linearDisconnectWorkspace, + linearSelectWorkspace, + linearStatus, + linearTestConnection +} from '@/runtime/runtime-linear-client' +import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' +import { translate } from '@/i18n/i18n' +import { + beginLinearMutation, + invalidateLinearCaches, + isCurrentLinearMutation +} from './linear-slice-request-state' +import { isCurrentLinearRuntimeContext } from './linear-slice-scope' +import { linearStatusScopeSignature } from './linear-cache' + +export function createLinearConnectionActions( + set: LinearSliceSet, + get: LinearSliceGet +): Pick< + LinearSlice, + | 'testLinearConnection' + | 'connectLinear' + | 'selectLinearWorkspace' + | 'disconnectLinear' + | 'disconnectLinearWorkspace' +> { + return { + testLinearConnection: async (workspaceId) => { + const requestGeneration = beginLinearMutation() + const contextKey = getProviderRuntimeContextKey(get().settings) + try { + const result = (await linearTestConnection(get().settings, workspaceId)) as + | { ok: true; viewer: LinearViewer } + | { ok: false; error: string } + if ( + !isCurrentLinearMutation(requestGeneration) || + !isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { + return result + } + const status = await linearStatus(get().settings) + if ( + isCurrentLinearMutation(requestGeneration) && + isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { + const prev = get().linearStatus + if (linearStatusScopeSignature(prev) !== linearStatusScopeSignature(status)) { + invalidateLinearCaches() + set({ + linearStatus: status, + linearIssueCache: {}, + linearSearchCache: {}, + linearListCache: {}, + linearTeamCache: {}, + linearProjectCache: {}, + linearProjectDetailCache: {}, + linearProjectIssueCache: {}, + linearCustomViewCache: {}, + linearCustomViewDetailCache: {}, + linearCustomViewIssueCache: {}, + linearCustomViewProjectCache: {}, + linearStatusChecked: true, + linearStatusContextKey: contextKey + }) + } else { + set({ + linearStatus: status, + linearStatusChecked: true, + linearStatusContextKey: contextKey + }) + } + } + return result + } catch (error) { + const message = error instanceof Error ? error.message : 'Test failed' + return { ok: false as const, error: message } + } + }, + + connectLinear: async (apiKey: string) => { + const requestGeneration = beginLinearMutation() + const contextKey = getProviderRuntimeContextKey(get().settings) + try { + const result = await linearConnect(get().settings, apiKey) + if ( + result.ok && + isCurrentLinearMutation(requestGeneration) && + isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { + invalidateLinearCaches() + set({ + linearIssueCache: {}, + linearSearchCache: {}, + linearListCache: {}, + linearTeamCache: {}, + linearProjectCache: {}, + linearProjectDetailCache: {}, + linearProjectIssueCache: {}, + linearCustomViewCache: {}, + linearCustomViewDetailCache: {}, + linearCustomViewIssueCache: {}, + linearCustomViewProjectCache: {} + }) + const status = await linearStatus(get().settings) + if ( + !isCurrentLinearMutation(requestGeneration) || + !isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { + return { + ok: false as const, + error: translate( + 'auto.store.slices.linear.37d36984d0', + 'Linear connection was superseded by a newer request.' + ) + } + } + set({ + linearStatus: status, + linearStatusChecked: true, + linearStatusContextKey: contextKey + }) + } else if (result.ok) { + return { + ok: false as const, + error: translate( + 'auto.store.slices.linear.37d36984d0', + 'Linear connection was superseded by a newer request.' + ) + } + } + return result as { ok: true; viewer: LinearViewer } | { ok: false; error: string } + } catch (error) { + const message = error instanceof Error ? error.message : 'Connection failed' + return { ok: false as const, error: message } + } + }, + + selectLinearWorkspace: async (workspaceId) => { + const requestGeneration = beginLinearMutation() + const contextKey = getProviderRuntimeContextKey(get().settings) + const status = await linearSelectWorkspace(get().settings, workspaceId) + if ( + !isCurrentLinearMutation(requestGeneration) || + !isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { + return + } + invalidateLinearCaches() + set({ + linearStatus: status, + linearIssueCache: {}, + linearSearchCache: {}, + linearListCache: {}, + linearTeamCache: {}, + linearProjectCache: {}, + linearProjectDetailCache: {}, + linearProjectIssueCache: {}, + linearCustomViewCache: {}, + linearCustomViewDetailCache: {}, + linearCustomViewIssueCache: {}, + linearCustomViewProjectCache: {}, + linearStatusChecked: true, + linearStatusContextKey: contextKey + }) + }, + + disconnectLinear: async () => { + const requestGeneration = beginLinearMutation() + const contextKey = getProviderRuntimeContextKey(get().settings) + await linearDisconnect(get().settings) + if ( + !isCurrentLinearMutation(requestGeneration) || + !isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { + return + } + invalidateLinearCaches() + set({ + linearStatus: { connected: false, viewer: null }, + linearIssueCache: {}, + linearSearchCache: {}, + linearListCache: {}, + linearTeamCache: {}, + linearProjectCache: {}, + linearProjectDetailCache: {}, + linearProjectIssueCache: {}, + linearCustomViewCache: {}, + linearCustomViewDetailCache: {}, + linearCustomViewIssueCache: {}, + linearCustomViewProjectCache: {}, + linearStatusChecked: true, + linearStatusContextKey: contextKey + }) + }, + + disconnectLinearWorkspace: async (workspaceId) => { + const requestGeneration = beginLinearMutation() + const contextKey = getProviderRuntimeContextKey(get().settings) + await linearDisconnectWorkspace(get().settings, workspaceId) + if ( + !isCurrentLinearMutation(requestGeneration) || + !isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { + return + } + const status = await linearStatus(get().settings) + if ( + !isCurrentLinearMutation(requestGeneration) || + !isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { + return + } + invalidateLinearCaches() + set({ + linearStatus: status, + linearIssueCache: {}, + linearSearchCache: {}, + linearListCache: {}, + linearTeamCache: {}, + linearProjectCache: {}, + linearProjectDetailCache: {}, + linearProjectIssueCache: {}, + linearCustomViewCache: {}, + linearCustomViewDetailCache: {}, + linearCustomViewIssueCache: {}, + linearCustomViewProjectCache: {}, + linearStatusChecked: true, + linearStatusContextKey: contextKey + }) + } + } +} diff --git a/src/renderer/src/store/slices/linear/linear-slice-contract.ts b/src/renderer/src/store/slices/linear/linear-slice-contract.ts new file mode 100644 index 00000000000..3c87fdf3090 --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-slice-contract.ts @@ -0,0 +1,160 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../../types' +import type { LinearIssue } from '../../../../../shared/linear/issue-types' +import type { + LinearCustomViewModel, + LinearCustomViewSummary, + LinearProjectDetail, + LinearProjectSummary +} from '../../../../../shared/linear/project-types' +import type { + LinearCollectionResult, + LinearConnectionStatus, + LinearTeam, + LinearViewer, + LinearWorkspaceSelection +} from '../../../../../shared/linear/workspace-types' +import type { CacheEntry } from '../../github/cache-model' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import type { LinearIssueAttributeFilter } from '../../../../../shared/linear/issue-attribute-filter' + +export type LinearIssueListReadArgs = { + kind: 'list' + filter?: 'assigned' | 'created' | 'all' | 'completed' + limit?: number + attributeFilter?: LinearIssueAttributeFilter | null +} + +export type LinearIssueReadArgs = + | { kind: 'search'; query: string; limit?: number } + | LinearIssueListReadArgs + +export type LinearFetchOptions = { force?: boolean; sourceContext?: TaskSourceContext | null } +export type LinearPatchOptions = { sourceContext?: TaskSourceContext | null } + +export type LinearSlice = { + linearStatus: LinearConnectionStatus + linearStatusChecked: boolean + linearStatusContextKey: string | null + linearIssueCache: Record> + linearSearchCache: Record> + linearListCache: Record>> + linearTeamCache: Record> + linearProjectCache: Record>> + linearProjectDetailCache: Record> + linearProjectIssueCache: Record>> + linearCustomViewCache: Record>> + linearCustomViewDetailCache: Record> + linearCustomViewIssueCache: Record>> + linearCustomViewProjectCache: Record< + string, + CacheEntry> + > + + checkLinearConnection: (force?: boolean) => Promise + connectLinear: ( + apiKey: string + ) => Promise<{ ok: true; viewer: LinearViewer } | { ok: false; error: string }> + testLinearConnection: ( + workspaceId?: string | null + ) => Promise<{ ok: true; viewer: LinearViewer } | { ok: false; error: string }> + selectLinearWorkspace: (workspaceId: LinearWorkspaceSelection) => Promise + disconnectLinear: () => Promise + disconnectLinearWorkspace: (workspaceId: string) => Promise + fetchLinearIssue: ( + id: string, + workspaceId?: string | null, + options?: LinearFetchOptions + ) => Promise + refreshLinearIssue: ( + id: string, + workspaceId?: string | null, + options?: LinearFetchOptions + ) => Promise + getCachedLinearIssues: ( + args: LinearIssueReadArgs, + options?: Pick + ) => LinearIssue[] | LinearCollectionResult | null + prefetchLinearIssues: (args: LinearIssueReadArgs, options?: LinearFetchOptions) => void + searchLinearIssues: ( + query: string, + limit?: number, + options?: LinearFetchOptions + ) => Promise + listLinearIssues: ( + args: LinearIssueListReadArgs, + options?: LinearFetchOptions + ) => Promise> + linearListInvalidationToken: { scope: string; version: number } + invalidateLinearIssueLists: (options?: Pick) => void + getCachedLinearTeams: ( + workspaceId?: LinearWorkspaceSelection | null, + options?: Pick + ) => LinearTeam[] | null + listLinearTeams: ( + workspaceId?: LinearWorkspaceSelection | null, + options?: LinearFetchOptions + ) => Promise + getCachedLinearProjects: ( + query?: string, + limit?: number, + workspaceId?: LinearWorkspaceSelection | null, + options?: Pick + ) => LinearCollectionResult | null + listLinearProjects: ( + query?: string, + limit?: number, + workspaceId?: LinearWorkspaceSelection | null, + options?: LinearFetchOptions + ) => Promise> + fetchLinearProject: ( + id: string, + workspaceId: string, + options?: LinearFetchOptions + ) => Promise + listLinearProjectIssues: ( + projectId: string, + workspaceId: string, + limit?: number, + options?: LinearFetchOptions + ) => Promise> + getCachedLinearCustomViews: ( + model: LinearCustomViewModel, + limit?: number, + workspaceId?: LinearWorkspaceSelection | null, + options?: Pick + ) => LinearCollectionResult | null + listLinearCustomViews: ( + model: LinearCustomViewModel, + limit?: number, + workspaceId?: LinearWorkspaceSelection | null, + options?: LinearFetchOptions + ) => Promise> + fetchLinearCustomView: ( + viewId: string, + workspaceId: string, + model: LinearCustomViewModel, + options?: LinearFetchOptions + ) => Promise + listLinearCustomViewIssues: ( + viewId: string, + workspaceId: string, + limit?: number, + options?: LinearFetchOptions + ) => Promise> + listLinearCustomViewProjects: ( + viewId: string, + workspaceId: string, + limit?: number, + options?: LinearFetchOptions + ) => Promise> + patchLinearIssue: ( + issueId: string, + patch: Partial, + options?: LinearPatchOptions + ) => void +} + +export type LinearSliceStateCreator = StateCreator +export type LinearSliceSet = Parameters[0] +export type LinearSliceGet = Parameters[1] diff --git a/src/renderer/src/store/slices/linear/linear-slice-request-state.ts b/src/renderer/src/store/slices/linear/linear-slice-request-state.ts new file mode 100644 index 00000000000..cd232ba768b --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-slice-request-state.ts @@ -0,0 +1,161 @@ +import type { LinearIssue } from '../../../../../shared/linear/issue-types' +import type { + LinearCustomViewSummary, + LinearProjectDetail, + LinearProjectSummary +} from '../../../../../shared/linear/project-types' +import type { + LinearCollectionResult, + LinearTeam +} from '../../../../../shared/linear/workspace-types' +import { clearLinearMetadataCache } from '../../../hooks/useIssueMetadata' + +export type InflightLinearIssueRequest = { + promise: Promise + generation: number + contextKey: string + mutationGeneration: number +} + +export type InflightLinearListRequest = { + promise: Promise + force: boolean + generation: number + contextKey: string + mutationGeneration: number +} + +export type InflightLinearPlainListRequest = { + promise: Promise> + force: boolean + generation: number + contextKey: string + mutationGeneration: number +} + +export type InflightLinearCollectionRequest = { + promise: Promise> + force: boolean + generation: number + contextKey: string + mutationGeneration: number +} + +export type InflightLinearDetailRequest = { + promise: Promise + force: boolean + contextKey: string + mutationGeneration: number +} + +export type InflightLinearTeamRequest = { + promise: Promise + force: boolean + generation: number + contextKey: string + mutationGeneration: number +} + +export const linearRequestState = { + issueRequests: new Map(), + searchRequests: new Map(), + listRequests: new Map(), + teamRequests: new Map(), + projectRequests: new Map>(), + projectDetailRequests: new Map>(), + projectIssueRequests: new Map>(), + customViewRequests: new Map>(), + customViewDetailRequests: new Map< + string, + InflightLinearDetailRequest + >(), + customViewIssueRequests: new Map>(), + customViewProjectRequests: new Map< + string, + InflightLinearCollectionRequest + >(), + inflightStatusRequest: null as { contextKey: string; promise: Promise } | null, + statusReadGeneration: 0, + mutationGeneration: 0, + cacheGeneration: 0 +} + +// Named aliases keep action modules focused on behavior while sharing one request registry. +export const inflightIssueRequests = linearRequestState.issueRequests +export const inflightSearchRequests = linearRequestState.searchRequests +export const inflightListRequests = linearRequestState.listRequests +export const inflightTeamRequests = linearRequestState.teamRequests +export const inflightProjectRequests = linearRequestState.projectRequests +export const inflightProjectDetailRequests = linearRequestState.projectDetailRequests +export const inflightProjectIssueRequests = linearRequestState.projectIssueRequests +export const inflightCustomViewRequests = linearRequestState.customViewRequests +export const inflightCustomViewDetailRequests = linearRequestState.customViewDetailRequests +export const inflightCustomViewIssueRequests = linearRequestState.customViewIssueRequests +export const inflightCustomViewProjectRequests = linearRequestState.customViewProjectRequests + +export function clearLinearRequestMaps(): void { + linearRequestState.issueRequests.clear() + linearRequestState.searchRequests.clear() + linearRequestState.listRequests.clear() + linearRequestState.teamRequests.clear() + linearRequestState.projectRequests.clear() + linearRequestState.projectDetailRequests.clear() + linearRequestState.projectIssueRequests.clear() + linearRequestState.customViewRequests.clear() + linearRequestState.customViewDetailRequests.clear() + linearRequestState.customViewIssueRequests.clear() + linearRequestState.customViewProjectRequests.clear() +} + +export function invalidateLinearCaches(): void { + linearRequestState.cacheGeneration += 1 + clearLinearRequestMaps() + clearLinearMetadataCache() +} + +export function clearLinearIssueCollectionRequestMaps(): void { + linearRequestState.searchRequests.clear() + linearRequestState.listRequests.clear() + linearRequestState.projectIssueRequests.clear() + linearRequestState.customViewIssueRequests.clear() +} + +export function beginLinearMutation(): number { + linearRequestState.mutationGeneration += 1 + linearRequestState.inflightStatusRequest = null + return linearRequestState.mutationGeneration +} + +export function isCurrentLinearMutation(generation: number): boolean { + return generation === linearRequestState.mutationGeneration +} + +export function nextLinearStatusReadGeneration(): number { + linearRequestState.statusReadGeneration += 1 + return linearRequestState.statusReadGeneration +} + +export function isCurrentLinearStatusRead(generation: number): boolean { + return generation === linearRequestState.statusReadGeneration +} + +export function getLinearCacheGeneration(): number { + return linearRequestState.cacheGeneration +} + +export function getLinearMutationGeneration(): number { + return linearRequestState.mutationGeneration +} + +export function getInflightStatusRequest(): { + contextKey: string + promise: Promise +} | null { + return linearRequestState.inflightStatusRequest +} + +export function setInflightStatusRequest( + request: { contextKey: string; promise: Promise } | null +): void { + linearRequestState.inflightStatusRequest = request +} diff --git a/src/renderer/src/store/slices/linear/linear-slice-scope.ts b/src/renderer/src/store/slices/linear/linear-slice-scope.ts new file mode 100644 index 00000000000..42c1aadac03 --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-slice-scope.ts @@ -0,0 +1,102 @@ +import type { AppState } from '../../types' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import { + getTaskSourceCacheScope, + getTaskSourceRuntimeSettings +} from '../../../../../shared/task-source-context' +import { + canonicalizeLinearIssueAttributeFilter, + isEmptyLinearIssueAttributeFilter, + type LinearIssueAttributeFilter +} from '../../../../../shared/linear/issue-attribute-filter' +import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' +import { + getLinearCacheGeneration, + getLinearMutationGeneration, + linearRequestState +} from './linear-slice-request-state' + +export function normalizeListAttributeFilter( + attributeFilter?: LinearIssueAttributeFilter | null +): LinearIssueAttributeFilter | undefined { + if (!attributeFilter || isEmptyLinearIssueAttributeFilter(attributeFilter)) { + return undefined + } + return canonicalizeLinearIssueAttributeFilter(attributeFilter) +} + +export type LinearReadScope = { + settings: AppState['settings'] | TaskSourceContext | null + contextKey: string + cachePrefix: string | null + explicitSource: boolean +} + +export function isCurrentLinearRuntimeContext( + contextKey: string, + settings: AppState['settings'] +): boolean { + return getProviderRuntimeContextKey(settings) === contextKey +} + +export function canWriteLinearReadResult( + contextKey: string, + generation: number, + mutationGeneration: number, + settings: AppState['settings'], + explicitSource = false +): boolean { + return ( + generation === getLinearCacheGeneration() && + mutationGeneration === getLinearMutationGeneration() && + (explicitSource || isCurrentLinearRuntimeContext(contextKey, settings)) + ) +} + +export function getLinearReadScope( + settings: AppState['settings'], + sourceContext?: TaskSourceContext | null +): LinearReadScope { + if (!sourceContext) { + return { + settings, + contextKey: getProviderRuntimeContextKey(settings), + cachePrefix: null, + explicitSource: false + } + } + const runtimeSettings = getTaskSourceRuntimeSettings(sourceContext) + const cachePrefix = getTaskSourceCacheScope(sourceContext) + return { + settings: sourceContext, + contextKey: `${getProviderRuntimeContextKey(runtimeSettings)}::${cachePrefix}`, + cachePrefix, + explicitSource: true + } +} + +export function scopedLinearCacheKey(scope: LinearReadScope, key: string): string { + return scope.cachePrefix ? `${scope.cachePrefix}::${key}` : key +} + +/** Read the current request generation without exposing mutable implementation details. */ +export function linearReadGenerationSnapshot(): { + cacheGeneration: number + mutationGeneration: number +} { + return { + cacheGeneration: getLinearCacheGeneration(), + mutationGeneration: getLinearMutationGeneration() + } +} + +/** Keep the status request handle private to the request-state module. */ +export function getInflightStatusRequest(): { contextKey: string; promise: Promise } | null { + return linearRequestState.inflightStatusRequest +} + +export function setInflightStatusRequest( + request: { contextKey: string; promise: Promise } | null +): void { + linearRequestState.inflightStatusRequest = request +} diff --git a/src/renderer/src/store/slices/linear/linear-slice-status-actions.ts b/src/renderer/src/store/slices/linear/linear-slice-status-actions.ts new file mode 100644 index 00000000000..1084f72d0d0 --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-slice-status-actions.ts @@ -0,0 +1,115 @@ +import type { LinearConnectionStatus } from '../../../../../shared/linear/workspace-types' +import type { LinearSlice, LinearSliceGet, LinearSliceSet } from './linear-slice-contract' +import { linearStatus } from '@/runtime/runtime-linear-client' +import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' +import { + getInflightStatusRequest, + getLinearMutationGeneration, + invalidateLinearCaches, + isCurrentLinearStatusRead, + nextLinearStatusReadGeneration, + setInflightStatusRequest +} from './linear-slice-request-state' +import { linearStatusScopeSignature } from './linear-cache' +import { isCurrentLinearRuntimeContext } from './linear-slice-scope' + +export function createLinearStatusActions( + set: LinearSliceSet, + get: LinearSliceGet +): Pick { + return { + checkLinearConnection: async (force = false) => { + const contextKey = getProviderRuntimeContextKey(get().settings) + const inflightStatusRequest = getInflightStatusRequest() + if (inflightStatusRequest && !force && inflightStatusRequest.contextKey === contextKey) { + return inflightStatusRequest.promise + } + if (get().linearStatusContextKey !== contextKey) { + set({ linearStatusChecked: false }) + } + + const mutationGeneration = getLinearMutationGeneration() + const statusReadGeneration = nextLinearStatusReadGeneration() + const request = linearStatus(get().settings) + .then((status) => { + if ( + mutationGeneration !== getLinearMutationGeneration() || + !isCurrentLinearStatusRead(statusReadGeneration) || + !isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { + return + } + const typedStatus = status as LinearConnectionStatus + const prev = get().linearStatus + const prevScopeSignature = linearStatusScopeSignature(prev) + const nextScopeSignature = linearStatusScopeSignature(typedStatus) + if (prevScopeSignature !== nextScopeSignature) { + invalidateLinearCaches() + set({ + linearStatus: typedStatus, + linearIssueCache: {}, + linearSearchCache: {}, + linearListCache: {}, + linearTeamCache: {}, + linearProjectCache: {}, + linearProjectDetailCache: {}, + linearProjectIssueCache: {}, + linearCustomViewCache: {}, + linearCustomViewDetailCache: {}, + linearCustomViewIssueCache: {}, + linearCustomViewProjectCache: {}, + linearStatusChecked: true, + linearStatusContextKey: contextKey + }) + } else if (!get().linearStatusChecked) { + set({ linearStatusChecked: true, linearStatusContextKey: contextKey }) + } else if (get().linearStatusContextKey !== contextKey) { + set({ linearStatusContextKey: contextKey }) + } + }) + .catch(() => { + if ( + mutationGeneration !== getLinearMutationGeneration() || + !isCurrentLinearStatusRead(statusReadGeneration) || + !isCurrentLinearRuntimeContext(contextKey, get().settings) + ) { + return + } + if (get().linearStatus.connected) { + invalidateLinearCaches() + set({ + linearStatus: { connected: false, viewer: null }, + linearIssueCache: {}, + linearSearchCache: {}, + linearListCache: {}, + linearTeamCache: {}, + linearProjectCache: {}, + linearProjectDetailCache: {}, + linearProjectIssueCache: {}, + linearCustomViewCache: {}, + linearCustomViewDetailCache: {}, + linearCustomViewIssueCache: {}, + linearCustomViewProjectCache: {}, + linearStatusChecked: true, + linearStatusContextKey: contextKey + }) + } else if (!get().linearStatusChecked) { + set({ linearStatusChecked: true, linearStatusContextKey: contextKey }) + } else if (get().linearStatusContextKey !== contextKey) { + set({ linearStatusContextKey: contextKey }) + } + }) + .finally(() => { + if ( + isCurrentLinearStatusRead(statusReadGeneration) && + getInflightStatusRequest()?.promise === request + ) { + setInflightStatusRequest(null) + } + }) + setInflightStatusRequest({ contextKey, promise: request }) + + return request + } + } +} diff --git a/src/renderer/src/store/slices/linear/linear-team-actions.ts b/src/renderer/src/store/slices/linear/linear-team-actions.ts new file mode 100644 index 00000000000..b551927cbcc --- /dev/null +++ b/src/renderer/src/store/slices/linear/linear-team-actions.ts @@ -0,0 +1,135 @@ +import type { LinearTeam } from '../../../../../shared/linear/workspace-types' +import type { + LinearFetchOptions, + LinearSlice, + LinearSliceGet, + LinearSliceSet +} from './linear-slice-contract' +import { isIntegrationCredentialDecryptionError } from '../../../../../shared/integration-credential-errors' +import { linearListTeams } from '@/runtime/runtime-linear-project-client' +import { + evictStaleEntries, + getSelectedWorkspaceId, + isFresh, + linearTeamsCacheKey, + looksLikeAuthError, + shouldRefreshStatusAfterRead, + TEAM_CACHE_TTL +} from './linear-cache' +import { + getLinearCacheGeneration, + getLinearMutationGeneration, + inflightTeamRequests, + type InflightLinearTeamRequest +} from './linear-slice-request-state' +import { + canWriteLinearReadResult, + getLinearReadScope, + scopedLinearCacheKey +} from './linear-slice-scope' + +export function createLinearTeamActions( + set: LinearSliceSet, + get: LinearSliceGet +): Pick { + return { + getCachedLinearTeams: (workspaceId, options) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const key = linearTeamsCacheKey(workspaceId ?? getSelectedWorkspaceId(get().linearStatus)) + return get().linearTeamCache[scopedLinearCacheKey(scope, key)]?.data ?? null + }, + + listLinearTeams: async (workspaceId, options?: LinearFetchOptions) => { + const scope = getLinearReadScope(get().settings, options?.sourceContext) + const { contextKey } = scope + const resolvedWorkspaceId = workspaceId ?? getSelectedWorkspaceId(get().linearStatus) + const cacheKey = scopedLinearCacheKey(scope, linearTeamsCacheKey(resolvedWorkspaceId)) + const cached = get().linearTeamCache[cacheKey] + if (!options?.force && isFresh(cached, TEAM_CACHE_TTL)) { + return cached.data ?? [] + } + + const inflight = inflightTeamRequests.get(cacheKey) + if ( + inflight && + inflight.contextKey === contextKey && + inflight.mutationGeneration === getLinearMutationGeneration() && + (!options?.force || inflight.force) + ) { + return inflight.promise + } + + let entry: InflightLinearTeamRequest + const requestCacheGeneration = getLinearCacheGeneration() + const requestMutationGeneration = getLinearMutationGeneration() + const promise = linearListTeams(scope.settings, resolvedWorkspaceId) + .then((teams) => { + const data = teams as LinearTeam[] + if ( + inflightTeamRequests.get(cacheKey) === entry && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + set((s) => ({ + linearTeamCache: evictStaleEntries({ + ...s.linearTeamCache, + [cacheKey]: { data, fetchedAt: Date.now() } + }) + })) + } + return data + }) + .catch((error) => { + console.warn('[linear] listLinearTeams failed:', error) + if ( + (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + if (!shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus)) { + void get().checkLinearConnection(true) + } + return [] + } + return get().linearTeamCache[cacheKey]?.data ?? [] + }) + .finally(() => { + if (inflightTeamRequests.get(cacheKey) === entry) { + inflightTeamRequests.delete(cacheKey) + } + if ( + shouldRefreshStatusAfterRead(resolvedWorkspaceId, get().linearStatus) && + canWriteLinearReadResult( + contextKey, + requestCacheGeneration, + requestMutationGeneration, + get().settings, + scope.explicitSource + ) + ) { + void get().checkLinearConnection(true) + } + }) + + entry = { + promise, + force: Boolean(options?.force), + generation: requestCacheGeneration, + contextKey, + mutationGeneration: requestMutationGeneration + } + inflightTeamRequests.set(cacheKey, entry) + return promise + } + } +} diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 1ef2684f3a0..d44de7db8f2 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -1,2111 +1,12 @@ -/* eslint-disable max-lines -- Why: split-tab group state updates layout, focus, and tab membership atomically in one slice to avoid split-brain. */ -import type { StateCreator } from 'zustand' -import type { AppState } from '../types' -import { toVisibleTabType } from '../../../../shared/tab-types' -import type { - Tab, - TabContentType, - TabGroup, - TabGroupLayoutNode, - WorkspaceVisibleTabType -} from '../../../../shared/tab-types' -import type { TerminalTab } from '../../../../shared/terminal-tab-types' -import type { TuiAgent } from '../../../../shared/tui-agent' -import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' -import { emitNativeChatToggled } from '@/lib/native-chat-telemetry' -import { - dedupeTabOrder, - ensureGroup, - findGroupAndWorktree, - findGroupForTab, - findTabAndWorktree, - findTabByEntityInGroup, - patchTab, - pickNextActiveTab, - pushRecentTabId, - sanitizeRecentTabIds, - updateGroup -} from './tab-group-state' -import { isPaneColumnSplitDropNoOp } from './pane-column-split-drop-no-op' -import { buildHydratedTabState, pruneTabGroupLayoutForGroups } from './tabs-hydration' -import { - buildOrphanTerminalCleanupPatch, - getOrphanTerminalIds, - terminalTabHasReconnectablePty -} from './terminal-orphan-helpers' -import { createBrowserUuid } from '@/lib/browser-uuid' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import { folderWorkspaceKey } from '../../../../shared/workspace-scope' -import { getActiveExecutionHostIdForWorktree } from '@/lib/unified-tab-host-ownership' -import { - addAdditionalValidWorkspaceKeys, - type WorkspaceSessionHydrationOptions -} from '@/lib/workspace-session-hydration-keys' -import { - buildValidWorktreeIdsForSessionHydration, - collectPersistedWorktreeIdsForSessionHydration -} from './degraded-repo-worktree-validity' - -export type TabSplitDirection = 'left' | 'right' | 'up' | 'down' - -function replaceWorkspaceRecordKeys( - current: Record, - hydrated: Record, - workspaceKeys: ReadonlySet -): Record { - return { - ...Object.fromEntries(Object.entries(current).filter(([key]) => !workspaceKeys.has(key))), - ...Object.fromEntries(Object.entries(hydrated).filter(([key]) => workspaceKeys.has(key))) - } -} - -export type TabsSlice = { - unifiedTabsByWorktree: Record - // Why: id of the tab whose inline title editor should open; shortcut (tab.rename) sets it, the tab clears it on consume. - renamingTabId: string | null - groupsByWorktree: Record - activeGroupIdByWorktree: Record - layoutByWorktree: Record - createUnifiedTab: ( - worktreeId: string, - contentType: TabContentType, - init?: Partial< - Pick< - Tab, - | 'id' - | 'entityId' - | 'executionHostId' - | 'label' - | 'generatedLabel' - | 'quickCommandLabel' - | 'customLabel' - | 'color' - | 'isPreview' - | 'isPinned' - > & { - targetGroupId: string - activate: boolean - recordInteraction: boolean - } - > - ) => Tab - createUnifiedTabInSplit: ( - worktreeId: string, - contentType: TabContentType, - target: { - sourceGroupId: string - splitDirection: TabSplitDirection - }, - init?: Partial< - Pick< - Tab, - | 'id' - | 'entityId' - | 'executionHostId' - | 'label' - | 'generatedLabel' - | 'quickCommandLabel' - | 'customLabel' - | 'color' - | 'isPreview' - | 'isPinned' - > & { - activate: boolean - recordInteraction: boolean - } - > - ) => Tab | null - getTab: (tabId: string) => Tab | null - getActiveTab: (worktreeId: string) => Tab | null - findTabForEntityInGroup: ( - worktreeId: string, - groupId: string, - entityId: string, - contentType?: TabContentType - ) => Tab | null - activateTab: (tabId: string, opts?: { preservePreview?: boolean; worktreeId?: string }) => void - closeUnifiedTab: ( - tabId: string, - opts?: { - /** Keep the worktree selected even if this empties it — for closes the user did not ask for. */ - preserveWorktreeSelection?: boolean - recordInteraction?: boolean - terminalRetirementHandled?: boolean - } - ) => { closedTabId: string; wasLastTab: boolean; worktreeId: string } | null - reorderUnifiedTabs: ( - groupId: string, - tabIds: string[], - opts?: { recordInteraction?: boolean } - ) => void - setTabLabel: (tabId: string, label: string) => void - /** Set a tab's view mode (terminal vs native chat). Patches only that tab. */ - setTabViewMode: (tabId: string, mode: 'terminal' | 'chat') => void - /** Flip a tab between terminal and native-chat renderings; the live TerminalPane stays mounted. */ - toggleTabViewMode: (tabId: string) => void - setTabCustomLabel: ( - tabId: string, - label: string | null, - opts?: { recordInteraction?: boolean } - ) => void - setUnifiedTabColor: (tabId: string, color: string | null) => void - setRenamingTabId: (tabId: string | null) => void - pinTab: (tabId: string) => void - unpinTab: (tabId: string) => void - closeOtherTabs: (tabId: string) => string[] - closeTabsToRight: (tabId: string) => string[] - closeTabsToLeft: (tabId: string) => string[] - ensureWorktreeRootGroup: (worktreeId: string) => string - focusGroup: (worktreeId: string, groupId: string) => void - closeEmptyGroup: (worktreeId: string, groupId: string) => boolean - createEmptySplitGroup: ( - worktreeId: string, - sourceGroupId: string, - direction: TabSplitDirection, - opts?: { activate?: boolean } - ) => string | null - moveUnifiedTabToGroup: ( - tabId: string, - targetGroupId: string, - opts?: { index?: number; activate?: boolean; recordInteraction?: boolean } - ) => boolean - dropUnifiedTab: ( - tabId: string, - target: { - groupId: string - index?: number - splitDirection?: TabSplitDirection - } - ) => boolean - copyUnifiedTabToGroup: ( - tabId: string, - targetGroupId: string, - init?: Partial< - Pick< - Tab, - | 'id' - | 'entityId' - | 'label' - | 'generatedLabel' - | 'quickCommandLabel' - | 'customLabel' - | 'color' - | 'isPinned' - > - > - ) => Tab | null - mergeGroupIntoSibling: (worktreeId: string, groupId: string) => string | null - setTabGroupSplitRatio: (worktreeId: string, nodePath: string, ratio: number) => void - reconcileWorktreeTabModel: (worktreeId: string) => { - renderableTabCount: number - activeRenderableTabId: string | null - } - hydrateTabsSession: ( - session: WorkspaceSessionState, - options?: WorkspaceSessionHydrationOptions - ) => void -} - -// Why: keep the TerminalTab pin in sync with the unified pin so reconcile's existing.isPinned fallback stays authoritative locally. -function patchTerminalTabPinned( - tabsByWorktree: Record, - worktreeId: string, - tabId: string, - isPinned: boolean -): Partial> { - const tabs = tabsByWorktree[worktreeId] - if (!tabs?.some((tab) => tab.id === tabId)) { - return {} - } - return { - tabsByWorktree: { - ...tabsByWorktree, - [worktreeId]: tabs.map((tab) => (tab.id === tabId ? { ...tab, isPinned } : tab)) - } - } -} - -// Why: pin is host-authoritative for remote-server tabs, so mirror it (like setTabColor) or it's lost on reconnect/other clients. -// Dynamic import keeps this store slice off the runtime layer. -function mirrorTabPinnedToHost(state: AppState, tabId: string, isPinned: boolean): void { - const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - // Why: only terminal tab pins are persisted host-side today (browser/editor in #5729); skip the RPC for other types. - if ( - !found || - found.tab.contentType !== 'terminal' || - !getRuntimeEnvironmentIdForWorktree(state, found.worktreeId) - ) { - return - } - const worktreeId = found.worktreeId - void import('@/runtime/web-runtime-session').then(({ setWebRuntimeTabProps }) => - setWebRuntimeTabProps({ worktreeId, tabId, isPinned }) - ) -} - -// Why: viewMode is host-tracked like color/pin, so mirror local sets or they're lost on reconnect and to paired clients. -// Only the action path mirrors (never reconcile applying a host value), so the echoed snapshot can't re-trigger an outbound RPC. -function mirrorTabViewModeToHost( - state: AppState, - tabId: string, - viewMode: 'terminal' | 'chat' -): void { - const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - // Why: only terminal tab viewMode is persisted host-side; skip the RPC for other types instead of a no-op round trip. - if ( - !found || - found.tab.contentType !== 'terminal' || - !getRuntimeEnvironmentIdForWorktree(state, found.worktreeId) - ) { - return - } - const worktreeId = found.worktreeId - void import('@/runtime/web-runtime-session').then(({ setWebRuntimeTabProps }) => - setWebRuntimeTabProps({ worktreeId, tabId, viewMode }) - ) -} - -function buildSplitNode( - existingGroupId: string, - newGroupId: string, - direction: 'horizontal' | 'vertical', - position: 'first' | 'second' -): TabGroupLayoutNode { - const existingLeaf: TabGroupLayoutNode = { type: 'leaf', groupId: existingGroupId } - const newLeaf: TabGroupLayoutNode = { type: 'leaf', groupId: newGroupId } - return { - type: 'split', - direction, - first: position === 'first' ? newLeaf : existingLeaf, - second: position === 'second' ? newLeaf : existingLeaf, - ratio: 0.5 - } -} - -function replaceLeaf( - root: TabGroupLayoutNode, - targetGroupId: string, - replacement: TabGroupLayoutNode -): TabGroupLayoutNode { - if (root.type === 'leaf') { - return root.groupId === targetGroupId ? replacement : root - } - return { - ...root, - first: replaceLeaf(root.first, targetGroupId, replacement), - second: replaceLeaf(root.second, targetGroupId, replacement) - } -} - -function updateSplitRatio( - root: TabGroupLayoutNode, - path: string[], - ratio: number -): TabGroupLayoutNode { - if (path.length === 0) { - return root.type === 'split' ? { ...root, ratio } : root - } - if (root.type !== 'split') { - return root - } - const [segment, ...rest] = path - if (segment === 'first') { - return { ...root, first: updateSplitRatio(root.first, rest, ratio) } - } - if (segment === 'second') { - return { ...root, second: updateSplitRatio(root.second, rest, ratio) } - } - return root -} - -function findFirstLeaf(root: TabGroupLayoutNode): string { - return root.type === 'leaf' ? root.groupId : findFirstLeaf(root.first) -} - -function partitionPinnedTabOrder(tabOrder: string[], tabs: Tab[], movingTabId: string): string[] { - const tabById = new Map(tabs.map((tab) => [tab.id, tab])) - const withoutMoving = dedupeTabOrder(tabOrder).filter((id) => id !== movingTabId) - const pinnedIds = withoutMoving.filter((id) => tabById.get(id)?.isPinned) - const unpinnedIds = withoutMoving.filter((id) => !tabById.get(id)?.isPinned) - return [...pinnedIds, movingTabId, ...unpinnedIds] -} - -function applyTabOrderSortValues(tabs: Tab[], tabOrder: string[]): Tab[] { - const orderMap = new Map(tabOrder.map((id, index) => [id, index])) - return tabs.map((tab) => { - const sortOrder = orderMap.get(tab.id) - return sortOrder === undefined ? tab : { ...tab, sortOrder } - }) -} - -function isReplaceablePreviewContentType(contentType: Tab['contentType']): boolean { - return ( - contentType === 'editor' || - contentType === 'diff' || - contentType === 'conflict-review' || - contentType === 'check-details' - ) -} - -function canReplacePreviewContentType( - incomingContentType: Tab['contentType'], - existingContentType: Tab['contentType'] -): boolean { - if (isReplaceablePreviewContentType(incomingContentType)) { - return isReplaceablePreviewContentType(existingContentType) - } - return existingContentType === incomingContentType -} - -export function findSiblingGroupId(root: TabGroupLayoutNode, targetGroupId: string): string | null { - if (root.type === 'leaf') { - return null - } - if (root.first.type === 'leaf' && root.first.groupId === targetGroupId) { - return root.second.type === 'leaf' ? root.second.groupId : findFirstLeaf(root.second) - } - if (root.second.type === 'leaf' && root.second.groupId === targetGroupId) { - return root.first.type === 'leaf' ? root.first.groupId : findFirstLeaf(root.first) - } - return ( - findSiblingGroupId(root.first, targetGroupId) ?? findSiblingGroupId(root.second, targetGroupId) - ) -} - -function removeLeaf(root: TabGroupLayoutNode, targetGroupId: string): TabGroupLayoutNode | null { - if (root.type === 'leaf') { - return root.groupId === targetGroupId ? null : root - } - if (root.first.type === 'leaf' && root.first.groupId === targetGroupId) { - return root.second - } - if (root.second.type === 'leaf' && root.second.groupId === targetGroupId) { - return root.first - } - const first = removeLeaf(root.first, targetGroupId) - const second = removeLeaf(root.second, targetGroupId) - if (first === null) { - return second - } - if (second === null) { - return first - } - return { ...root, first, second } -} - -function collapseGroupLayout( - layoutByWorktree: Record, - activeGroupIdByWorktree: Record, - worktreeId: string, - groupId: string, - fallbackGroupId?: string | null -): { - layoutByWorktree: Record - activeGroupIdByWorktree: Record -} { - const currentLayout = layoutByWorktree[worktreeId] - if (!currentLayout) { - return { layoutByWorktree, activeGroupIdByWorktree } - } - const siblingId = findSiblingGroupId(currentLayout, groupId) - const collapsed = removeLeaf(currentLayout, groupId) - const nextLayoutByWorktree = { ...layoutByWorktree } - if (collapsed) { - nextLayoutByWorktree[worktreeId] = collapsed - } else { - delete nextLayoutByWorktree[worktreeId] - } - return { - layoutByWorktree: nextLayoutByWorktree, - activeGroupIdByWorktree: { - ...activeGroupIdByWorktree, - [worktreeId]: siblingId ?? fallbackGroupId ?? activeGroupIdByWorktree[worktreeId] - } - } -} - -function deriveActiveSurfaceForWorktree( - state: Pick< - AppState, - | 'activeBrowserTabIdByWorktree' - | 'activeFileIdByWorktree' - | 'activeGroupIdByWorktree' - | 'activeTabIdByWorktree' - | 'browserTabsByWorktree' - | 'groupsByWorktree' - | 'layoutByWorktree' - | 'openFiles' - | 'tabsByWorktree' - | 'unifiedTabsByWorktree' - >, - worktreeId: string, - preferredGroupId?: string | null -): { - activeBrowserTabId: string | null - activeFileId: string | null - activeTabId: string | null - activeTabType: WorkspaceVisibleTabType -} { - const groups = state.groupsByWorktree[worktreeId] ?? [] - const activeGroupId = preferredGroupId ?? state.activeGroupIdByWorktree[worktreeId] ?? null - const activeGroup = - (activeGroupId ? groups.find((group) => group.id === activeGroupId) : null) ?? groups[0] ?? null - const activeUnifiedTab = - activeGroup?.activeTabId != null - ? ((state.unifiedTabsByWorktree[worktreeId] ?? []).find( - (tab) => tab.id === activeGroup.activeTabId && tab.groupId === activeGroup.id - ) ?? null) - : null - const restoredFileId = state.activeFileIdByWorktree[worktreeId] ?? null - const restoredBrowserTabId = state.activeBrowserTabIdByWorktree[worktreeId] ?? null - const restoredTerminalTabId = state.activeTabIdByWorktree[worktreeId] ?? null - const browserTabs = state.browserTabsByWorktree[worktreeId] ?? [] - const terminalTabs = state.tabsByWorktree[worktreeId] ?? [] - const fileStillOpen = restoredFileId - ? state.openFiles.some((file) => file.id === restoredFileId && file.worktreeId === worktreeId) - : false - const browserTabStillOpen = restoredBrowserTabId - ? browserTabs.some((tab) => tab.id === restoredBrowserTabId) - : false - const terminalTabStillExists = restoredTerminalTabId - ? terminalTabs.some((tab) => tab.id === restoredTerminalTabId) - : false - const hasGroupOwnedSurface = groups.length > 0 || Boolean(state.layoutByWorktree[worktreeId]) - - let activeFileId: string | null - let activeBrowserTabId: string | null - let activeTabType: WorkspaceVisibleTabType - - if (activeUnifiedTab) { - activeFileId = - activeUnifiedTab.contentType === 'editor' || - activeUnifiedTab.contentType === 'diff' || - activeUnifiedTab.contentType === 'conflict-review' || - activeUnifiedTab.contentType === 'check-details' - ? activeUnifiedTab.entityId - : fileStillOpen - ? restoredFileId - : null - activeBrowserTabId = - activeUnifiedTab.contentType === 'browser' - ? activeUnifiedTab.entityId - : browserTabStillOpen - ? restoredBrowserTabId - : (browserTabs[0]?.id ?? null) - activeTabType = toVisibleTabType(activeUnifiedTab.contentType) - } else if (hasGroupOwnedSurface) { - activeFileId = fileStillOpen ? restoredFileId : null - activeBrowserTabId = browserTabStillOpen ? restoredBrowserTabId : (browserTabs[0]?.id ?? null) - // Why: focusing an empty split should target its default terminal area, not the previously active browser/editor in another group. - activeTabType = 'terminal' - } else if (browserTabStillOpen) { - activeFileId = fileStillOpen ? restoredFileId : null - activeBrowserTabId = restoredBrowserTabId - activeTabType = 'browser' - } else if (fileStillOpen) { - activeFileId = restoredFileId - activeBrowserTabId = browserTabs[0]?.id ?? null - activeTabType = 'editor' - } else { - const fallbackFile = state.openFiles.find((file) => file.worktreeId === worktreeId) ?? null - const fallbackBrowserTab = browserTabs[0] ?? null - activeFileId = fallbackFile?.id ?? null - activeBrowserTabId = fallbackBrowserTab?.id ?? null - activeTabType = fallbackFile ? 'editor' : fallbackBrowserTab ? 'browser' : 'terminal' - } - - return { - activeBrowserTabId, - activeFileId, - activeTabId: - activeUnifiedTab?.contentType === 'terminal' - ? activeUnifiedTab.entityId - : terminalTabStillExists - ? restoredTerminalTabId - : (terminalTabs[0]?.id ?? null), - activeTabType - } -} - -function buildActiveSurfacePatch( - state: Pick< - AppState, - | 'activeBrowserTabIdByWorktree' - | 'activeFileIdByWorktree' - | 'activeGroupIdByWorktree' - | 'activeTabIdByWorktree' - | 'activeTabTypeByWorktree' - | 'browserTabsByWorktree' - | 'groupsByWorktree' - | 'layoutByWorktree' - | 'openFiles' - | 'tabsByWorktree' - | 'unifiedTabsByWorktree' - >, - worktreeId: string, - preferredGroupId?: string | null -): Pick< - AppState, - | 'activeBrowserTabId' - | 'activeBrowserTabIdByWorktree' - | 'activeFileId' - | 'activeFileIdByWorktree' - | 'activeTabId' - | 'activeTabIdByWorktree' - | 'activeTabType' - | 'activeTabTypeByWorktree' -> { - const derived = deriveActiveSurfaceForWorktree(state, worktreeId, preferredGroupId) - return { - activeBrowserTabId: derived.activeBrowserTabId, - activeBrowserTabIdByWorktree: { - ...state.activeBrowserTabIdByWorktree, - [worktreeId]: derived.activeBrowserTabId - }, - activeFileId: derived.activeFileId, - activeFileIdByWorktree: { - ...state.activeFileIdByWorktree, - [worktreeId]: derived.activeFileId - }, - activeTabId: derived.activeTabId, - activeTabIdByWorktree: { - ...state.activeTabIdByWorktree, - [worktreeId]: derived.activeTabId - }, - activeTabType: derived.activeTabType, - activeTabTypeByWorktree: { - ...state.activeTabTypeByWorktree, - [worktreeId]: derived.activeTabType - } - } -} - -function activeSurfacePatchMatchesState( - state: Pick< - AppState, - | 'activeBrowserTabId' - | 'activeBrowserTabIdByWorktree' - | 'activeFileId' - | 'activeFileIdByWorktree' - | 'activeTabId' - | 'activeTabIdByWorktree' - | 'activeTabType' - | 'activeTabTypeByWorktree' - >, - worktreeId: string, - patch: Pick< - AppState, - | 'activeBrowserTabId' - | 'activeBrowserTabIdByWorktree' - | 'activeFileId' - | 'activeFileIdByWorktree' - | 'activeTabId' - | 'activeTabIdByWorktree' - | 'activeTabType' - | 'activeTabTypeByWorktree' - > -): boolean { - return ( - state.activeBrowserTabId === patch.activeBrowserTabId && - state.activeBrowserTabIdByWorktree[worktreeId] === - patch.activeBrowserTabIdByWorktree[worktreeId] && - state.activeFileId === patch.activeFileId && - state.activeFileIdByWorktree[worktreeId] === patch.activeFileIdByWorktree[worktreeId] && - state.activeTabId === patch.activeTabId && - state.activeTabIdByWorktree[worktreeId] === patch.activeTabIdByWorktree[worktreeId] && - state.activeTabType === patch.activeTabType && - state.activeTabTypeByWorktree[worktreeId] === patch.activeTabTypeByWorktree[worktreeId] - ) -} - -export type WorktreeTabModelReconciliation = { - patch: Partial - renderableTabCount: number - activeRenderableTabId: string | null -} - -export function projectWorktreeTabModelReconciliation( - state: AppState, - worktreeId: string -): WorktreeTabModelReconciliation { - const unifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] - const groups = state.groupsByWorktree[worktreeId] ?? [] - const runtimeTerminalTabs = state.tabsByWorktree[worktreeId] ?? [] - const unifiedTerminalEntityIds = new Set( - unifiedTabs.filter((tab) => tab.contentType === 'terminal').map((tab) => tab.entityId) - ) - const legacyRuntimeTerminalTabs = runtimeTerminalTabs.filter((tab) => { - if (unifiedTerminalEntityIds.has(tab.id)) { - return false - } - // Why: reconnectable legacy tabs must re-enter the unified model instead of being orphaned. - return terminalTabHasReconnectablePty(state, tab.id, tab.ptyId) - }) - const orphanTerminalIds = getOrphanTerminalIds(state, worktreeId) - const ensuredGroupState = - legacyRuntimeTerminalTabs.length > 0 - ? ensureGroup( - state.groupsByWorktree, - state.activeGroupIdByWorktree, - worktreeId, - state.activeGroupIdByWorktree[worktreeId] - ) - : null - const reconciliationGroup = ensuredGroupState?.group ?? groups[0] ?? null - const restoredLegacyTabs = - reconciliationGroup == null - ? [] - : legacyRuntimeTerminalTabs - .sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt) - .map((tab) => ({ - id: tab.id, - entityId: tab.id, - groupId: reconciliationGroup.id, - worktreeId, - contentType: 'terminal' as const, - label: tab.title, - ...(tab.quickCommandLabel?.trim() - ? { quickCommandLabel: tab.quickCommandLabel.trim() } - : {}), - ...(tab.generatedTitle?.trim() ? { generatedLabel: tab.generatedTitle.trim() } : {}), - ...(tab.aiVaultTitle ? { aiVaultTitle: tab.aiVaultTitle } : {}), - customLabel: tab.customTitle, - color: tab.color, - sortOrder: tab.sortOrder, - createdAt: tab.createdAt - })) - const reconciledUnifiedTabs = - restoredLegacyTabs.length > 0 ? [...unifiedTabs, ...restoredLegacyTabs] : unifiedTabs - const rememberedLegacyActiveTabId = state.activeTabIdByWorktree[worktreeId] - const restoredLegacyTabIds = new Set(restoredLegacyTabs.map((tab) => tab.id)) - const legacyFallbackActiveTabId = - rememberedLegacyActiveTabId && restoredLegacyTabIds.has(rememberedLegacyActiveTabId) - ? rememberedLegacyActiveTabId - : (restoredLegacyTabs[0]?.id ?? null) - const reconciledGroups = - restoredLegacyTabs.length > 0 && reconciliationGroup - ? updateGroup(ensuredGroupState!.groupsByWorktree[worktreeId] ?? [], { - ...reconciliationGroup, - // Why: restore runtime tabs into the active/root group so reattach cannot spawn a duplicate. - activeTabId: reconciliationGroup.activeTabId ?? legacyFallbackActiveTabId, - tabOrder: dedupeTabOrder([ - ...reconciliationGroup.tabOrder, - ...restoredLegacyTabs.map((tab) => tab.id) - ]) - }) - : groups - const liveTerminalIds = new Set( - runtimeTerminalTabs.filter((tab) => !orphanTerminalIds.has(tab.id)).map((tab) => tab.id) - ) - const liveEditorIds = new Set( - state.openFiles.filter((file) => file.worktreeId === worktreeId).map((file) => file.id) - ) - const liveBrowserIds = new Set( - (state.browserTabsByWorktree[worktreeId] ?? []).map((browserTab) => browserTab.id) - ) - - const isRenderableTab = (tab: Tab): boolean => { - if (tab.contentType === 'terminal') { - return liveTerminalIds.has(tab.entityId) - } - if (tab.contentType === 'browser') { - return liveBrowserIds.has(tab.entityId) - } - if (tab.contentType === 'simulator' || tab.contentType === 'agent-session') { - return true - } - return liveEditorIds.has(tab.entityId) - } - - const validTabs = reconciledUnifiedTabs.filter(isRenderableTab) - const validTabIds = new Set(validTabs.map((tab) => tab.id)) - const nextGroupsWithEmpty = reconciledGroups.map((group) => { - const tabOrder = group.tabOrder.filter((tabId) => validTabIds.has(tabId)) - const activeTabId = - group.activeTabId && validTabIds.has(group.activeTabId) - ? group.activeTabId - : (tabOrder[0] ?? null) - const tabOrderUnchanged = - tabOrder.length === group.tabOrder.length && - tabOrder.every((tabId, index) => tabId === group.tabOrder[index]) - const recentTabIds = sanitizeRecentTabIds(group.recentTabIds, tabOrder) - const recentUnchanged = - recentTabIds.length === (group.recentTabIds ?? []).length && - recentTabIds.every((id, index) => id === (group.recentTabIds ?? [])[index]) - return tabOrderUnchanged && activeTabId === group.activeTabId && recentUnchanged - ? group - : { ...group, tabOrder, activeTabId, recentTabIds } - }) - const nextGroups = - validTabs.length > 0 - ? nextGroupsWithEmpty.filter((group) => group.tabOrder.length > 0) - : nextGroupsWithEmpty - const currentActiveGroupId = - state.activeGroupIdByWorktree[worktreeId] ?? - ensuredGroupState?.activeGroupIdByWorktree[worktreeId] - const activeGroupStillExists = nextGroups.some((group) => group.id === currentActiveGroupId) - const nextActiveGroupId = activeGroupStillExists - ? currentActiveGroupId - : (nextGroups.find((group) => group.activeTabId !== null)?.id ?? - nextGroups[0]?.id ?? - currentActiveGroupId) - const groupsChanged = - nextGroups.length !== groups.length || - nextGroups.some((group, index) => group !== groups[index]) - const tabsChanged = validTabs.length !== unifiedTabs.length || restoredLegacyTabs.length > 0 - const activeGroupChanged = nextActiveGroupId !== currentActiveGroupId - const baseNextLayout = - restoredLegacyTabs.length > 0 && reconciliationGroup - ? (state.layoutByWorktree[worktreeId] ?? { type: 'leaf', groupId: reconciliationGroup.id }) - : state.layoutByWorktree[worktreeId] - const validGroupIds = new Set(nextGroups.map((group) => group.id)) - const prunedNextLayout = - baseNextLayout && validGroupIds.size > 0 - ? pruneTabGroupLayoutForGroups(baseNextLayout, validGroupIds) - : baseNextLayout - const nextLayout = - prunedNextLayout ?? (nextGroups[0] ? { type: 'leaf', groupId: nextGroups[0].id } : undefined) - const currentLayout = state.layoutByWorktree[worktreeId] - const layoutChanged = nextLayout !== currentLayout - let patch: Partial = {} - - if ( - tabsChanged || - groupsChanged || - activeGroupChanged || - layoutChanged || - orphanTerminalIds.size > 0 - ) { - const droppedTerminalEntityIds = unifiedTabs.flatMap((tab) => - tab.contentType === 'terminal' && !validTabIds.has(tab.id) ? [tab.entityId] : [] - ) - let nextUnreadTerminalTabs = state.unreadTerminalTabs - if (droppedTerminalEntityIds.length > 0) { - const copy = { ...state.unreadTerminalTabs } - let changed = false - for (const entityId of droppedTerminalEntityIds) { - if (copy[entityId]) { - delete copy[entityId] - changed = true - } - } - if (changed) { - nextUnreadTerminalTabs = copy - } - } - patch = { - unifiedTabsByWorktree: { ...state.unifiedTabsByWorktree, [worktreeId]: validTabs }, - groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: nextGroups }, - activeGroupIdByWorktree: { - ...state.activeGroupIdByWorktree, - [worktreeId]: nextActiveGroupId - }, - ...(nextUnreadTerminalTabs !== state.unreadTerminalTabs - ? { unreadTerminalTabs: nextUnreadTerminalTabs } - : {}), - ...(nextLayout && layoutChanged - ? { - layoutByWorktree: { - ...state.layoutByWorktree, - // Why: restored runtime terminals need a concrete leaf before activation. - [worktreeId]: nextLayout - } - } - : {}), - ...(orphanTerminalIds.size > 0 - ? buildOrphanTerminalCleanupPatch(state, worktreeId, orphanTerminalIds) - : {}) - } - } - - return { - patch, - renderableTabCount: validTabs.length, - activeRenderableTabId: - nextGroups.find((group) => group.id === nextActiveGroupId)?.activeTabId ?? - nextGroups.find((group) => group.activeTabId !== null)?.activeTabId ?? - null - } -} - -export const createTabsSlice: StateCreator = (set, get) => ({ - unifiedTabsByWorktree: {}, - renamingTabId: null, - groupsByWorktree: {}, - activeGroupIdByWorktree: {}, - layoutByWorktree: {}, - - createUnifiedTab: (worktreeId, contentType, init) => { - const id = init?.id ?? createBrowserUuid() - let created!: Tab - set((state) => { - const { group, groupsByWorktree, activeGroupIdByWorktree } = ensureGroup( - state.groupsByWorktree, - state.activeGroupIdByWorktree, - worktreeId, - init?.targetGroupId ?? state.activeGroupIdByWorktree[worktreeId] - ) - const existingTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] - - let nextTabs = existingTabs - let nextOrder = dedupeTabOrder(group.tabOrder) - if (init?.isPreview) { - const existingPreview = existingTabs.find( - (tab) => - tab.groupId === group.id && - tab.isPreview && - canReplacePreviewContentType(contentType, tab.contentType) - ) - if (existingPreview) { - nextTabs = existingTabs.filter((tab) => tab.id !== existingPreview.id) - nextOrder = nextOrder.filter((tabId) => tabId !== existingPreview.id) - } - } - - const shouldActivate = init?.activate ?? true - const createdAt = Date.now() - const executionHostId = - init?.executionHostId ?? getActiveExecutionHostIdForWorktree(state, worktreeId) - created = { - id, - entityId: init?.entityId ?? id, - groupId: group.id, - worktreeId, - ...(executionHostId ? { executionHostId } : {}), - contentType, - label: - init?.label ?? (contentType === 'terminal' ? `Terminal ${existingTabs.length + 1}` : id), - ...(init?.generatedLabel !== undefined ? { generatedLabel: init.generatedLabel } : {}), - ...(init?.quickCommandLabel !== undefined - ? { quickCommandLabel: init.quickCommandLabel } - : {}), - customLabel: init?.customLabel ?? null, - color: init?.color ?? null, - sortOrder: nextOrder.length, - createdAt, - // Why: creating an active tab is a focus event; Cmd+J recency reads lastFocusedAt. - ...(shouldActivate ? { lastFocusedAt: createdAt } : {}), - isPreview: init?.isPreview, - isPinned: init?.isPinned - } - - nextOrder = dedupeTabOrder([...nextOrder, created.id]) - const nextActiveTabId = shouldActivate ? created.id : (group.activeTabId ?? created.id) - const sanitizedRecent = sanitizeRecentTabIds(group.recentTabIds, nextOrder) - // Why: automation-created browser tabs must paint without stealing the visible group selection from the user's current tab. - const nextRecent = shouldActivate - ? pushRecentTabId(sanitizedRecent, created.id) - : sanitizedRecent - return { - unifiedTabsByWorktree: { - ...state.unifiedTabsByWorktree, - [worktreeId]: [...nextTabs, created] - }, - groupsByWorktree: { - ...groupsByWorktree, - [worktreeId]: updateGroup(groupsByWorktree[worktreeId] ?? [], { - ...group, - activeTabId: nextActiveTabId, - tabOrder: nextOrder, - recentTabIds: nextRecent - }) - }, - activeGroupIdByWorktree, - layoutByWorktree: { - ...state.layoutByWorktree, - [worktreeId]: state.layoutByWorktree[worktreeId] ?? { type: 'leaf', groupId: group.id } - } - } - }) - if (init?.recordInteraction !== false) { - get().recordFeatureInteraction?.('terminal-tabs') - } - return created - }, - - createUnifiedTabInSplit: (worktreeId, contentType, target, init) => { - const id = init?.id ?? createBrowserUuid() - const newGroupId = createBrowserUuid() - let created: Tab | null = null - let moved = false - set((state) => { - const sourceGroup = findGroupForTab(state.groupsByWorktree, worktreeId, target.sourceGroupId) - if (!sourceGroup) { - return {} - } - const existingTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] - const currentGroups = state.groupsByWorktree[worktreeId] ?? [] - const shouldActivate = init?.activate ?? true - const currentLayout = - state.layoutByWorktree[worktreeId] ?? - ({ type: 'leaf', groupId: target.sourceGroupId } as const) - const createdAt = Date.now() - const executionHostId = - init?.executionHostId ?? getActiveExecutionHostIdForWorktree(state, worktreeId) - const createdTab: Tab = { - id, - entityId: init?.entityId ?? id, - groupId: newGroupId, - worktreeId, - ...(executionHostId ? { executionHostId } : {}), - contentType, - label: - init?.label ?? (contentType === 'terminal' ? `Terminal ${existingTabs.length + 1}` : id), - ...(init?.generatedLabel !== undefined ? { generatedLabel: init.generatedLabel } : {}), - ...(init?.quickCommandLabel !== undefined - ? { quickCommandLabel: init.quickCommandLabel } - : {}), - customLabel: init?.customLabel ?? null, - color: init?.color ?? null, - sortOrder: 0, - createdAt, - // Why: creating an active tab is a focus event; Cmd+J recency reads lastFocusedAt. - ...(shouldActivate ? { lastFocusedAt: createdAt } : {}), - isPreview: init?.isPreview, - isPinned: init?.isPinned - } - const newGroup: TabGroup = { - id: newGroupId, - worktreeId, - activeTabId: id, - tabOrder: [id], - recentTabIds: shouldActivate ? [id] : [] - } - created = createdTab - const replacement = buildSplitNode( - target.sourceGroupId, - newGroupId, - target.splitDirection === 'left' || target.splitDirection === 'right' - ? 'horizontal' - : 'vertical', - target.splitDirection === 'left' || target.splitDirection === 'up' ? 'first' : 'second' - ) - const nextUnifiedTabsByWorktree = { - ...state.unifiedTabsByWorktree, - [worktreeId]: [...existingTabs, createdTab] - } - const nextGroupsByWorktree = { - ...state.groupsByWorktree, - [worktreeId]: [...currentGroups, newGroup] - } - const nextLayoutByWorktree = { - ...state.layoutByWorktree, - [worktreeId]: replaceLeaf(currentLayout, target.sourceGroupId, replacement) - } - const nextActiveGroupIdByWorktree = shouldActivate - ? { - ...state.activeGroupIdByWorktree, - [worktreeId]: newGroupId - } - : state.activeGroupIdByWorktree - moved = true - return { - unifiedTabsByWorktree: nextUnifiedTabsByWorktree, - groupsByWorktree: nextGroupsByWorktree, - layoutByWorktree: nextLayoutByWorktree, - activeGroupIdByWorktree: nextActiveGroupIdByWorktree, - ...(shouldActivate && state.activeWorktreeId === worktreeId - ? buildActiveSurfacePatch( - { - ...state, - unifiedTabsByWorktree: nextUnifiedTabsByWorktree, - groupsByWorktree: nextGroupsByWorktree, - layoutByWorktree: nextLayoutByWorktree, - activeGroupIdByWorktree: nextActiveGroupIdByWorktree - }, - worktreeId, - newGroupId - ) - : {}) - } - }) - if (created && init?.recordInteraction !== false) { - get().recordFeatureInteraction?.('terminal-tabs') - } - if (moved && init?.recordInteraction !== false) { - get().recordFeatureInteraction?.('tab-splits') - } - return created - }, - - getTab: (tabId) => findTabAndWorktree(get().unifiedTabsByWorktree, tabId)?.tab ?? null, - - getActiveTab: (worktreeId) => { - const state = get() - const groupId = state.activeGroupIdByWorktree[worktreeId] - const group = (state.groupsByWorktree[worktreeId] ?? []).find( - (candidate) => candidate.id === groupId - ) - if (!group?.activeTabId) { - return null - } - return ( - (state.unifiedTabsByWorktree[worktreeId] ?? []).find((tab) => tab.id === group.activeTabId) ?? - null - ) - }, - - findTabForEntityInGroup: (worktreeId, groupId, entityId, contentType) => - findTabByEntityInGroup(get().unifiedTabsByWorktree, worktreeId, groupId, entityId, contentType), - - activateTab: (tabId, opts) => { - set((state) => { - const scopedWorktreeId = opts?.worktreeId - let found: ReturnType - if (scopedWorktreeId !== undefined) { - const scopedTabs = Object.hasOwn(state.unifiedTabsByWorktree, scopedWorktreeId) - ? state.unifiedTabsByWorktree[scopedWorktreeId] - : [] - const scopedTab = scopedTabs.find((tab) => tab.id === tabId) - found = scopedTab ? { tab: scopedTab, worktreeId: scopedWorktreeId } : null - } else { - found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - } - if (!found) { - return {} - } - const { tab, worktreeId } = found - // Why: activating a terminal tab dismisses its tab-level bell — the user has moved their eyes here. - // Why (activeWorktree guard below): only when the tab is in the active worktree, else the unseen signal is lost (mirrors focusGroup). - const terminalEntityId = tab.contentType === 'terminal' ? tab.entityId : null - const nextUnreadTerminalTabs = - state.activeWorktreeId === worktreeId && - terminalEntityId && - state.unreadTerminalTabs[terminalEntityId] - ? (() => { - const copy = { ...state.unreadTerminalTabs } - delete copy[terminalEntityId] - return copy - })() - : state.unreadTerminalTabs - return { - unifiedTabsByWorktree: { - ...state.unifiedTabsByWorktree, - [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((item) => - item.id === tabId - ? { - ...item, - isPreview: opts?.preservePreview ? item.isPreview : false, - lastFocusedAt: Date.now() - } - : item - ) - }, - groupsByWorktree: { - ...state.groupsByWorktree, - [worktreeId]: (state.groupsByWorktree[worktreeId] ?? []).map((group) => - group.id === tab.groupId - ? { - ...group, - activeTabId: tabId, - // Why: track every activation in the group's MRU so closeUnifiedTab returns to the previous tab; sanitize to prune removed ids. - recentTabIds: pushRecentTabId( - sanitizeRecentTabIds(group.recentTabIds, group.tabOrder), - tabId - ) - } - : group - ) - }, - activeGroupIdByWorktree: { - ...state.activeGroupIdByWorktree, - [worktreeId]: tab.groupId - }, - // Why: skip writing unreadTerminalTabs when the reference is unchanged, avoiding a no-op alloc that re-runs full-state selectors. - ...(nextUnreadTerminalTabs !== state.unreadTerminalTabs - ? { unreadTerminalTabs: nextUnreadTerminalTabs } - : {}) - } - }) - }, - - closeUnifiedTab: (tabId, opts) => { - const state = get() - const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - if (!found) { - return null - } - const { tab, worktreeId } = found - const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) - if (!group) { - return null - } - - if (tab.contentType === 'terminal' && !opts?.terminalRetirementHandled) { - const dedupedGroupOrder = dedupeTabOrder(group.tabOrder) - const wasLastTab = dedupeTabOrder(dedupedGroupOrder.filter((id) => id !== tabId)).length === 0 - // Why: unified-only hydrated tabs still own provider sessions without a legacy row, so retire every terminal close by entity id. - get().closeTab(tab.entityId, { recordInteraction: opts?.recordInteraction }) - return { closedTabId: tabId, wasLastTab, worktreeId } - } - - const dedupedGroupOrder = dedupeTabOrder(group.tabOrder) - const remainingOrder = dedupeTabOrder(dedupedGroupOrder.filter((id) => id !== tabId)) - const wasLastTab = remainingOrder.length === 0 - // Why: on closing the active tab, walk the MRU stack to the previously-active tab; pickNextActiveTab falls back to the neighbor. - const nextActiveTabId = - group.activeTabId === tabId - ? wasLastTab - ? null - : pickNextActiveTab(dedupedGroupOrder, group.recentTabIds, tabId) - : group.activeTabId - const nextRecentTabIds = sanitizeRecentTabIds( - (group.recentTabIds ?? []).filter((id) => id !== tabId), - remainingOrder - ) - const terminalEntityId = tab.contentType === 'terminal' ? tab.entityId : null - - set((current) => { - const nextTabs = (current.unifiedTabsByWorktree[worktreeId] ?? []).filter( - (item) => item.id !== tabId - ) - // Why: close-to-right/others bypass terminals.closeTab, so clear the entityId-keyed unread flag here or a stale dot leaks. - let nextUnreadTerminalTabs = current.unreadTerminalTabs - if (terminalEntityId && current.unreadTerminalTabs[terminalEntityId]) { - nextUnreadTerminalTabs = { ...current.unreadTerminalTabs } - delete nextUnreadTerminalTabs[terminalEntityId] - } - let nextGroups = (current.groupsByWorktree[worktreeId] ?? []).map((candidate) => - candidate.id === group.id - ? { - ...candidate, - activeTabId: nextActiveTabId, - tabOrder: remainingOrder, - recentTabIds: nextRecentTabIds - } - : candidate - ) - let nextLayoutByWorktree = current.layoutByWorktree - let nextActiveGroupIdByWorktree = current.activeGroupIdByWorktree - if (wasLastTab && current.layoutByWorktree[worktreeId] && nextGroups.length > 1) { - nextGroups = nextGroups.filter((candidate) => candidate.id !== group.id) - const collapsedState = collapseGroupLayout( - current.layoutByWorktree, - current.activeGroupIdByWorktree, - worktreeId, - group.id, - nextGroups[0]?.id ?? null - ) - nextLayoutByWorktree = collapsedState.layoutByWorktree - nextActiveGroupIdByWorktree = collapsedState.activeGroupIdByWorktree - } - // Why: the landing fallback answers "the user emptied this worktree". An unwound create - // never added a tab, so it must leave the selection exactly as the click found it. - const shouldDeactivateWorktree = - !opts?.preserveWorktreeSelection && - current.activeWorktreeId === worktreeId && - nextTabs.length === 0 && - (current.tabsByWorktree[worktreeId] ?? []).length === 0 && - (current.browserTabsByWorktree[worktreeId] ?? []).length === 0 && - !current.openFiles.some((file) => file.worktreeId === worktreeId) - return { - unifiedTabsByWorktree: { ...current.unifiedTabsByWorktree, [worktreeId]: nextTabs }, - groupsByWorktree: { - ...current.groupsByWorktree, - [worktreeId]: nextGroups - }, - layoutByWorktree: nextLayoutByWorktree, - activeGroupIdByWorktree: nextActiveGroupIdByWorktree, - // Why: skip writing unreadTerminalTabs when the reference is unchanged, avoiding a no-op alloc that re-runs full-state selectors. - ...(nextUnreadTerminalTabs !== current.unreadTerminalTabs - ? { unreadTerminalTabs: nextUnreadTerminalTabs } - : {}), - // Why: closing the last tab can leave the worktree selected but render-empty, so write the landing-state fallback directly. - ...(shouldDeactivateWorktree - ? { - activeWorktreeId: null, - activeWorkspaceKey: null, - activeWorkspaceExecutionHostId: null, - activeTabId: null, - activeBrowserTabId: null, - activeFileId: null, - activeTabType: 'terminal' as const, - activeTabIdByWorktree: { - ...current.activeTabIdByWorktree, - [worktreeId]: null - }, - activeBrowserTabIdByWorktree: { - ...current.activeBrowserTabIdByWorktree, - [worktreeId]: null - }, - activeFileIdByWorktree: { - ...current.activeFileIdByWorktree, - [worktreeId]: null - }, - activeTabTypeByWorktree: { - ...current.activeTabTypeByWorktree, - [worktreeId]: 'terminal' - } - } - : {}), - ...(!shouldDeactivateWorktree && current.activeWorktreeId === worktreeId - ? buildActiveSurfacePatch( - { - ...current, - unifiedTabsByWorktree: { - ...current.unifiedTabsByWorktree, - [worktreeId]: nextTabs - }, - groupsByWorktree: { - ...current.groupsByWorktree, - [worktreeId]: nextGroups - }, - layoutByWorktree: nextLayoutByWorktree, - activeGroupIdByWorktree: nextActiveGroupIdByWorktree - }, - worktreeId, - nextActiveGroupIdByWorktree[worktreeId] ?? null - ) - : {}) - } - }) - - if (opts?.recordInteraction !== false) { - get().recordFeatureInteraction?.('terminal-tabs') - } - return { closedTabId: tabId, wasLastTab, worktreeId } - }, - - reorderUnifiedTabs: (groupId, tabIds, opts) => { - let reordered = false - set((state) => { - for (const [worktreeId, groups] of Object.entries(state.groupsByWorktree)) { - const group = groups.find((candidate) => candidate.id === groupId) - if (!group) { - continue - } - // Why: dedupe at the store boundary so each tab keeps one canonical position and later group ops don't branch on duplicate ids. - const nextTabOrder = dedupeTabOrder(tabIds) - reordered = true - const orderMap = new Map(nextTabOrder.map((id, index) => [id, index])) - return { - groupsByWorktree: { - ...state.groupsByWorktree, - [worktreeId]: updateGroup(groups, { ...group, tabOrder: nextTabOrder }) - }, - unifiedTabsByWorktree: { - ...state.unifiedTabsByWorktree, - [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((tab) => { - const sortOrder = orderMap.get(tab.id) - return sortOrder === undefined ? tab : { ...tab, sortOrder } - }) - } - } - } - return {} - }) - if (reordered && opts?.recordInteraction !== false) { - get().recordFeatureInteraction?.('terminal-tabs') - } - }, - - setTabLabel: (tabId, label) => { - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { label }) ?? {}) - }, - - setTabViewMode: (tabId, mode) => { - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: mode }) ?? {}) - mirrorTabViewModeToHost(get(), tabId, mode) - }, - - toggleTabViewMode: (tabId) => { - let toggled: { - from: 'terminal' | 'chat' - to: 'terminal' | 'chat' - agent: TuiAgent | null - } | null = null - set((state) => { - const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - if (!found) { - return {} - } - // Why: viewMode defaults to 'terminal' for legacy/missing, so the first toggle flips to 'chat'. - const fromMode: 'terminal' | 'chat' = found.tab.viewMode === 'chat' ? 'chat' : 'terminal' - const nextMode = fromMode === 'chat' ? 'terminal' : 'chat' - // Why: launchAgent lives on the legacy terminal tab (keyed by entityId); resolve it here so toggle telemetry can attribute by agent. - const agent = - (state.tabsByWorktree[found.worktreeId] ?? []).find( - (terminal) => terminal.id === found.tab.entityId - )?.launchAgent ?? null - toggled = { from: fromMode, to: nextMode, agent } - return patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: nextMode }) ?? {} - }) - // Why: emit after the state write so the event reflects the committed mode. - const committed = toggled as { - from: 'terminal' | 'chat' - to: 'terminal' | 'chat' - agent: TuiAgent | null - } | null - if (committed) { - emitNativeChatToggled(committed) - mirrorTabViewModeToHost(get(), tabId, committed.to) - } - }, - - setRenamingTabId: (tabId) => { - set({ renamingTabId: tabId }) - }, - - setTabCustomLabel: (tabId, label, opts) => { - const exists = get().getTab(tabId) !== null - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { customLabel: label }) ?? {}) - if (exists && opts?.recordInteraction !== false) { - get().recordFeatureInteraction?.('terminal-tabs') - } - }, - - setUnifiedTabColor: (tabId, color) => { - const exists = get().getTab(tabId) !== null - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { color }) ?? {}) - if (exists) { - get().recordFeatureInteraction?.('terminal-tabs') - } - }, - - pinTab: (tabId) => { - const exists = get().getTab(tabId) !== null - set((state) => { - const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - if (!found) { - return {} - } - const { tab, worktreeId } = found - const tabs = (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => - candidate.id === tabId ? { ...candidate, isPinned: true, isPreview: false } : candidate - ) - const groups = state.groupsByWorktree[worktreeId] ?? [] - const group = groups.find((candidate) => candidate.id === tab.groupId) - if (!group) { - return { - unifiedTabsByWorktree: { ...state.unifiedTabsByWorktree, [worktreeId]: tabs } - } - } - const tabOrder = partitionPinnedTabOrder(group.tabOrder, tabs, tabId) - return { - unifiedTabsByWorktree: { - ...state.unifiedTabsByWorktree, - [worktreeId]: applyTabOrderSortValues(tabs, tabOrder) - }, - // Why: reconcile derives pin from the TerminalTab, so mirror it there too or a host snapshot recomputes isPinned:false and un-pins during the echo window. - ...patchTerminalTabPinned(state.tabsByWorktree, worktreeId, tabId, true), - groupsByWorktree: { - ...state.groupsByWorktree, - [worktreeId]: updateGroup(groups, { ...group, tabOrder }) - } - } - }) - mirrorTabPinnedToHost(get(), tabId, true) - if (exists) { - get().recordFeatureInteraction?.('terminal-tabs') - } - }, - - unpinTab: (tabId) => { - const exists = get().getTab(tabId) !== null - set((state) => { - const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - if (!found) { - return {} - } - const { tab, worktreeId } = found - const tabs = (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => - candidate.id === tabId ? { ...candidate, isPinned: false } : candidate - ) - const groups = state.groupsByWorktree[worktreeId] ?? [] - const group = groups.find((candidate) => candidate.id === tab.groupId) - if (!group) { - return { - unifiedTabsByWorktree: { ...state.unifiedTabsByWorktree, [worktreeId]: tabs } - } - } - const tabOrder = partitionPinnedTabOrder(group.tabOrder, tabs, tabId) - return { - unifiedTabsByWorktree: { - ...state.unifiedTabsByWorktree, - [worktreeId]: applyTabOrderSortValues(tabs, tabOrder) - }, - ...patchTerminalTabPinned(state.tabsByWorktree, worktreeId, tabId, false), - groupsByWorktree: { - ...state.groupsByWorktree, - [worktreeId]: updateGroup(groups, { ...group, tabOrder }) - } - } - }) - mirrorTabPinnedToHost(get(), tabId, false) - if (exists) { - get().recordFeatureInteraction?.('terminal-tabs') - } - }, - - closeOtherTabs: (tabId) => { - const state = get() - const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - if (!found) { - return [] - } - const { tab, worktreeId } = found - const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) - if (!group) { - return [] - } - const closedIds = (state.unifiedTabsByWorktree[worktreeId] ?? []) - .filter((item) => item.groupId === group.id && item.id !== tabId && !item.isPinned) - .map((item) => item.id) - for (const id of closedIds) { - get().closeUnifiedTab(id) - } - return closedIds - }, - - closeTabsToRight: (tabId) => { - const state = get() - const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - if (!found) { - return [] - } - const { tab, worktreeId } = found - const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) - if (!group) { - return [] - } - const index = group.tabOrder.indexOf(tabId) - if (index === -1) { - return [] - } - const closableIds = group.tabOrder - .slice(index + 1) - .filter( - (id) => - !(state.unifiedTabsByWorktree[worktreeId] ?? []).find((candidate) => candidate.id === id) - ?.isPinned - ) - for (const id of closableIds) { - get().closeUnifiedTab(id) - } - return closableIds - }, - - closeTabsToLeft: (tabId) => { - const state = get() - const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - if (!found) { - return [] - } - const { tab, worktreeId } = found - const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) - if (!group) { - return [] - } - const index = group.tabOrder.indexOf(tabId) - if (index === -1) { - return [] - } - const closableIds = group.tabOrder - .slice(0, index) - .filter( - (id) => - !(state.unifiedTabsByWorktree[worktreeId] ?? []).find((candidate) => candidate.id === id) - ?.isPinned - ) - for (const id of closableIds) { - get().closeUnifiedTab(id) - } - return closableIds - }, - - ensureWorktreeRootGroup: (worktreeId) => { - const existingGroups = get().groupsByWorktree[worktreeId] ?? [] - if (existingGroups.length > 0) { - return get().activeGroupIdByWorktree[worktreeId] ?? existingGroups[0].id - } - - const groupId = createBrowserUuid() - set((state) => ({ - // Why: a zero-tab worktree still needs a canonical root group so new tabs and splits land in a deterministic place. - groupsByWorktree: { - ...state.groupsByWorktree, - [worktreeId]: [{ id: groupId, worktreeId, activeTabId: null, tabOrder: [] }] - }, - layoutByWorktree: { - ...state.layoutByWorktree, - [worktreeId]: { type: 'leaf', groupId } - }, - activeGroupIdByWorktree: { - ...state.activeGroupIdByWorktree, - [worktreeId]: groupId - } - })) - return groupId - }, - - focusGroup: (worktreeId, groupId) => - set((state) => { - const groupAlreadyFocused = state.activeGroupIdByWorktree[worktreeId] === groupId - const nextActiveGroupIdByWorktree = groupAlreadyFocused - ? state.activeGroupIdByWorktree - : { - ...state.activeGroupIdByWorktree, - [worktreeId]: groupId - } - // Why: focusing a group surfaces its active terminal tab, so dismiss the tab-level bell. - // Why (activeWorktree guard below): only when the group is in the active worktree, else the unseen tab's bell is swallowed. - if (state.activeWorktreeId !== worktreeId) { - if (groupAlreadyFocused) { - return state - } - return { - activeGroupIdByWorktree: nextActiveGroupIdByWorktree - } - } - const groups = state.groupsByWorktree[worktreeId] ?? [] - const unifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] - const visibleTerminalEntityIds = new Set( - groups - .map((group) => - group.activeTabId ? unifiedTabs.find((tab) => tab.id === group.activeTabId) : null - ) - .filter((tab): tab is (typeof unifiedTabs)[number] => tab?.contentType === 'terminal') - .map((tab) => tab.entityId) - ) - const nextUnreadTerminalTabs = - visibleTerminalEntityIds.size > 0 - ? (() => { - let changed = false - const copy = { ...state.unreadTerminalTabs } - for (const terminalEntityId of visibleTerminalEntityIds) { - if (!copy[terminalEntityId]) { - continue - } - delete copy[terminalEntityId] - changed = true - } - return changed ? copy : state.unreadTerminalTabs - })() - : state.unreadTerminalTabs - const activeSurfacePatch = buildActiveSurfacePatch( - { - ...state, - activeGroupIdByWorktree: nextActiveGroupIdByWorktree - }, - worktreeId, - groupId - ) - if ( - groupAlreadyFocused && - nextUnreadTerminalTabs === state.unreadTerminalTabs && - activeSurfacePatchMatchesState(state, worktreeId, activeSurfacePatch) - ) { - return state - } - return { - ...(groupAlreadyFocused ? {} : { activeGroupIdByWorktree: nextActiveGroupIdByWorktree }), - // Why: only write unreadTerminalTabs when it changed — preserving the reference keeps selectors/subscribers from firing spuriously. - ...(nextUnreadTerminalTabs !== state.unreadTerminalTabs - ? { unreadTerminalTabs: nextUnreadTerminalTabs } - : {}), - ...activeSurfacePatch - } - }), - - closeEmptyGroup: (worktreeId, groupId) => { - const state = get() - const group = (state.groupsByWorktree[worktreeId] ?? []).find( - (candidate) => candidate.id === groupId - ) - if (!group || group.tabOrder.length > 0) { - return false - } - set((current) => { - const remainingGroups = (current.groupsByWorktree[worktreeId] ?? []).filter( - (candidate) => candidate.id !== groupId - ) - const collapsedState = collapseGroupLayout( - current.layoutByWorktree, - current.activeGroupIdByWorktree, - worktreeId, - groupId, - remainingGroups[0]?.id ?? null - ) - // Why: drop the dead group's recent-quick-command entry so the map can't grow unbounded as groups open/close. - const { [groupId]: _droppedRecent, ...remainingRecent } = current.recentQuickCommandIdByGroup - return { - groupsByWorktree: { ...current.groupsByWorktree, [worktreeId]: remainingGroups }, - layoutByWorktree: collapsedState.layoutByWorktree, - activeGroupIdByWorktree: collapsedState.activeGroupIdByWorktree, - recentQuickCommandIdByGroup: remainingRecent, - ...(current.activeWorktreeId === worktreeId - ? buildActiveSurfacePatch( - { - ...current, - groupsByWorktree: { - ...current.groupsByWorktree, - [worktreeId]: remainingGroups - }, - layoutByWorktree: collapsedState.layoutByWorktree, - activeGroupIdByWorktree: collapsedState.activeGroupIdByWorktree - }, - worktreeId, - collapsedState.activeGroupIdByWorktree[worktreeId] ?? null - ) - : {}) - } - }) - return true - }, - - createEmptySplitGroup: (worktreeId, sourceGroupId, direction, opts) => { - const newGroupId = createBrowserUuid() - const newGroup: TabGroup = { - id: newGroupId, - worktreeId, - activeTabId: null, - tabOrder: [] - } - const shouldActivate = opts?.activate !== false - set((state) => { - const existing = state.groupsByWorktree[worktreeId] ?? [] - const currentLayout = - state.layoutByWorktree[worktreeId] ?? ({ type: 'leaf', groupId: sourceGroupId } as const) - const replacement = buildSplitNode( - sourceGroupId, - newGroupId, - direction === 'left' || direction === 'right' ? 'horizontal' : 'vertical', - direction === 'left' || direction === 'up' ? 'first' : 'second' - ) - return { - groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: [...existing, newGroup] }, - layoutByWorktree: { - ...state.layoutByWorktree, - [worktreeId]: replaceLeaf(currentLayout, sourceGroupId, replacement) - }, - ...(shouldActivate - ? { - activeGroupIdByWorktree: { - ...state.activeGroupIdByWorktree, - [worktreeId]: newGroupId - } - } - : {}) - } - }) - get().recordFeatureInteraction?.('terminal-panes') - return newGroupId - }, - - moveUnifiedTabToGroup: (tabId, targetGroupId, opts) => { - let moved = false - set((state) => { - const foundTab = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - const foundTarget = findGroupAndWorktree(state.groupsByWorktree, targetGroupId) - if (!foundTab || !foundTarget || foundTab.worktreeId !== foundTarget.worktreeId) { - return {} - } - const { tab, worktreeId } = foundTab - if (tab.groupId === targetGroupId) { - return {} - } - const sourceGroup = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) - const targetGroup = foundTarget.group - if (!sourceGroup) { - return {} - } - moved = true - - const dedupedSourceGroupOrder = dedupeTabOrder(sourceGroup.tabOrder) - const sourceOrder = dedupeTabOrder(dedupedSourceGroupOrder.filter((id) => id !== tabId)) - // Why: defensive dedupe so target order can't grow a duplicate id (stale state); see dropUnifiedTab for the same guard. - const targetOrder = dedupeTabOrder(targetGroup.tabOrder.filter((id) => id !== tabId)) - const targetIndex = Math.max( - 0, - Math.min(opts?.index ?? targetOrder.length, targetOrder.length) - ) - targetOrder.splice(targetIndex, 0, tabId) - const nextActiveGroupIdByWorktree = { - ...state.activeGroupIdByWorktree, - [worktreeId]: opts?.activate ? targetGroupId : state.activeGroupIdByWorktree[worktreeId] - } - const sourceRecentTabIds = sanitizeRecentTabIds( - (sourceGroup.recentTabIds ?? []).filter((id) => id !== tabId), - sourceOrder - ) - const nextGroups = (state.groupsByWorktree[worktreeId] ?? []).map((group) => { - if (group.id === sourceGroup.id) { - return { - ...group, - activeTabId: - group.activeTabId === tabId - ? // Why: keep MRU-aware selection so the user lands on their previously-focused tab, not a visual neighbor. - pickNextActiveTab(dedupedSourceGroupOrder, sourceGroup.recentTabIds, tabId) - : group.activeTabId, - tabOrder: sourceOrder, - recentTabIds: sourceRecentTabIds - } - } - if (group.id === targetGroupId) { - const sanitizedTargetRecent = sanitizeRecentTabIds(group.recentTabIds, targetOrder) - return { - ...group, - activeTabId: opts?.activate ? tabId : group.activeTabId, - tabOrder: targetOrder, - recentTabIds: opts?.activate - ? pushRecentTabId(sanitizedTargetRecent, tabId) - : sanitizedTargetRecent - } - } - return group - }) - let nextLayoutByWorktree = state.layoutByWorktree - let nextActiveGroupIdByWorktreeResolved = nextActiveGroupIdByWorktree - let filteredGroups = nextGroups - if (sourceOrder.length === 0) { - filteredGroups = nextGroups.filter((group) => group.id !== sourceGroup.id) - const collapsedState = collapseGroupLayout( - nextLayoutByWorktree, - nextActiveGroupIdByWorktreeResolved, - worktreeId, - sourceGroup.id, - targetGroupId - ) - nextLayoutByWorktree = collapsedState.layoutByWorktree - nextActiveGroupIdByWorktreeResolved = collapsedState.activeGroupIdByWorktree - } - const nextGroupsByWorktree = { - ...state.groupsByWorktree, - [worktreeId]: filteredGroups - } - const nextUnifiedTabsByWorktree = { - ...state.unifiedTabsByWorktree, - [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => - candidate.id === tabId ? { ...candidate, groupId: targetGroupId } : candidate - ) - } - return { - unifiedTabsByWorktree: nextUnifiedTabsByWorktree, - groupsByWorktree: nextGroupsByWorktree, - layoutByWorktree: nextLayoutByWorktree, - activeGroupIdByWorktree: nextActiveGroupIdByWorktreeResolved, - ...(state.activeWorktreeId === worktreeId - ? buildActiveSurfacePatch( - { - ...state, - unifiedTabsByWorktree: nextUnifiedTabsByWorktree, - groupsByWorktree: nextGroupsByWorktree, - layoutByWorktree: nextLayoutByWorktree, - activeGroupIdByWorktree: nextActiveGroupIdByWorktreeResolved - }, - worktreeId, - nextActiveGroupIdByWorktreeResolved[worktreeId] ?? null - ) - : {}) - } - }) - if (moved && opts?.recordInteraction !== false) { - get().recordFeatureInteraction?.('tab-splits') - } - return moved - }, - - dropUnifiedTab: (tabId, target) => { - let moved = false - set((state) => { - const foundTab = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - const foundTarget = findGroupAndWorktree(state.groupsByWorktree, target.groupId) - if (!foundTab || !foundTarget || foundTab.worktreeId !== foundTarget.worktreeId) { - return {} - } - - const { tab, worktreeId } = foundTab - const sourceGroup = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) - const targetGroup = foundTarget.group - if (!sourceGroup) { - return {} - } - - const isSplitDrop = Boolean(target.splitDirection) - if (!isSplitDrop && tab.groupId === target.groupId) { - return {} - } - const layout = state.layoutByWorktree[worktreeId] - if ( - isSplitDrop && - isPaneColumnSplitDropNoOp({ - sourceGroupId: sourceGroup.id, - targetGroupId: target.groupId, - splitDirection: target.splitDirection!, - sourceTabCount: sourceGroup.tabOrder.length, - layout - }) - ) { - // Why: dropping a group's last tab on its own/sibling matching edge only makes a transient column that immediately collapses. - return {} - } - - moved = true - - let nextGroups = state.groupsByWorktree[worktreeId] ?? [] - let nextLayoutByWorktree = state.layoutByWorktree - let nextActiveGroupIdByWorktree = state.activeGroupIdByWorktree - let resolvedTargetGroupId = target.groupId - - if (target.splitDirection) { - const newGroupId = createBrowserUuid() - const newGroup: TabGroup = { - id: newGroupId, - worktreeId, - activeTabId: null, // Placeholder; properly set in the nextGroups.map() below - tabOrder: [] - } - const currentLayout = - nextLayoutByWorktree[worktreeId] ?? ({ type: 'leaf', groupId: target.groupId } as const) - const replacement = buildSplitNode( - target.groupId, - newGroupId, - target.splitDirection === 'left' || target.splitDirection === 'right' - ? 'horizontal' - : 'vertical', - target.splitDirection === 'left' || target.splitDirection === 'up' ? 'first' : 'second' - ) - - resolvedTargetGroupId = newGroupId - nextGroups = [...nextGroups, newGroup] - nextLayoutByWorktree = { - ...nextLayoutByWorktree, - [worktreeId]: replaceLeaf(currentLayout, target.groupId, replacement) - } - nextActiveGroupIdByWorktree = { - ...nextActiveGroupIdByWorktree, - [worktreeId]: newGroupId - } - } - - const dedupedSourceGroupOrder = dedupeTabOrder(sourceGroup.tabOrder) - const sourceOrder = dedupeTabOrder(dedupedSourceGroupOrder.filter((id) => id !== tabId)) - const destinationGroup = - nextGroups.find((group) => group.id === resolvedTargetGroupId) ?? targetGroup - // Why: target order may already hold this tab id (racey write / same-group split); dedupe first or React hits a duplicate key. - const targetOrder = dedupeTabOrder(destinationGroup.tabOrder.filter((id) => id !== tabId)) - const targetIndex = Math.max( - 0, - Math.min(target.index ?? targetOrder.length, targetOrder.length) - ) - targetOrder.splice(targetIndex, 0, tabId) - - const sourceRecentTabIds = sanitizeRecentTabIds( - (sourceGroup.recentTabIds ?? []).filter((id) => id !== tabId), - sourceOrder - ) - nextGroups = nextGroups.map((group) => { - if (group.id === sourceGroup.id) { - return { - ...group, - activeTabId: - group.activeTabId === tabId - ? // Why: same MRU-aware fallback as moveUnifiedTabToGroup — the drag keeps the user on their previously-active tab. - pickNextActiveTab(dedupedSourceGroupOrder, sourceGroup.recentTabIds, tabId) - : group.activeTabId, - tabOrder: sourceOrder, - recentTabIds: sourceRecentTabIds - } - } - if (group.id === resolvedTargetGroupId) { - return { - ...group, - activeTabId: tabId, - tabOrder: targetOrder, - recentTabIds: pushRecentTabId( - sanitizeRecentTabIds(group.recentTabIds, targetOrder), - tabId - ) - } - } - return group - }) - - if (sourceOrder.length === 0) { - nextGroups = nextGroups.filter((group) => group.id !== sourceGroup.id) - const collapsedState = collapseGroupLayout( - nextLayoutByWorktree, - nextActiveGroupIdByWorktree, - worktreeId, - sourceGroup.id, - resolvedTargetGroupId - ) - nextLayoutByWorktree = collapsedState.layoutByWorktree - nextActiveGroupIdByWorktree = collapsedState.activeGroupIdByWorktree - } else { - nextActiveGroupIdByWorktree = { - ...nextActiveGroupIdByWorktree, - [worktreeId]: resolvedTargetGroupId - } - } - - const nextUnifiedTabsByWorktree = { - ...state.unifiedTabsByWorktree, - [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => - candidate.id === tabId ? { ...candidate, groupId: resolvedTargetGroupId } : candidate - ) - } - const nextGroupsByWorktree = { - ...state.groupsByWorktree, - [worktreeId]: nextGroups - } - - return { - unifiedTabsByWorktree: nextUnifiedTabsByWorktree, - groupsByWorktree: nextGroupsByWorktree, - layoutByWorktree: nextLayoutByWorktree, - activeGroupIdByWorktree: nextActiveGroupIdByWorktree, - ...(state.activeWorktreeId === worktreeId - ? buildActiveSurfacePatch( - { - ...state, - unifiedTabsByWorktree: nextUnifiedTabsByWorktree, - groupsByWorktree: nextGroupsByWorktree, - layoutByWorktree: nextLayoutByWorktree, - activeGroupIdByWorktree: nextActiveGroupIdByWorktree - }, - worktreeId, - resolvedTargetGroupId - ) - : {}) - } - }) - if (moved) { - get().recordFeatureInteraction?.('terminal-tabs') - get().recordFeatureInteraction?.('tab-splits') - } - return moved - }, - - copyUnifiedTabToGroup: (tabId, targetGroupId, init) => { - const foundTab = findTabAndWorktree(get().unifiedTabsByWorktree, tabId) - const foundTarget = findGroupAndWorktree(get().groupsByWorktree, targetGroupId) - if (!foundTab || !foundTarget || foundTab.worktreeId !== foundTarget.worktreeId) { - return null - } - const { tab, worktreeId } = foundTab - return get().createUnifiedTab(worktreeId, tab.contentType, { - entityId: init?.entityId ?? tab.entityId, - executionHostId: tab.executionHostId, - label: init?.label ?? tab.label, - generatedLabel: init?.generatedLabel ?? tab.generatedLabel, - quickCommandLabel: init?.quickCommandLabel ?? tab.quickCommandLabel, - customLabel: init?.customLabel ?? tab.customLabel, - color: init?.color ?? tab.color, - isPinned: init?.isPinned ?? tab.isPinned, - id: init?.id, - targetGroupId - }) - }, - - mergeGroupIntoSibling: (worktreeId, groupId) => { - const state = get() - const groups = state.groupsByWorktree[worktreeId] ?? [] - const sourceGroup = groups.find((candidate) => candidate.id === groupId) - const layout = state.layoutByWorktree[worktreeId] - if (!sourceGroup || !layout || groups.length <= 1) { - return null - } - const targetGroupId = findSiblingGroupId(layout, groupId) - if (!targetGroupId) { - return null - } - - const orderedSourceTabs = (state.unifiedTabsByWorktree[worktreeId] ?? []).filter( - (tab) => tab.groupId === groupId - ) - for (const tabId of sourceGroup.tabOrder) { - const item = orderedSourceTabs.find((tab) => tab.id === tabId) - if (!item) { - continue - } - get().moveUnifiedTabToGroup(item.id, targetGroupId, { recordInteraction: false }) - } - get().closeEmptyGroup(worktreeId, groupId) - get().recordFeatureInteraction?.('terminal-panes') - return targetGroupId - }, - - setTabGroupSplitRatio: (worktreeId, nodePath, ratio) => { - set((state) => { - const currentLayout = state.layoutByWorktree[worktreeId] - if (!currentLayout) { - return state - } - // Why: an unchanged ratio must not mint fresh root state — every store - // subscriber wakes on the new reference (STA-3328). - let targetNode: TabGroupLayoutNode | undefined = currentLayout - for (const segment of nodePath.length > 0 ? nodePath.split('.') : []) { - targetNode = - targetNode && targetNode.type === 'split' && (segment === 'first' || segment === 'second') - ? targetNode[segment] - : undefined - } - if (!targetNode || targetNode.type !== 'split' || (targetNode.ratio ?? 0.5) === ratio) { - return state - } - return { - layoutByWorktree: { - ...state.layoutByWorktree, - // Why: split ratios belong to the tab-group model (not transient UI), so persist them for restores and multi-step group ops. - [worktreeId]: updateSplitRatio( - currentLayout, - nodePath.length > 0 ? nodePath.split('.') : [], - ratio - ) - } - } - }) - }, - - reconcileWorktreeTabModel: (worktreeId) => { - const reconciliation = projectWorktreeTabModelReconciliation(get(), worktreeId) - if (Object.keys(reconciliation.patch).length > 0) { - set(reconciliation.patch) - } - return { - renderableTabCount: reconciliation.renderableTabCount, - activeRenderableTabId: reconciliation.activeRenderableTabId - } - }, - - hydrateTabsSession: (session, options) => { - const state = get() - const persistedWorktreeIds = collectPersistedWorktreeIdsForSessionHydration(session) - const validWorktreeIds = buildValidWorktreeIdsForSessionHydration(state, persistedWorktreeIds) - validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID) - for (const workspace of state.folderWorkspaces) { - validWorktreeIds.add(folderWorkspaceKey(workspace.id)) - } - addAdditionalValidWorkspaceKeys(validWorktreeIds, options) - const hydrated = buildHydratedTabState(session, validWorktreeIds) - if (!options?.replaceWorkspaceKeys) { - set(hydrated) - return - } - const replaceWorkspaceKeys = new Set(options.replaceWorkspaceKeys) - set((current) => ({ - unifiedTabsByWorktree: replaceWorkspaceRecordKeys( - current.unifiedTabsByWorktree, - hydrated.unifiedTabsByWorktree, - replaceWorkspaceKeys - ), - groupsByWorktree: replaceWorkspaceRecordKeys( - current.groupsByWorktree, - hydrated.groupsByWorktree, - replaceWorkspaceKeys - ), - activeGroupIdByWorktree: replaceWorkspaceRecordKeys( - current.activeGroupIdByWorktree, - hydrated.activeGroupIdByWorktree, - replaceWorkspaceKeys - ), - layoutByWorktree: replaceWorkspaceRecordKeys( - current.layoutByWorktree, - hydrated.layoutByWorktree, - replaceWorkspaceKeys - ) - })) - } -}) +export type { + TabSplitDirection, + TabsSlice, + TabsSliceGet, + TabsSliceSet +} from './tabs/tabs-slice-contract' +export { createTabsSlice } from './tabs/create-tabs-slice' +export { findSiblingGroupId } from './tabs/tabs-layout' +export { + type WorktreeTabModelReconciliation, + projectWorktreeTabModelReconciliation +} from './tabs/tabs-reconciliation' diff --git a/src/renderer/src/store/slices/tabs/create-tabs-slice.ts b/src/renderer/src/store/slices/tabs/create-tabs-slice.ts new file mode 100644 index 00000000000..799cd257339 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/create-tabs-slice.ts @@ -0,0 +1,31 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../../types' +import type { TabsSlice } from './tabs-slice-contract' +import { createTabsCreateActions } from './tabs-create-actions' +import { createTabsFocusActions } from './tabs-focus-actions' +import { createTabsCloseActions } from './tabs-close-actions' +import { createTabsBulkCloseActions } from './tabs-bulk-close-actions' +import { createTabsLabelActions } from './tabs-label-actions' +import { createTabsGroupActions } from './tabs-group-actions' +import { createTabsMoveActions } from './tabs-move-actions' +import { createTabsDropActions } from './tabs-drop-actions' +import { createTabsSecondaryActions } from './tabs-secondary-actions' +import { createTabsSessionActions } from './tabs-session-actions' + +export const createTabsSlice: StateCreator = (set, get) => ({ + unifiedTabsByWorktree: {}, + renamingTabId: null, + groupsByWorktree: {}, + activeGroupIdByWorktree: {}, + layoutByWorktree: {}, + ...createTabsCreateActions(set, get), + ...createTabsFocusActions(set, get), + ...createTabsCloseActions(set, get), + ...createTabsBulkCloseActions(set, get), + ...createTabsLabelActions(set, get), + ...createTabsGroupActions(set, get), + ...createTabsMoveActions(set, get), + ...createTabsDropActions(set, get), + ...createTabsSecondaryActions(set, get), + ...createTabsSessionActions(set, get) +}) diff --git a/src/renderer/src/store/slices/tabs/tabs-bulk-close-actions.ts b/src/renderer/src/store/slices/tabs/tabs-bulk-close-actions.ts new file mode 100644 index 00000000000..65393c43315 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-bulk-close-actions.ts @@ -0,0 +1,87 @@ +import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' +import { findGroupForTab, findTabAndWorktree } from '../tab-group-state' + +export function createTabsBulkCloseActions( + _set: TabsSliceSet, + get: TabsSliceGet +): Pick { + return { + closeOtherTabs: (tabId) => { + const state = get() + const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + if (!found) { + return [] + } + const { tab, worktreeId } = found + const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) + if (!group) { + return [] + } + const closedIds = (state.unifiedTabsByWorktree[worktreeId] ?? []) + .filter((item) => item.groupId === group.id && item.id !== tabId && !item.isPinned) + .map((item) => item.id) + for (const id of closedIds) { + get().closeUnifiedTab(id) + } + return closedIds + }, + + closeTabsToRight: (tabId) => { + const state = get() + const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + if (!found) { + return [] + } + const { tab, worktreeId } = found + const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) + if (!group) { + return [] + } + const index = group.tabOrder.indexOf(tabId) + if (index === -1) { + return [] + } + const closableIds = group.tabOrder + .slice(index + 1) + .filter( + (id) => + !(state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.id === id + )?.isPinned + ) + for (const id of closableIds) { + get().closeUnifiedTab(id) + } + return closableIds + }, + + closeTabsToLeft: (tabId) => { + const state = get() + const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + if (!found) { + return [] + } + const { tab, worktreeId } = found + const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) + if (!group) { + return [] + } + const index = group.tabOrder.indexOf(tabId) + if (index === -1) { + return [] + } + const closableIds = group.tabOrder + .slice(0, index) + .filter( + (id) => + !(state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.id === id + )?.isPinned + ) + for (const id of closableIds) { + get().closeUnifiedTab(id) + } + return closableIds + } + } +} diff --git a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts new file mode 100644 index 00000000000..c89b0953f5d --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts @@ -0,0 +1,165 @@ +import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' +import { collapseGroupLayout } from './tabs-layout' +import { + dedupeTabOrder, + findGroupForTab, + findTabAndWorktree, + pickNextActiveTab, + sanitizeRecentTabIds +} from '../tab-group-state' +import { buildActiveSurfacePatch } from './tabs-surface' + +export function createTabsCloseActions( + set: TabsSliceSet, + get: TabsSliceGet +): Pick { + return { + closeUnifiedTab: (tabId, opts) => { + const state = get() + const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + if (!found) { + return null + } + const { tab, worktreeId } = found + const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) + if (!group) { + return null + } + + if (tab.contentType === 'terminal' && !opts?.terminalRetirementHandled) { + const dedupedGroupOrder = dedupeTabOrder(group.tabOrder) + const wasLastTab = + dedupeTabOrder(dedupedGroupOrder.filter((id) => id !== tabId)).length === 0 + // Why: unified-only hydrated tabs still own provider sessions without a legacy row, so retire every terminal close by entity id. + get().closeTab(tab.entityId, { recordInteraction: opts?.recordInteraction }) + return { closedTabId: tabId, wasLastTab, worktreeId } + } + + const dedupedGroupOrder = dedupeTabOrder(group.tabOrder) + const remainingOrder = dedupeTabOrder(dedupedGroupOrder.filter((id) => id !== tabId)) + const wasLastTab = remainingOrder.length === 0 + // Why: on closing the active tab, walk the MRU stack to the previously-active tab; pickNextActiveTab falls back to the neighbor. + const nextActiveTabId = + group.activeTabId === tabId + ? wasLastTab + ? null + : pickNextActiveTab(dedupedGroupOrder, group.recentTabIds, tabId) + : group.activeTabId + const nextRecentTabIds = sanitizeRecentTabIds( + (group.recentTabIds ?? []).filter((id) => id !== tabId), + remainingOrder + ) + const terminalEntityId = tab.contentType === 'terminal' ? tab.entityId : null + + set((current) => { + const nextTabs = (current.unifiedTabsByWorktree[worktreeId] ?? []).filter( + (item) => item.id !== tabId + ) + // Why: close-to-right/others bypass terminals.closeTab, so clear the entityId-keyed unread flag here or a stale dot leaks. + let nextUnreadTerminalTabs = current.unreadTerminalTabs + if (terminalEntityId && current.unreadTerminalTabs[terminalEntityId]) { + nextUnreadTerminalTabs = { ...current.unreadTerminalTabs } + delete nextUnreadTerminalTabs[terminalEntityId] + } + let nextGroups = (current.groupsByWorktree[worktreeId] ?? []).map((candidate) => + candidate.id === group.id + ? { + ...candidate, + activeTabId: nextActiveTabId, + tabOrder: remainingOrder, + recentTabIds: nextRecentTabIds + } + : candidate + ) + let nextLayoutByWorktree = current.layoutByWorktree + let nextActiveGroupIdByWorktree = current.activeGroupIdByWorktree + if (wasLastTab && current.layoutByWorktree[worktreeId] && nextGroups.length > 1) { + nextGroups = nextGroups.filter((candidate) => candidate.id !== group.id) + const collapsedState = collapseGroupLayout( + current.layoutByWorktree, + current.activeGroupIdByWorktree, + worktreeId, + group.id, + nextGroups[0]?.id ?? null + ) + nextLayoutByWorktree = collapsedState.layoutByWorktree + nextActiveGroupIdByWorktree = collapsedState.activeGroupIdByWorktree + } + // Why: the landing fallback answers "the user emptied this worktree". An unwound create + // never added a tab, so it must leave the selection exactly as the click found it. + const shouldDeactivateWorktree = + !opts?.preserveWorktreeSelection && + current.activeWorktreeId === worktreeId && + nextTabs.length === 0 && + (current.tabsByWorktree[worktreeId] ?? []).length === 0 && + (current.browserTabsByWorktree[worktreeId] ?? []).length === 0 && + !current.openFiles.some((file) => file.worktreeId === worktreeId) + return { + unifiedTabsByWorktree: { ...current.unifiedTabsByWorktree, [worktreeId]: nextTabs }, + groupsByWorktree: { + ...current.groupsByWorktree, + [worktreeId]: nextGroups + }, + layoutByWorktree: nextLayoutByWorktree, + activeGroupIdByWorktree: nextActiveGroupIdByWorktree, + // Why: skip writing unreadTerminalTabs when the reference is unchanged, avoiding a no-op alloc that re-runs full-state selectors. + ...(nextUnreadTerminalTabs !== current.unreadTerminalTabs + ? { unreadTerminalTabs: nextUnreadTerminalTabs } + : {}), + // Why: closing the last tab can leave the worktree selected but render-empty, so write the landing-state fallback directly. + ...(shouldDeactivateWorktree + ? { + activeWorktreeId: null, + activeWorkspaceKey: null, + activeWorkspaceExecutionHostId: null, + activeTabId: null, + activeBrowserTabId: null, + activeFileId: null, + activeTabType: 'terminal' as const, + activeTabIdByWorktree: { + ...current.activeTabIdByWorktree, + [worktreeId]: null + }, + activeBrowserTabIdByWorktree: { + ...current.activeBrowserTabIdByWorktree, + [worktreeId]: null + }, + activeFileIdByWorktree: { + ...current.activeFileIdByWorktree, + [worktreeId]: null + }, + activeTabTypeByWorktree: { + ...current.activeTabTypeByWorktree, + [worktreeId]: 'terminal' + } + } + : {}), + ...(!shouldDeactivateWorktree && current.activeWorktreeId === worktreeId + ? buildActiveSurfacePatch( + { + ...current, + unifiedTabsByWorktree: { + ...current.unifiedTabsByWorktree, + [worktreeId]: nextTabs + }, + groupsByWorktree: { + ...current.groupsByWorktree, + [worktreeId]: nextGroups + }, + layoutByWorktree: nextLayoutByWorktree, + activeGroupIdByWorktree: nextActiveGroupIdByWorktree + }, + worktreeId, + nextActiveGroupIdByWorktree[worktreeId] ?? null + ) + : {}) + } + }) + + if (opts?.recordInteraction !== false) { + get().recordFeatureInteraction?.('terminal-tabs') + } + return { closedTabId: tabId, wasLastTab, worktreeId } + } + } +} diff --git a/src/renderer/src/store/slices/tabs/tabs-create-actions.ts b/src/renderer/src/store/slices/tabs/tabs-create-actions.ts new file mode 100644 index 00000000000..b4f1d611b2f --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-create-actions.ts @@ -0,0 +1,221 @@ +import { createBrowserUuid } from '@/lib/browser-uuid' +import { getActiveExecutionHostIdForWorktree } from '@/lib/unified-tab-host-ownership' +import type { Tab, TabGroup } from '../../../../../shared/tab-types' +import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' +import { buildActiveSurfacePatch } from './tabs-surface' +import { buildSplitNode, replaceLeaf } from './tabs-layout' +import { + dedupeTabOrder, + ensureGroup, + findGroupForTab, + pushRecentTabId, + sanitizeRecentTabIds, + updateGroup +} from '../tab-group-state' +import { canReplacePreviewContentType } from './tabs-tab-order' + +export function createTabsCreateActions( + set: TabsSliceSet, + get: TabsSliceGet +): Pick { + return { + createUnifiedTab: (worktreeId, contentType, init) => { + const id = init?.id ?? createBrowserUuid() + let created!: Tab + set((state) => { + const { group, groupsByWorktree, activeGroupIdByWorktree } = ensureGroup( + state.groupsByWorktree, + state.activeGroupIdByWorktree, + worktreeId, + init?.targetGroupId ?? state.activeGroupIdByWorktree[worktreeId] + ) + const existingTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] + + let nextTabs = existingTabs + let nextOrder = dedupeTabOrder(group.tabOrder) + if (init?.isPreview) { + const existingPreview = existingTabs.find( + (tab) => + tab.groupId === group.id && + tab.isPreview && + canReplacePreviewContentType(contentType, tab.contentType) + ) + if (existingPreview) { + nextTabs = existingTabs.filter((tab) => tab.id !== existingPreview.id) + nextOrder = nextOrder.filter((tabId) => tabId !== existingPreview.id) + } + } + + const shouldActivate = init?.activate ?? true + const createdAt = Date.now() + const executionHostId = + init?.executionHostId ?? getActiveExecutionHostIdForWorktree(state, worktreeId) + created = { + id, + entityId: init?.entityId ?? id, + groupId: group.id, + worktreeId, + ...(executionHostId ? { executionHostId } : {}), + contentType, + label: + init?.label ?? + (contentType === 'terminal' ? `Terminal ${existingTabs.length + 1}` : id), + ...(init?.generatedLabel !== undefined ? { generatedLabel: init.generatedLabel } : {}), + ...(init?.quickCommandLabel !== undefined + ? { quickCommandLabel: init.quickCommandLabel } + : {}), + customLabel: init?.customLabel ?? null, + color: init?.color ?? null, + sortOrder: nextOrder.length, + createdAt, + // Why: creating an active tab is a focus event; Cmd+J recency reads lastFocusedAt. + ...(shouldActivate ? { lastFocusedAt: createdAt } : {}), + isPreview: init?.isPreview, + isPinned: init?.isPinned + } + + nextOrder = dedupeTabOrder([...nextOrder, created.id]) + const nextActiveTabId = shouldActivate ? created.id : (group.activeTabId ?? created.id) + const sanitizedRecent = sanitizeRecentTabIds(group.recentTabIds, nextOrder) + // Why: automation-created browser tabs must paint without stealing the visible group selection from the user's current tab. + const nextRecent = shouldActivate + ? pushRecentTabId(sanitizedRecent, created.id) + : sanitizedRecent + return { + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [worktreeId]: [...nextTabs, created] + }, + groupsByWorktree: { + ...groupsByWorktree, + [worktreeId]: updateGroup(groupsByWorktree[worktreeId] ?? [], { + ...group, + activeTabId: nextActiveTabId, + tabOrder: nextOrder, + recentTabIds: nextRecent + }) + }, + activeGroupIdByWorktree, + layoutByWorktree: { + ...state.layoutByWorktree, + [worktreeId]: state.layoutByWorktree[worktreeId] ?? { type: 'leaf', groupId: group.id } + } + } + }) + if (init?.recordInteraction !== false) { + get().recordFeatureInteraction?.('terminal-tabs') + } + return created + }, + + createUnifiedTabInSplit: (worktreeId, contentType, target, init) => { + const id = init?.id ?? createBrowserUuid() + const newGroupId = createBrowserUuid() + let created: Tab | null = null + let moved = false + set((state) => { + const sourceGroup = findGroupForTab( + state.groupsByWorktree, + worktreeId, + target.sourceGroupId + ) + if (!sourceGroup) { + return {} + } + const existingTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] + const currentGroups = state.groupsByWorktree[worktreeId] ?? [] + const shouldActivate = init?.activate ?? true + const currentLayout = + state.layoutByWorktree[worktreeId] ?? + ({ type: 'leaf', groupId: target.sourceGroupId } as const) + const createdAt = Date.now() + const executionHostId = + init?.executionHostId ?? getActiveExecutionHostIdForWorktree(state, worktreeId) + const createdTab: Tab = { + id, + entityId: init?.entityId ?? id, + groupId: newGroupId, + worktreeId, + ...(executionHostId ? { executionHostId } : {}), + contentType, + label: + init?.label ?? + (contentType === 'terminal' ? `Terminal ${existingTabs.length + 1}` : id), + ...(init?.generatedLabel !== undefined ? { generatedLabel: init.generatedLabel } : {}), + ...(init?.quickCommandLabel !== undefined + ? { quickCommandLabel: init.quickCommandLabel } + : {}), + customLabel: init?.customLabel ?? null, + color: init?.color ?? null, + sortOrder: 0, + createdAt, + // Why: creating an active tab is a focus event; Cmd+J recency reads lastFocusedAt. + ...(shouldActivate ? { lastFocusedAt: createdAt } : {}), + isPreview: init?.isPreview, + isPinned: init?.isPinned + } + const newGroup: TabGroup = { + id: newGroupId, + worktreeId, + activeTabId: id, + tabOrder: [id], + recentTabIds: shouldActivate ? [id] : [] + } + created = createdTab + const replacement = buildSplitNode( + target.sourceGroupId, + newGroupId, + target.splitDirection === 'left' || target.splitDirection === 'right' + ? 'horizontal' + : 'vertical', + target.splitDirection === 'left' || target.splitDirection === 'up' ? 'first' : 'second' + ) + const nextUnifiedTabsByWorktree = { + ...state.unifiedTabsByWorktree, + [worktreeId]: [...existingTabs, createdTab] + } + const nextGroupsByWorktree = { + ...state.groupsByWorktree, + [worktreeId]: [...currentGroups, newGroup] + } + const nextLayoutByWorktree = { + ...state.layoutByWorktree, + [worktreeId]: replaceLeaf(currentLayout, target.sourceGroupId, replacement) + } + const nextActiveGroupIdByWorktree = shouldActivate + ? { + ...state.activeGroupIdByWorktree, + [worktreeId]: newGroupId + } + : state.activeGroupIdByWorktree + moved = true + return { + unifiedTabsByWorktree: nextUnifiedTabsByWorktree, + groupsByWorktree: nextGroupsByWorktree, + layoutByWorktree: nextLayoutByWorktree, + activeGroupIdByWorktree: nextActiveGroupIdByWorktree, + ...(shouldActivate && state.activeWorktreeId === worktreeId + ? buildActiveSurfacePatch( + { + ...state, + unifiedTabsByWorktree: nextUnifiedTabsByWorktree, + groupsByWorktree: nextGroupsByWorktree, + layoutByWorktree: nextLayoutByWorktree, + activeGroupIdByWorktree: nextActiveGroupIdByWorktree + }, + worktreeId, + newGroupId + ) + : {}) + } + }) + if (created && init?.recordInteraction !== false) { + get().recordFeatureInteraction?.('terminal-tabs') + } + if (moved && init?.recordInteraction !== false) { + get().recordFeatureInteraction?.('tab-splits') + } + return created + } + } +} diff --git a/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts b/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts new file mode 100644 index 00000000000..2cadf76365e --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts @@ -0,0 +1,194 @@ +import { createBrowserUuid } from '@/lib/browser-uuid' +import type { TabGroup } from '../../../../../shared/tab-types' +import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' +import { isPaneColumnSplitDropNoOp } from '../pane-column-split-drop-no-op' +import { collapseGroupLayout, buildSplitNode, replaceLeaf } from './tabs-layout' +import { buildActiveSurfacePatch } from './tabs-surface' +import { + dedupeTabOrder, + findGroupAndWorktree, + findGroupForTab, + findTabAndWorktree, + pickNextActiveTab, + pushRecentTabId, + sanitizeRecentTabIds +} from '../tab-group-state' + +export function createTabsDropActions( + set: TabsSliceSet, + get: TabsSliceGet +): Pick { + return { + dropUnifiedTab: (tabId, target) => { + let moved = false + set((state) => { + const foundTab = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + const foundTarget = findGroupAndWorktree(state.groupsByWorktree, target.groupId) + if (!foundTab || !foundTarget || foundTab.worktreeId !== foundTarget.worktreeId) { + return {} + } + + const { tab, worktreeId } = foundTab + const sourceGroup = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) + const targetGroup = foundTarget.group + if (!sourceGroup) { + return {} + } + + const isSplitDrop = Boolean(target.splitDirection) + if (!isSplitDrop && tab.groupId === target.groupId) { + return {} + } + const layout = state.layoutByWorktree[worktreeId] + if ( + isSplitDrop && + isPaneColumnSplitDropNoOp({ + sourceGroupId: sourceGroup.id, + targetGroupId: target.groupId, + splitDirection: target.splitDirection!, + sourceTabCount: sourceGroup.tabOrder.length, + layout + }) + ) { + // Why: dropping a group's last tab on its own/sibling matching edge only makes a transient column that immediately collapses. + return {} + } + + moved = true + + let nextGroups = state.groupsByWorktree[worktreeId] ?? [] + let nextLayoutByWorktree = state.layoutByWorktree + let nextActiveGroupIdByWorktree = state.activeGroupIdByWorktree + let resolvedTargetGroupId = target.groupId + + if (target.splitDirection) { + const newGroupId = createBrowserUuid() + const newGroup: TabGroup = { + id: newGroupId, + worktreeId, + activeTabId: null, // Placeholder; properly set in the nextGroups.map() below + tabOrder: [] + } + const currentLayout = + nextLayoutByWorktree[worktreeId] ?? ({ type: 'leaf', groupId: target.groupId } as const) + const replacement = buildSplitNode( + target.groupId, + newGroupId, + target.splitDirection === 'left' || target.splitDirection === 'right' + ? 'horizontal' + : 'vertical', + target.splitDirection === 'left' || target.splitDirection === 'up' ? 'first' : 'second' + ) + + resolvedTargetGroupId = newGroupId + nextGroups = [...nextGroups, newGroup] + nextLayoutByWorktree = { + ...nextLayoutByWorktree, + [worktreeId]: replaceLeaf(currentLayout, target.groupId, replacement) + } + nextActiveGroupIdByWorktree = { + ...nextActiveGroupIdByWorktree, + [worktreeId]: newGroupId + } + } + + const dedupedSourceGroupOrder = dedupeTabOrder(sourceGroup.tabOrder) + const sourceOrder = dedupeTabOrder(dedupedSourceGroupOrder.filter((id) => id !== tabId)) + const destinationGroup = + nextGroups.find((group) => group.id === resolvedTargetGroupId) ?? targetGroup + // Why: target order may already hold this tab id (racey write / same-group split); dedupe first or React hits a duplicate key. + const targetOrder = dedupeTabOrder(destinationGroup.tabOrder.filter((id) => id !== tabId)) + const targetIndex = Math.max( + 0, + Math.min(target.index ?? targetOrder.length, targetOrder.length) + ) + targetOrder.splice(targetIndex, 0, tabId) + + const sourceRecentTabIds = sanitizeRecentTabIds( + (sourceGroup.recentTabIds ?? []).filter((id) => id !== tabId), + sourceOrder + ) + nextGroups = nextGroups.map((group) => { + if (group.id === sourceGroup.id) { + return { + ...group, + activeTabId: + group.activeTabId === tabId + ? // Why: same MRU-aware fallback as moveUnifiedTabToGroup — the drag keeps the user on their previously-active tab. + pickNextActiveTab(dedupedSourceGroupOrder, sourceGroup.recentTabIds, tabId) + : group.activeTabId, + tabOrder: sourceOrder, + recentTabIds: sourceRecentTabIds + } + } + if (group.id === resolvedTargetGroupId) { + return { + ...group, + activeTabId: tabId, + tabOrder: targetOrder, + recentTabIds: pushRecentTabId( + sanitizeRecentTabIds(group.recentTabIds, targetOrder), + tabId + ) + } + } + return group + }) + + if (sourceOrder.length === 0) { + nextGroups = nextGroups.filter((group) => group.id !== sourceGroup.id) + const collapsedState = collapseGroupLayout( + nextLayoutByWorktree, + nextActiveGroupIdByWorktree, + worktreeId, + sourceGroup.id, + resolvedTargetGroupId + ) + nextLayoutByWorktree = collapsedState.layoutByWorktree + nextActiveGroupIdByWorktree = collapsedState.activeGroupIdByWorktree + } else { + nextActiveGroupIdByWorktree = { + ...nextActiveGroupIdByWorktree, + [worktreeId]: resolvedTargetGroupId + } + } + + const nextUnifiedTabsByWorktree = { + ...state.unifiedTabsByWorktree, + [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => + candidate.id === tabId ? { ...candidate, groupId: resolvedTargetGroupId } : candidate + ) + } + const nextGroupsByWorktree = { + ...state.groupsByWorktree, + [worktreeId]: nextGroups + } + + return { + unifiedTabsByWorktree: nextUnifiedTabsByWorktree, + groupsByWorktree: nextGroupsByWorktree, + layoutByWorktree: nextLayoutByWorktree, + activeGroupIdByWorktree: nextActiveGroupIdByWorktree, + ...(state.activeWorktreeId === worktreeId + ? buildActiveSurfacePatch( + { + ...state, + unifiedTabsByWorktree: nextUnifiedTabsByWorktree, + groupsByWorktree: nextGroupsByWorktree, + layoutByWorktree: nextLayoutByWorktree, + activeGroupIdByWorktree: nextActiveGroupIdByWorktree + }, + worktreeId, + resolvedTargetGroupId + ) + : {}) + } + }) + if (moved) { + get().recordFeatureInteraction?.('terminal-tabs') + get().recordFeatureInteraction?.('tab-splits') + } + return moved + } + } +} diff --git a/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts b/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts new file mode 100644 index 00000000000..39a0dd38858 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts @@ -0,0 +1,113 @@ +import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' +import { + findTabAndWorktree, + findTabByEntityInGroup, + pushRecentTabId, + sanitizeRecentTabIds +} from '../tab-group-state' + +export function createTabsFocusActions( + _set: TabsSliceSet, + get: TabsSliceGet +): Pick { + const set = _set + return { + getTab: (tabId) => findTabAndWorktree(get().unifiedTabsByWorktree, tabId)?.tab ?? null, + + getActiveTab: (worktreeId) => { + const state = get() + const groupId = state.activeGroupIdByWorktree[worktreeId] + const group = (state.groupsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.id === groupId + ) + if (!group?.activeTabId) { + return null + } + return ( + (state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (tab) => tab.id === group.activeTabId + ) ?? null + ) + }, + + findTabForEntityInGroup: (worktreeId, groupId, entityId, contentType) => + findTabByEntityInGroup( + get().unifiedTabsByWorktree, + worktreeId, + groupId, + entityId, + contentType + ), + + activateTab: (tabId, opts) => { + set((state) => { + const scopedWorktreeId = opts?.worktreeId + let found: ReturnType + if (scopedWorktreeId !== undefined) { + const scopedTabs = Object.hasOwn(state.unifiedTabsByWorktree, scopedWorktreeId) + ? state.unifiedTabsByWorktree[scopedWorktreeId] + : [] + const scopedTab = scopedTabs.find((tab) => tab.id === tabId) + found = scopedTab ? { tab: scopedTab, worktreeId: scopedWorktreeId } : null + } else { + found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + } + if (!found) { + return {} + } + const { tab, worktreeId } = found + // Why: activating a terminal tab dismisses its tab-level bell — the user has moved their eyes here. + // Why (activeWorktree guard below): only when the tab is in the active worktree, else the unseen signal is lost (mirrors focusGroup). + const terminalEntityId = tab.contentType === 'terminal' ? tab.entityId : null + const nextUnreadTerminalTabs = + state.activeWorktreeId === worktreeId && + terminalEntityId && + state.unreadTerminalTabs[terminalEntityId] + ? (() => { + const copy = { ...state.unreadTerminalTabs } + delete copy[terminalEntityId] + return copy + })() + : state.unreadTerminalTabs + return { + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((item) => + item.id === tabId + ? { + ...item, + isPreview: opts?.preservePreview ? item.isPreview : false, + lastFocusedAt: Date.now() + } + : item + ) + }, + groupsByWorktree: { + ...state.groupsByWorktree, + [worktreeId]: (state.groupsByWorktree[worktreeId] ?? []).map((group) => + group.id === tab.groupId + ? { + ...group, + activeTabId: tabId, + // Why: track every activation in the group's MRU so closeUnifiedTab returns to the previous tab; sanitize to prune removed ids. + recentTabIds: pushRecentTabId( + sanitizeRecentTabIds(group.recentTabIds, group.tabOrder), + tabId + ) + } + : group + ) + }, + activeGroupIdByWorktree: { + ...state.activeGroupIdByWorktree, + [worktreeId]: tab.groupId + }, + // Why: skip writing unreadTerminalTabs when the reference is unchanged, avoiding a no-op alloc that re-runs full-state selectors. + ...(nextUnreadTerminalTabs !== state.unreadTerminalTabs + ? { unreadTerminalTabs: nextUnreadTerminalTabs } + : {}) + } + }) + } + } +} diff --git a/src/renderer/src/store/slices/tabs/tabs-group-actions.ts b/src/renderer/src/store/slices/tabs/tabs-group-actions.ts new file mode 100644 index 00000000000..f071ff4071d --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-group-actions.ts @@ -0,0 +1,195 @@ +import { createBrowserUuid } from '@/lib/browser-uuid' +import type { TabGroup } from '../../../../../shared/tab-types' +import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' +import { activeSurfacePatchMatchesState, buildActiveSurfacePatch } from './tabs-surface' +import { buildSplitNode, collapseGroupLayout, replaceLeaf } from './tabs-layout' + +export function createTabsGroupActions( + set: TabsSliceSet, + get: TabsSliceGet +): Pick< + TabsSlice, + 'ensureWorktreeRootGroup' | 'focusGroup' | 'closeEmptyGroup' | 'createEmptySplitGroup' +> { + return { + ensureWorktreeRootGroup: (worktreeId) => { + const existingGroups = get().groupsByWorktree[worktreeId] ?? [] + if (existingGroups.length > 0) { + return get().activeGroupIdByWorktree[worktreeId] ?? existingGroups[0].id + } + + const groupId = createBrowserUuid() + set((state) => ({ + // Why: a zero-tab worktree still needs a canonical root group so new tabs and splits land in a deterministic place. + groupsByWorktree: { + ...state.groupsByWorktree, + [worktreeId]: [{ id: groupId, worktreeId, activeTabId: null, tabOrder: [] }] + }, + layoutByWorktree: { + ...state.layoutByWorktree, + [worktreeId]: { type: 'leaf', groupId } + }, + activeGroupIdByWorktree: { + ...state.activeGroupIdByWorktree, + [worktreeId]: groupId + } + })) + return groupId + }, + + focusGroup: (worktreeId, groupId) => + set((state) => { + const groupAlreadyFocused = state.activeGroupIdByWorktree[worktreeId] === groupId + const nextActiveGroupIdByWorktree = groupAlreadyFocused + ? state.activeGroupIdByWorktree + : { + ...state.activeGroupIdByWorktree, + [worktreeId]: groupId + } + // Why: focusing a group surfaces its active terminal tab, so dismiss the tab-level bell. + // Why (activeWorktree guard below): only when the group is in the active worktree, else the unseen tab's bell is swallowed. + if (state.activeWorktreeId !== worktreeId) { + if (groupAlreadyFocused) { + return state + } + return { + activeGroupIdByWorktree: nextActiveGroupIdByWorktree + } + } + const groups = state.groupsByWorktree[worktreeId] ?? [] + const unifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] + const visibleTerminalEntityIds = new Set( + groups + .map((group) => + group.activeTabId ? unifiedTabs.find((tab) => tab.id === group.activeTabId) : null + ) + .filter((tab): tab is (typeof unifiedTabs)[number] => tab?.contentType === 'terminal') + .map((tab) => tab.entityId) + ) + const nextUnreadTerminalTabs = + visibleTerminalEntityIds.size > 0 + ? (() => { + let changed = false + const copy = { ...state.unreadTerminalTabs } + for (const terminalEntityId of visibleTerminalEntityIds) { + if (!copy[terminalEntityId]) { + continue + } + delete copy[terminalEntityId] + changed = true + } + return changed ? copy : state.unreadTerminalTabs + })() + : state.unreadTerminalTabs + const activeSurfacePatch = buildActiveSurfacePatch( + { + ...state, + activeGroupIdByWorktree: nextActiveGroupIdByWorktree + }, + worktreeId, + groupId + ) + if ( + groupAlreadyFocused && + nextUnreadTerminalTabs === state.unreadTerminalTabs && + activeSurfacePatchMatchesState(state, worktreeId, activeSurfacePatch) + ) { + return state + } + return { + ...(groupAlreadyFocused ? {} : { activeGroupIdByWorktree: nextActiveGroupIdByWorktree }), + // Why: only write unreadTerminalTabs when it changed — preserving the reference keeps selectors/subscribers from firing spuriously. + ...(nextUnreadTerminalTabs !== state.unreadTerminalTabs + ? { unreadTerminalTabs: nextUnreadTerminalTabs } + : {}), + ...activeSurfacePatch + } + }), + + closeEmptyGroup: (worktreeId, groupId) => { + const state = get() + const group = (state.groupsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.id === groupId + ) + if (!group || group.tabOrder.length > 0) { + return false + } + set((current) => { + const remainingGroups = (current.groupsByWorktree[worktreeId] ?? []).filter( + (candidate) => candidate.id !== groupId + ) + const collapsedState = collapseGroupLayout( + current.layoutByWorktree, + current.activeGroupIdByWorktree, + worktreeId, + groupId, + remainingGroups[0]?.id ?? null + ) + // Why: drop the dead group's recent-quick-command entry so the map can't grow unbounded as groups open/close. + const { [groupId]: _droppedRecent, ...remainingRecent } = + current.recentQuickCommandIdByGroup + return { + groupsByWorktree: { ...current.groupsByWorktree, [worktreeId]: remainingGroups }, + layoutByWorktree: collapsedState.layoutByWorktree, + activeGroupIdByWorktree: collapsedState.activeGroupIdByWorktree, + recentQuickCommandIdByGroup: remainingRecent, + ...(current.activeWorktreeId === worktreeId + ? buildActiveSurfacePatch( + { + ...current, + groupsByWorktree: { + ...current.groupsByWorktree, + [worktreeId]: remainingGroups + }, + layoutByWorktree: collapsedState.layoutByWorktree, + activeGroupIdByWorktree: collapsedState.activeGroupIdByWorktree + }, + worktreeId, + collapsedState.activeGroupIdByWorktree[worktreeId] ?? null + ) + : {}) + } + }) + return true + }, + + createEmptySplitGroup: (worktreeId, sourceGroupId, direction, opts) => { + const newGroupId = createBrowserUuid() + const newGroup: TabGroup = { + id: newGroupId, + worktreeId, + activeTabId: null, + tabOrder: [] + } + const shouldActivate = opts?.activate !== false + set((state) => { + const existing = state.groupsByWorktree[worktreeId] ?? [] + const currentLayout = + state.layoutByWorktree[worktreeId] ?? ({ type: 'leaf', groupId: sourceGroupId } as const) + const replacement = buildSplitNode( + sourceGroupId, + newGroupId, + direction === 'left' || direction === 'right' ? 'horizontal' : 'vertical', + direction === 'left' || direction === 'up' ? 'first' : 'second' + ) + return { + groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: [...existing, newGroup] }, + layoutByWorktree: { + ...state.layoutByWorktree, + [worktreeId]: replaceLeaf(currentLayout, sourceGroupId, replacement) + }, + ...(shouldActivate + ? { + activeGroupIdByWorktree: { + ...state.activeGroupIdByWorktree, + [worktreeId]: newGroupId + } + } + : {}) + } + }) + get().recordFeatureInteraction?.('terminal-panes') + return newGroupId + } + } +} diff --git a/src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts b/src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts new file mode 100644 index 00000000000..e87a34f81c2 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts @@ -0,0 +1,62 @@ +import type { AppState } from '../../types' +import type { TerminalTab } from '../../../../../shared/terminal-tab-types' +import { findTabAndWorktree } from '../tab-group-state' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' + +export function patchTerminalTabPinned( + tabsByWorktree: Record, + worktreeId: string, + tabId: string, + isPinned: boolean +): Partial> { + const tabs = tabsByWorktree[worktreeId] + if (!tabs?.some((tab) => tab.id === tabId)) { + return {} + } + return { + tabsByWorktree: { + ...tabsByWorktree, + [worktreeId]: tabs.map((tab) => (tab.id === tabId ? { ...tab, isPinned } : tab)) + } + } +} + +// Why: pin is host-authoritative for remote-server tabs, so mirror it (like setTabColor) or it's lost on reconnect/other clients. +// Dynamic import keeps this store slice off the runtime layer. +export function mirrorTabPinnedToHost(state: AppState, tabId: string, isPinned: boolean): void { + const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + // Why: only terminal tab pins are persisted host-side today (browser/editor in #5729); skip the RPC for other types. + if ( + !found || + found.tab.contentType !== 'terminal' || + !getRuntimeEnvironmentIdForWorktree(state, found.worktreeId) + ) { + return + } + const worktreeId = found.worktreeId + void import('@/runtime/web-runtime-session').then(({ setWebRuntimeTabProps }) => + setWebRuntimeTabProps({ worktreeId, tabId, isPinned }) + ) +} + +// Why: viewMode is host-tracked like color/pin, so mirror local sets or they're lost on reconnect and to paired clients. +// Only the action path mirrors (never reconcile applying a host value), so the echoed snapshot can't re-trigger an outbound RPC. +export function mirrorTabViewModeToHost( + state: AppState, + tabId: string, + viewMode: 'terminal' | 'chat' +): void { + const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + // Why: only terminal tab viewMode is persisted host-side; skip the RPC for other types instead of a no-op round trip. + if ( + !found || + found.tab.contentType !== 'terminal' || + !getRuntimeEnvironmentIdForWorktree(state, found.worktreeId) + ) { + return + } + const worktreeId = found.worktreeId + void import('@/runtime/web-runtime-session').then(({ setWebRuntimeTabProps }) => + setWebRuntimeTabProps({ worktreeId, tabId, viewMode }) + ) +} diff --git a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts new file mode 100644 index 00000000000..012e1e8c0c0 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts @@ -0,0 +1,199 @@ +import { emitNativeChatToggled } from '@/lib/native-chat-telemetry' +import type { TuiAgent } from '../../../../../shared/tui-agent' +import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' +import { findTabAndWorktree, patchTab, updateGroup, dedupeTabOrder } from '../tab-group-state' +import { applyTabOrderSortValues, partitionPinnedTabOrder } from './tabs-tab-order' +import { + mirrorTabPinnedToHost, + mirrorTabViewModeToHost, + patchTerminalTabPinned +} from './tabs-host-mirroring' + +export function createTabsLabelActions( + set: TabsSliceSet, + get: TabsSliceGet +): Pick< + TabsSlice, + | 'reorderUnifiedTabs' + | 'setTabLabel' + | 'setTabViewMode' + | 'toggleTabViewMode' + | 'setRenamingTabId' + | 'setTabCustomLabel' + | 'setUnifiedTabColor' + | 'pinTab' + | 'unpinTab' +> { + return { + reorderUnifiedTabs: (groupId, tabIds, opts) => { + let reordered = false + set((state) => { + for (const [worktreeId, groups] of Object.entries(state.groupsByWorktree)) { + const group = groups.find((candidate) => candidate.id === groupId) + if (!group) { + continue + } + // Why: dedupe at the store boundary so each tab keeps one canonical position and later group ops don't branch on duplicate ids. + const nextTabOrder = dedupeTabOrder(tabIds) + reordered = true + const orderMap = new Map(nextTabOrder.map((id, index) => [id, index])) + return { + groupsByWorktree: { + ...state.groupsByWorktree, + [worktreeId]: updateGroup(groups, { ...group, tabOrder: nextTabOrder }) + }, + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((tab) => { + const sortOrder = orderMap.get(tab.id) + return sortOrder === undefined ? tab : { ...tab, sortOrder } + }) + } + } + } + return {} + }) + if (reordered && opts?.recordInteraction !== false) { + get().recordFeatureInteraction?.('terminal-tabs') + } + }, + + setTabLabel: (tabId, label) => { + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { label }) ?? {}) + }, + + setTabViewMode: (tabId, mode) => { + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: mode }) ?? {}) + mirrorTabViewModeToHost(get(), tabId, mode) + }, + + toggleTabViewMode: (tabId) => { + let toggled: { + from: 'terminal' | 'chat' + to: 'terminal' | 'chat' + agent: TuiAgent | null + } | null = null + set((state) => { + const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + if (!found) { + return {} + } + // Why: viewMode defaults to 'terminal' for legacy/missing, so the first toggle flips to 'chat'. + const fromMode: 'terminal' | 'chat' = found.tab.viewMode === 'chat' ? 'chat' : 'terminal' + const nextMode = fromMode === 'chat' ? 'terminal' : 'chat' + // Why: launchAgent lives on the legacy terminal tab (keyed by entityId); resolve it here so toggle telemetry can attribute by agent. + const agent = + (state.tabsByWorktree[found.worktreeId] ?? []).find( + (terminal) => terminal.id === found.tab.entityId + )?.launchAgent ?? null + toggled = { from: fromMode, to: nextMode, agent } + return patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: nextMode }) ?? {} + }) + // Why: emit after the state write so the event reflects the committed mode. + const committed = toggled as { + from: 'terminal' | 'chat' + to: 'terminal' | 'chat' + agent: TuiAgent | null + } | null + if (committed) { + emitNativeChatToggled(committed) + mirrorTabViewModeToHost(get(), tabId, committed.to) + } + }, + + setRenamingTabId: (tabId) => { + set({ renamingTabId: tabId }) + }, + + setTabCustomLabel: (tabId, label, opts) => { + const exists = get().getTab(tabId) !== null + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { customLabel: label }) ?? {}) + if (exists && opts?.recordInteraction !== false) { + get().recordFeatureInteraction?.('terminal-tabs') + } + }, + + setUnifiedTabColor: (tabId, color) => { + const exists = get().getTab(tabId) !== null + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { color }) ?? {}) + if (exists) { + get().recordFeatureInteraction?.('terminal-tabs') + } + }, + + pinTab: (tabId) => { + const exists = get().getTab(tabId) !== null + set((state) => { + const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + if (!found) { + return {} + } + const { tab, worktreeId } = found + const tabs = (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => + candidate.id === tabId ? { ...candidate, isPinned: true, isPreview: false } : candidate + ) + const groups = state.groupsByWorktree[worktreeId] ?? [] + const group = groups.find((candidate) => candidate.id === tab.groupId) + if (!group) { + return { + unifiedTabsByWorktree: { ...state.unifiedTabsByWorktree, [worktreeId]: tabs } + } + } + const tabOrder = partitionPinnedTabOrder(group.tabOrder, tabs, tabId) + return { + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [worktreeId]: applyTabOrderSortValues(tabs, tabOrder) + }, + // Why: reconcile derives pin from the TerminalTab, so mirror it there too or a host snapshot recomputes isPinned:false and un-pins during the echo window. + ...patchTerminalTabPinned(state.tabsByWorktree, worktreeId, tabId, true), + groupsByWorktree: { + ...state.groupsByWorktree, + [worktreeId]: updateGroup(groups, { ...group, tabOrder }) + } + } + }) + mirrorTabPinnedToHost(get(), tabId, true) + if (exists) { + get().recordFeatureInteraction?.('terminal-tabs') + } + }, + + unpinTab: (tabId) => { + const exists = get().getTab(tabId) !== null + set((state) => { + const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + if (!found) { + return {} + } + const { tab, worktreeId } = found + const tabs = (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => + candidate.id === tabId ? { ...candidate, isPinned: false } : candidate + ) + const groups = state.groupsByWorktree[worktreeId] ?? [] + const group = groups.find((candidate) => candidate.id === tab.groupId) + if (!group) { + return { + unifiedTabsByWorktree: { ...state.unifiedTabsByWorktree, [worktreeId]: tabs } + } + } + const tabOrder = partitionPinnedTabOrder(group.tabOrder, tabs, tabId) + return { + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [worktreeId]: applyTabOrderSortValues(tabs, tabOrder) + }, + ...patchTerminalTabPinned(state.tabsByWorktree, worktreeId, tabId, false), + groupsByWorktree: { + ...state.groupsByWorktree, + [worktreeId]: updateGroup(groups, { ...group, tabOrder }) + } + } + }) + mirrorTabPinnedToHost(get(), tabId, false) + if (exists) { + get().recordFeatureInteraction?.('terminal-tabs') + } + } + } +} diff --git a/src/renderer/src/store/slices/tabs/tabs-layout.ts b/src/renderer/src/store/slices/tabs/tabs-layout.ts new file mode 100644 index 00000000000..293dcce03a5 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-layout.ts @@ -0,0 +1,125 @@ +import type { TabGroupLayoutNode } from '../../../../../shared/tab-types' + +export function buildSplitNode( + existingGroupId: string, + newGroupId: string, + direction: 'horizontal' | 'vertical', + position: 'first' | 'second' +): TabGroupLayoutNode { + const existingLeaf: TabGroupLayoutNode = { type: 'leaf', groupId: existingGroupId } + const newLeaf: TabGroupLayoutNode = { type: 'leaf', groupId: newGroupId } + return { + type: 'split', + direction, + first: position === 'first' ? newLeaf : existingLeaf, + second: position === 'second' ? newLeaf : existingLeaf, + ratio: 0.5 + } +} + +export function replaceLeaf( + root: TabGroupLayoutNode, + targetGroupId: string, + replacement: TabGroupLayoutNode +): TabGroupLayoutNode { + if (root.type === 'leaf') { + return root.groupId === targetGroupId ? replacement : root + } + return { + ...root, + first: replaceLeaf(root.first, targetGroupId, replacement), + second: replaceLeaf(root.second, targetGroupId, replacement) + } +} + +export function updateSplitRatio( + root: TabGroupLayoutNode, + path: string[], + ratio: number +): TabGroupLayoutNode { + if (path.length === 0) { + return root.type === 'split' ? { ...root, ratio } : root + } + if (root.type !== 'split') { + return root + } + const [segment, ...rest] = path + if (segment === 'first') { + return { ...root, first: updateSplitRatio(root.first, rest, ratio) } + } + if (segment === 'second') { + return { ...root, second: updateSplitRatio(root.second, rest, ratio) } + } + return root +} + +function findFirstLeaf(root: TabGroupLayoutNode): string { + return root.type === 'leaf' ? root.groupId : findFirstLeaf(root.first) +} + +export function findSiblingGroupId(root: TabGroupLayoutNode, targetGroupId: string): string | null { + if (root.type === 'leaf') { + return null + } + if (root.first.type === 'leaf' && root.first.groupId === targetGroupId) { + return root.second.type === 'leaf' ? root.second.groupId : findFirstLeaf(root.second) + } + if (root.second.type === 'leaf' && root.second.groupId === targetGroupId) { + return root.first.type === 'leaf' ? root.first.groupId : findFirstLeaf(root.first) + } + return ( + findSiblingGroupId(root.first, targetGroupId) ?? findSiblingGroupId(root.second, targetGroupId) + ) +} + +function removeLeaf(root: TabGroupLayoutNode, targetGroupId: string): TabGroupLayoutNode | null { + if (root.type === 'leaf') { + return root.groupId === targetGroupId ? null : root + } + if (root.first.type === 'leaf' && root.first.groupId === targetGroupId) { + return root.second + } + if (root.second.type === 'leaf' && root.second.groupId === targetGroupId) { + return root.first + } + const first = removeLeaf(root.first, targetGroupId) + const second = removeLeaf(root.second, targetGroupId) + if (first === null) { + return second + } + if (second === null) { + return first + } + return { ...root, first, second } +} + +export function collapseGroupLayout( + layoutByWorktree: Record, + activeGroupIdByWorktree: Record, + worktreeId: string, + groupId: string, + fallbackGroupId?: string | null +): { + layoutByWorktree: Record + activeGroupIdByWorktree: Record +} { + const currentLayout = layoutByWorktree[worktreeId] + if (!currentLayout) { + return { layoutByWorktree, activeGroupIdByWorktree } + } + const siblingId = findSiblingGroupId(currentLayout, groupId) + const collapsed = removeLeaf(currentLayout, groupId) + const nextLayoutByWorktree = { ...layoutByWorktree } + if (collapsed) { + nextLayoutByWorktree[worktreeId] = collapsed + } else { + delete nextLayoutByWorktree[worktreeId] + } + return { + layoutByWorktree: nextLayoutByWorktree, + activeGroupIdByWorktree: { + ...activeGroupIdByWorktree, + [worktreeId]: siblingId ?? fallbackGroupId ?? activeGroupIdByWorktree[worktreeId] + } + } +} diff --git a/src/renderer/src/store/slices/tabs/tabs-move-actions.ts b/src/renderer/src/store/slices/tabs/tabs-move-actions.ts new file mode 100644 index 00000000000..4c651f613ac --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-move-actions.ts @@ -0,0 +1,132 @@ +import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' +import { collapseGroupLayout } from './tabs-layout' +import { buildActiveSurfacePatch } from './tabs-surface' +import { + dedupeTabOrder, + findGroupAndWorktree, + findGroupForTab, + findTabAndWorktree, + pickNextActiveTab, + pushRecentTabId, + sanitizeRecentTabIds +} from '../tab-group-state' + +export function createTabsMoveActions( + set: TabsSliceSet, + get: TabsSliceGet +): Pick { + return { + moveUnifiedTabToGroup: (tabId, targetGroupId, opts) => { + let moved = false + set((state) => { + const foundTab = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + const foundTarget = findGroupAndWorktree(state.groupsByWorktree, targetGroupId) + if (!foundTab || !foundTarget || foundTab.worktreeId !== foundTarget.worktreeId) { + return {} + } + const { tab, worktreeId } = foundTab + if (tab.groupId === targetGroupId) { + return {} + } + const sourceGroup = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) + const targetGroup = foundTarget.group + if (!sourceGroup) { + return {} + } + moved = true + + const dedupedSourceGroupOrder = dedupeTabOrder(sourceGroup.tabOrder) + const sourceOrder = dedupeTabOrder(dedupedSourceGroupOrder.filter((id) => id !== tabId)) + // Why: defensive dedupe so target order can't grow a duplicate id (stale state); see dropUnifiedTab for the same guard. + const targetOrder = dedupeTabOrder(targetGroup.tabOrder.filter((id) => id !== tabId)) + const targetIndex = Math.max( + 0, + Math.min(opts?.index ?? targetOrder.length, targetOrder.length) + ) + targetOrder.splice(targetIndex, 0, tabId) + const nextActiveGroupIdByWorktree = { + ...state.activeGroupIdByWorktree, + [worktreeId]: opts?.activate ? targetGroupId : state.activeGroupIdByWorktree[worktreeId] + } + const sourceRecentTabIds = sanitizeRecentTabIds( + (sourceGroup.recentTabIds ?? []).filter((id) => id !== tabId), + sourceOrder + ) + const nextGroups = (state.groupsByWorktree[worktreeId] ?? []).map((group) => { + if (group.id === sourceGroup.id) { + return { + ...group, + activeTabId: + group.activeTabId === tabId + ? // Why: keep MRU-aware selection so the user lands on their previously-focused tab, not a visual neighbor. + pickNextActiveTab(dedupedSourceGroupOrder, sourceGroup.recentTabIds, tabId) + : group.activeTabId, + tabOrder: sourceOrder, + recentTabIds: sourceRecentTabIds + } + } + if (group.id === targetGroupId) { + const sanitizedTargetRecent = sanitizeRecentTabIds(group.recentTabIds, targetOrder) + return { + ...group, + activeTabId: opts?.activate ? tabId : group.activeTabId, + tabOrder: targetOrder, + recentTabIds: opts?.activate + ? pushRecentTabId(sanitizedTargetRecent, tabId) + : sanitizedTargetRecent + } + } + return group + }) + let nextLayoutByWorktree = state.layoutByWorktree + let nextActiveGroupIdByWorktreeResolved = nextActiveGroupIdByWorktree + let filteredGroups = nextGroups + if (sourceOrder.length === 0) { + filteredGroups = nextGroups.filter((group) => group.id !== sourceGroup.id) + const collapsedState = collapseGroupLayout( + nextLayoutByWorktree, + nextActiveGroupIdByWorktreeResolved, + worktreeId, + sourceGroup.id, + targetGroupId + ) + nextLayoutByWorktree = collapsedState.layoutByWorktree + nextActiveGroupIdByWorktreeResolved = collapsedState.activeGroupIdByWorktree + } + const nextGroupsByWorktree = { + ...state.groupsByWorktree, + [worktreeId]: filteredGroups + } + const nextUnifiedTabsByWorktree = { + ...state.unifiedTabsByWorktree, + [worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => + candidate.id === tabId ? { ...candidate, groupId: targetGroupId } : candidate + ) + } + return { + unifiedTabsByWorktree: nextUnifiedTabsByWorktree, + groupsByWorktree: nextGroupsByWorktree, + layoutByWorktree: nextLayoutByWorktree, + activeGroupIdByWorktree: nextActiveGroupIdByWorktreeResolved, + ...(state.activeWorktreeId === worktreeId + ? buildActiveSurfacePatch( + { + ...state, + unifiedTabsByWorktree: nextUnifiedTabsByWorktree, + groupsByWorktree: nextGroupsByWorktree, + layoutByWorktree: nextLayoutByWorktree, + activeGroupIdByWorktree: nextActiveGroupIdByWorktreeResolved + }, + worktreeId, + nextActiveGroupIdByWorktreeResolved[worktreeId] ?? null + ) + : {}) + } + }) + if (moved && opts?.recordInteraction !== false) { + get().recordFeatureInteraction?.('tab-splits') + } + return moved + } + } +} diff --git a/src/renderer/src/store/slices/tabs/tabs-reconciliation.ts b/src/renderer/src/store/slices/tabs/tabs-reconciliation.ts new file mode 100644 index 00000000000..fb0a357273f --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-reconciliation.ts @@ -0,0 +1,219 @@ +import type { AppState } from '../../types' +import type { Tab } from '../../../../../shared/tab-types' +import { dedupeTabOrder, ensureGroup, sanitizeRecentTabIds, updateGroup } from '../tab-group-state' +import { pruneTabGroupLayoutForGroups } from '../tabs-hydration' +import { + buildOrphanTerminalCleanupPatch, + getOrphanTerminalIds, + terminalTabHasReconnectablePty +} from '../terminal-orphan-helpers' + +export type WorktreeTabModelReconciliation = { + patch: Partial + renderableTabCount: number + activeRenderableTabId: string | null +} + +export function projectWorktreeTabModelReconciliation( + state: AppState, + worktreeId: string +): WorktreeTabModelReconciliation { + const unifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] + const groups = state.groupsByWorktree[worktreeId] ?? [] + const runtimeTerminalTabs = state.tabsByWorktree[worktreeId] ?? [] + const unifiedTerminalEntityIds = new Set( + unifiedTabs.filter((tab) => tab.contentType === 'terminal').map((tab) => tab.entityId) + ) + const legacyRuntimeTerminalTabs = runtimeTerminalTabs.filter((tab) => { + if (unifiedTerminalEntityIds.has(tab.id)) { + return false + } + // Why: reconnectable legacy tabs must re-enter the unified model instead of being orphaned. + return terminalTabHasReconnectablePty(state, tab.id, tab.ptyId) + }) + const orphanTerminalIds = getOrphanTerminalIds(state, worktreeId) + const ensuredGroupState = + legacyRuntimeTerminalTabs.length > 0 + ? ensureGroup( + state.groupsByWorktree, + state.activeGroupIdByWorktree, + worktreeId, + state.activeGroupIdByWorktree[worktreeId] + ) + : null + const reconciliationGroup = ensuredGroupState?.group ?? groups[0] ?? null + const restoredLegacyTabs = + reconciliationGroup == null + ? [] + : legacyRuntimeTerminalTabs + .sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt) + .map((tab) => ({ + id: tab.id, + entityId: tab.id, + groupId: reconciliationGroup.id, + worktreeId, + contentType: 'terminal' as const, + label: tab.title, + ...(tab.quickCommandLabel?.trim() + ? { quickCommandLabel: tab.quickCommandLabel.trim() } + : {}), + ...(tab.generatedTitle?.trim() ? { generatedLabel: tab.generatedTitle.trim() } : {}), + ...(tab.aiVaultTitle ? { aiVaultTitle: tab.aiVaultTitle } : {}), + customLabel: tab.customTitle, + color: tab.color, + sortOrder: tab.sortOrder, + createdAt: tab.createdAt + })) + const reconciledUnifiedTabs = + restoredLegacyTabs.length > 0 ? [...unifiedTabs, ...restoredLegacyTabs] : unifiedTabs + const rememberedLegacyActiveTabId = state.activeTabIdByWorktree[worktreeId] + const restoredLegacyTabIds = new Set(restoredLegacyTabs.map((tab) => tab.id)) + const legacyFallbackActiveTabId = + rememberedLegacyActiveTabId && restoredLegacyTabIds.has(rememberedLegacyActiveTabId) + ? rememberedLegacyActiveTabId + : (restoredLegacyTabs[0]?.id ?? null) + const reconciledGroups = + restoredLegacyTabs.length > 0 && reconciliationGroup + ? updateGroup(ensuredGroupState!.groupsByWorktree[worktreeId] ?? [], { + ...reconciliationGroup, + // Why: restore runtime tabs into the active/root group so reattach cannot spawn a duplicate. + activeTabId: reconciliationGroup.activeTabId ?? legacyFallbackActiveTabId, + tabOrder: dedupeTabOrder([ + ...reconciliationGroup.tabOrder, + ...restoredLegacyTabs.map((tab) => tab.id) + ]) + }) + : groups + const liveTerminalIds = new Set( + runtimeTerminalTabs.filter((tab) => !orphanTerminalIds.has(tab.id)).map((tab) => tab.id) + ) + const liveEditorIds = new Set( + state.openFiles.filter((file) => file.worktreeId === worktreeId).map((file) => file.id) + ) + const liveBrowserIds = new Set( + (state.browserTabsByWorktree[worktreeId] ?? []).map((browserTab) => browserTab.id) + ) + + const isRenderableTab = (tab: Tab): boolean => { + if (tab.contentType === 'terminal') { + return liveTerminalIds.has(tab.entityId) + } + if (tab.contentType === 'browser') { + return liveBrowserIds.has(tab.entityId) + } + if (tab.contentType === 'simulator' || tab.contentType === 'agent-session') { + return true + } + return liveEditorIds.has(tab.entityId) + } + + const validTabs = reconciledUnifiedTabs.filter(isRenderableTab) + const validTabIds = new Set(validTabs.map((tab) => tab.id)) + const nextGroupsWithEmpty = reconciledGroups.map((group) => { + const tabOrder = group.tabOrder.filter((tabId) => validTabIds.has(tabId)) + const activeTabId = + group.activeTabId && validTabIds.has(group.activeTabId) + ? group.activeTabId + : (tabOrder[0] ?? null) + const tabOrderUnchanged = + tabOrder.length === group.tabOrder.length && + tabOrder.every((tabId, index) => tabId === group.tabOrder[index]) + const recentTabIds = sanitizeRecentTabIds(group.recentTabIds, tabOrder) + const recentUnchanged = + recentTabIds.length === (group.recentTabIds ?? []).length && + recentTabIds.every((id, index) => id === (group.recentTabIds ?? [])[index]) + return tabOrderUnchanged && activeTabId === group.activeTabId && recentUnchanged + ? group + : { ...group, tabOrder, activeTabId, recentTabIds } + }) + const nextGroups = + validTabs.length > 0 + ? nextGroupsWithEmpty.filter((group) => group.tabOrder.length > 0) + : nextGroupsWithEmpty + const currentActiveGroupId = + state.activeGroupIdByWorktree[worktreeId] ?? + ensuredGroupState?.activeGroupIdByWorktree[worktreeId] + const activeGroupStillExists = nextGroups.some((group) => group.id === currentActiveGroupId) + const nextActiveGroupId = activeGroupStillExists + ? currentActiveGroupId + : (nextGroups.find((group) => group.activeTabId !== null)?.id ?? + nextGroups[0]?.id ?? + currentActiveGroupId) + const groupsChanged = + nextGroups.length !== groups.length || + nextGroups.some((group, index) => group !== groups[index]) + const tabsChanged = validTabs.length !== unifiedTabs.length || restoredLegacyTabs.length > 0 + const activeGroupChanged = nextActiveGroupId !== currentActiveGroupId + const baseNextLayout = + restoredLegacyTabs.length > 0 && reconciliationGroup + ? (state.layoutByWorktree[worktreeId] ?? { type: 'leaf', groupId: reconciliationGroup.id }) + : state.layoutByWorktree[worktreeId] + const validGroupIds = new Set(nextGroups.map((group) => group.id)) + const prunedNextLayout = + baseNextLayout && validGroupIds.size > 0 + ? pruneTabGroupLayoutForGroups(baseNextLayout, validGroupIds) + : baseNextLayout + const nextLayout = + prunedNextLayout ?? (nextGroups[0] ? { type: 'leaf', groupId: nextGroups[0].id } : undefined) + const currentLayout = state.layoutByWorktree[worktreeId] + const layoutChanged = nextLayout !== currentLayout + let patch: Partial = {} + + if ( + tabsChanged || + groupsChanged || + activeGroupChanged || + layoutChanged || + orphanTerminalIds.size > 0 + ) { + const droppedTerminalEntityIds = unifiedTabs.flatMap((tab) => + tab.contentType === 'terminal' && !validTabIds.has(tab.id) ? [tab.entityId] : [] + ) + let nextUnreadTerminalTabs = state.unreadTerminalTabs + if (droppedTerminalEntityIds.length > 0) { + const copy = { ...state.unreadTerminalTabs } + let changed = false + for (const entityId of droppedTerminalEntityIds) { + if (copy[entityId]) { + delete copy[entityId] + changed = true + } + } + if (changed) { + nextUnreadTerminalTabs = copy + } + } + patch = { + unifiedTabsByWorktree: { ...state.unifiedTabsByWorktree, [worktreeId]: validTabs }, + groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: nextGroups }, + activeGroupIdByWorktree: { + ...state.activeGroupIdByWorktree, + [worktreeId]: nextActiveGroupId + }, + ...(nextUnreadTerminalTabs !== state.unreadTerminalTabs + ? { unreadTerminalTabs: nextUnreadTerminalTabs } + : {}), + ...(nextLayout && layoutChanged + ? { + layoutByWorktree: { + ...state.layoutByWorktree, + // Why: restored runtime terminals need a concrete leaf before activation. + [worktreeId]: nextLayout + } + } + : {}), + ...(orphanTerminalIds.size > 0 + ? buildOrphanTerminalCleanupPatch(state, worktreeId, orphanTerminalIds) + : {}) + } + } + + return { + patch, + renderableTabCount: validTabs.length, + activeRenderableTabId: + nextGroups.find((group) => group.id === nextActiveGroupId)?.activeTabId ?? + nextGroups.find((group) => group.activeTabId !== null)?.activeTabId ?? + null + } +} diff --git a/src/renderer/src/store/slices/tabs/tabs-secondary-actions.ts b/src/renderer/src/store/slices/tabs/tabs-secondary-actions.ts new file mode 100644 index 00000000000..4d98c1e2fbd --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-secondary-actions.ts @@ -0,0 +1,94 @@ +import type { TabGroupLayoutNode } from '../../../../../shared/tab-types' +import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' +import { findGroupAndWorktree, findTabAndWorktree } from '../tab-group-state' +import { findSiblingGroupId, updateSplitRatio } from './tabs-layout' + +export function createTabsSecondaryActions( + set: TabsSliceSet, + get: TabsSliceGet +): Pick { + return { + copyUnifiedTabToGroup: (tabId, targetGroupId, init) => { + const foundTab = findTabAndWorktree(get().unifiedTabsByWorktree, tabId) + const foundTarget = findGroupAndWorktree(get().groupsByWorktree, targetGroupId) + if (!foundTab || !foundTarget || foundTab.worktreeId !== foundTarget.worktreeId) { + return null + } + const { tab, worktreeId } = foundTab + return get().createUnifiedTab(worktreeId, tab.contentType, { + entityId: init?.entityId ?? tab.entityId, + executionHostId: tab.executionHostId, + label: init?.label ?? tab.label, + generatedLabel: init?.generatedLabel ?? tab.generatedLabel, + quickCommandLabel: init?.quickCommandLabel ?? tab.quickCommandLabel, + customLabel: init?.customLabel ?? tab.customLabel, + color: init?.color ?? tab.color, + isPinned: init?.isPinned ?? tab.isPinned, + id: init?.id, + targetGroupId + }) + }, + + mergeGroupIntoSibling: (worktreeId, groupId) => { + const state = get() + const groups = state.groupsByWorktree[worktreeId] ?? [] + const sourceGroup = groups.find((candidate) => candidate.id === groupId) + const layout = state.layoutByWorktree[worktreeId] + if (!sourceGroup || !layout || groups.length <= 1) { + return null + } + const targetGroupId = findSiblingGroupId(layout, groupId) + if (!targetGroupId) { + return null + } + + const orderedSourceTabs = (state.unifiedTabsByWorktree[worktreeId] ?? []).filter( + (tab) => tab.groupId === groupId + ) + for (const tabId of sourceGroup.tabOrder) { + const item = orderedSourceTabs.find((tab) => tab.id === tabId) + if (!item) { + continue + } + get().moveUnifiedTabToGroup(item.id, targetGroupId, { recordInteraction: false }) + } + get().closeEmptyGroup(worktreeId, groupId) + get().recordFeatureInteraction?.('terminal-panes') + return targetGroupId + }, + + setTabGroupSplitRatio: (worktreeId, nodePath, ratio) => { + set((state) => { + const currentLayout = state.layoutByWorktree[worktreeId] + if (!currentLayout) { + return state + } + // Why: an unchanged ratio must not mint fresh root state — every store + // subscriber wakes on the new reference (STA-3328). + let targetNode: TabGroupLayoutNode | undefined = currentLayout + for (const segment of nodePath.length > 0 ? nodePath.split('.') : []) { + targetNode = + targetNode && + targetNode.type === 'split' && + (segment === 'first' || segment === 'second') + ? targetNode[segment] + : undefined + } + if (!targetNode || targetNode.type !== 'split' || (targetNode.ratio ?? 0.5) === ratio) { + return state + } + return { + layoutByWorktree: { + ...state.layoutByWorktree, + // Why: split ratios belong to the tab-group model (not transient UI), so persist them for restores and multi-step group ops. + [worktreeId]: updateSplitRatio( + currentLayout, + nodePath.length > 0 ? nodePath.split('.') : [], + ratio + ) + } + } + }) + } + } +} diff --git a/src/renderer/src/store/slices/tabs/tabs-session-actions.ts b/src/renderer/src/store/slices/tabs/tabs-session-actions.ts new file mode 100644 index 00000000000..1c2d22c0ce7 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-session-actions.ts @@ -0,0 +1,78 @@ +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../shared/constants' +import { folderWorkspaceKey } from '../../../../../shared/workspace-scope' +import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' +import { addAdditionalValidWorkspaceKeys } from '@/lib/workspace-session-hydration-keys' +import { + buildValidWorktreeIdsForSessionHydration, + collectPersistedWorktreeIdsForSessionHydration +} from '../degraded-repo-worktree-validity' +import { buildHydratedTabState } from '../tabs-hydration' +import { projectWorktreeTabModelReconciliation } from './tabs-reconciliation' + +function replaceWorkspaceRecordKeys( + current: Record, + hydrated: Record, + workspaceKeys: ReadonlySet +): Record { + return { + ...Object.fromEntries(Object.entries(current).filter(([key]) => !workspaceKeys.has(key))), + ...Object.fromEntries(Object.entries(hydrated).filter(([key]) => workspaceKeys.has(key))) + } +} + +export function createTabsSessionActions( + set: TabsSliceSet, + get: TabsSliceGet +): Pick { + return { + reconcileWorktreeTabModel: (worktreeId) => { + const reconciliation = projectWorktreeTabModelReconciliation(get(), worktreeId) + if (Object.keys(reconciliation.patch).length > 0) { + set(reconciliation.patch) + } + return { + renderableTabCount: reconciliation.renderableTabCount, + activeRenderableTabId: reconciliation.activeRenderableTabId + } + }, + + hydrateTabsSession: (session, options) => { + const state = get() + const persistedWorktreeIds = collectPersistedWorktreeIdsForSessionHydration(session) + const validWorktreeIds = buildValidWorktreeIdsForSessionHydration(state, persistedWorktreeIds) + validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID) + for (const workspace of state.folderWorkspaces) { + validWorktreeIds.add(folderWorkspaceKey(workspace.id)) + } + addAdditionalValidWorkspaceKeys(validWorktreeIds, options) + const hydrated = buildHydratedTabState(session, validWorktreeIds) + if (!options?.replaceWorkspaceKeys) { + set(hydrated) + return + } + const replaceWorkspaceKeys = new Set(options.replaceWorkspaceKeys) + set((current) => ({ + unifiedTabsByWorktree: replaceWorkspaceRecordKeys( + current.unifiedTabsByWorktree, + hydrated.unifiedTabsByWorktree, + replaceWorkspaceKeys + ), + groupsByWorktree: replaceWorkspaceRecordKeys( + current.groupsByWorktree, + hydrated.groupsByWorktree, + replaceWorkspaceKeys + ), + activeGroupIdByWorktree: replaceWorkspaceRecordKeys( + current.activeGroupIdByWorktree, + hydrated.activeGroupIdByWorktree, + replaceWorkspaceKeys + ), + layoutByWorktree: replaceWorkspaceRecordKeys( + current.layoutByWorktree, + hydrated.layoutByWorktree, + replaceWorkspaceKeys + ) + })) + } + } +} diff --git a/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts b/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts new file mode 100644 index 00000000000..456d5892255 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts @@ -0,0 +1,163 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../../types' +import type { + Tab, + TabContentType, + TabGroup, + TabGroupLayoutNode +} from '../../../../../shared/tab-types' +import type { WorkspaceSessionState } from '../../../../../shared/workspace-session-state-types' +import type { WorkspaceSessionHydrationOptions } from '@/lib/workspace-session-hydration-keys' + +export type TabSplitDirection = 'left' | 'right' | 'up' | 'down' + +export type TabsSlice = { + unifiedTabsByWorktree: Record + // Why: id of the tab whose inline title editor should open; shortcut (tab.rename) sets it, the tab clears it on consume. + renamingTabId: string | null + groupsByWorktree: Record + activeGroupIdByWorktree: Record + layoutByWorktree: Record + createUnifiedTab: ( + worktreeId: string, + contentType: TabContentType, + init?: Partial< + Pick< + Tab, + | 'id' + | 'entityId' + | 'executionHostId' + | 'label' + | 'generatedLabel' + | 'quickCommandLabel' + | 'customLabel' + | 'color' + | 'isPreview' + | 'isPinned' + > & { + targetGroupId: string + activate: boolean + recordInteraction: boolean + } + > + ) => Tab + createUnifiedTabInSplit: ( + worktreeId: string, + contentType: TabContentType, + target: { + sourceGroupId: string + splitDirection: TabSplitDirection + }, + init?: Partial< + Pick< + Tab, + | 'id' + | 'entityId' + | 'executionHostId' + | 'label' + | 'generatedLabel' + | 'quickCommandLabel' + | 'customLabel' + | 'color' + | 'isPreview' + | 'isPinned' + > & { + activate: boolean + recordInteraction: boolean + } + > + ) => Tab | null + getTab: (tabId: string) => Tab | null + getActiveTab: (worktreeId: string) => Tab | null + findTabForEntityInGroup: ( + worktreeId: string, + groupId: string, + entityId: string, + contentType?: TabContentType + ) => Tab | null + activateTab: (tabId: string, opts?: { preservePreview?: boolean; worktreeId?: string }) => void + closeUnifiedTab: ( + tabId: string, + opts?: { + /** Keep the worktree selected even if this empties it — for closes the user did not ask for. */ + preserveWorktreeSelection?: boolean + recordInteraction?: boolean + terminalRetirementHandled?: boolean + } + ) => { closedTabId: string; wasLastTab: boolean; worktreeId: string } | null + reorderUnifiedTabs: ( + groupId: string, + tabIds: string[], + opts?: { recordInteraction?: boolean } + ) => void + setTabLabel: (tabId: string, label: string) => void + /** Set a tab's view mode (terminal vs native chat). Patches only that tab. */ + setTabViewMode: (tabId: string, mode: 'terminal' | 'chat') => void + /** Flip a tab between terminal and native-chat renderings; the live TerminalPane stays mounted. */ + toggleTabViewMode: (tabId: string) => void + setTabCustomLabel: ( + tabId: string, + label: string | null, + opts?: { recordInteraction?: boolean } + ) => void + setUnifiedTabColor: (tabId: string, color: string | null) => void + setRenamingTabId: (tabId: string | null) => void + pinTab: (tabId: string) => void + unpinTab: (tabId: string) => void + closeOtherTabs: (tabId: string) => string[] + closeTabsToRight: (tabId: string) => string[] + closeTabsToLeft: (tabId: string) => string[] + ensureWorktreeRootGroup: (worktreeId: string) => string + focusGroup: (worktreeId: string, groupId: string) => void + closeEmptyGroup: (worktreeId: string, groupId: string) => boolean + createEmptySplitGroup: ( + worktreeId: string, + sourceGroupId: string, + direction: TabSplitDirection, + opts?: { activate?: boolean } + ) => string | null + moveUnifiedTabToGroup: ( + tabId: string, + targetGroupId: string, + opts?: { index?: number; activate?: boolean; recordInteraction?: boolean } + ) => boolean + dropUnifiedTab: ( + tabId: string, + target: { + groupId: string + index?: number + splitDirection?: TabSplitDirection + } + ) => boolean + copyUnifiedTabToGroup: ( + tabId: string, + targetGroupId: string, + init?: Partial< + Pick< + Tab, + | 'id' + | 'entityId' + | 'label' + | 'generatedLabel' + | 'quickCommandLabel' + | 'customLabel' + | 'color' + | 'isPinned' + > + > + ) => Tab | null + mergeGroupIntoSibling: (worktreeId: string, groupId: string) => string | null + setTabGroupSplitRatio: (worktreeId: string, nodePath: string, ratio: number) => void + reconcileWorktreeTabModel: (worktreeId: string) => { + renderableTabCount: number + activeRenderableTabId: string | null + } + hydrateTabsSession: ( + session: WorkspaceSessionState, + options?: WorkspaceSessionHydrationOptions + ) => void +} + +type TabsStateCreator = StateCreator +export type TabsSliceSet = Parameters[0] +export type TabsSliceGet = Parameters[1] diff --git a/src/renderer/src/store/slices/tabs/tabs-surface.ts b/src/renderer/src/store/slices/tabs/tabs-surface.ts new file mode 100644 index 00000000000..b870ca7bb32 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-surface.ts @@ -0,0 +1,197 @@ +import type { AppState } from '../../types' +import { toVisibleTabType } from '../../../../../shared/tab-types' +import type { WorkspaceVisibleTabType } from '../../../../../shared/tab-types' + +export function deriveActiveSurfaceForWorktree( + state: Pick< + AppState, + | 'activeBrowserTabIdByWorktree' + | 'activeFileIdByWorktree' + | 'activeGroupIdByWorktree' + | 'activeTabIdByWorktree' + | 'browserTabsByWorktree' + | 'groupsByWorktree' + | 'layoutByWorktree' + | 'openFiles' + | 'tabsByWorktree' + | 'unifiedTabsByWorktree' + >, + worktreeId: string, + preferredGroupId?: string | null +): { + activeBrowserTabId: string | null + activeFileId: string | null + activeTabId: string | null + activeTabType: WorkspaceVisibleTabType +} { + const groups = state.groupsByWorktree[worktreeId] ?? [] + const activeGroupId = preferredGroupId ?? state.activeGroupIdByWorktree[worktreeId] ?? null + const activeGroup = + (activeGroupId ? groups.find((group) => group.id === activeGroupId) : null) ?? groups[0] ?? null + const activeUnifiedTab = + activeGroup?.activeTabId != null + ? ((state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (tab) => tab.id === activeGroup.activeTabId && tab.groupId === activeGroup.id + ) ?? null) + : null + const restoredFileId = state.activeFileIdByWorktree[worktreeId] ?? null + const restoredBrowserTabId = state.activeBrowserTabIdByWorktree[worktreeId] ?? null + const restoredTerminalTabId = state.activeTabIdByWorktree[worktreeId] ?? null + const browserTabs = state.browserTabsByWorktree[worktreeId] ?? [] + const terminalTabs = state.tabsByWorktree[worktreeId] ?? [] + const fileStillOpen = restoredFileId + ? state.openFiles.some((file) => file.id === restoredFileId && file.worktreeId === worktreeId) + : false + const browserTabStillOpen = restoredBrowserTabId + ? browserTabs.some((tab) => tab.id === restoredBrowserTabId) + : false + const terminalTabStillExists = restoredTerminalTabId + ? terminalTabs.some((tab) => tab.id === restoredTerminalTabId) + : false + const hasGroupOwnedSurface = groups.length > 0 || Boolean(state.layoutByWorktree[worktreeId]) + + let activeFileId: string | null + let activeBrowserTabId: string | null + let activeTabType: WorkspaceVisibleTabType + + if (activeUnifiedTab) { + activeFileId = + activeUnifiedTab.contentType === 'editor' || + activeUnifiedTab.contentType === 'diff' || + activeUnifiedTab.contentType === 'conflict-review' || + activeUnifiedTab.contentType === 'check-details' + ? activeUnifiedTab.entityId + : fileStillOpen + ? restoredFileId + : null + activeBrowserTabId = + activeUnifiedTab.contentType === 'browser' + ? activeUnifiedTab.entityId + : browserTabStillOpen + ? restoredBrowserTabId + : (browserTabs[0]?.id ?? null) + activeTabType = toVisibleTabType(activeUnifiedTab.contentType) + } else if (hasGroupOwnedSurface) { + activeFileId = fileStillOpen ? restoredFileId : null + activeBrowserTabId = browserTabStillOpen ? restoredBrowserTabId : (browserTabs[0]?.id ?? null) + // Why: focusing an empty split should target its default terminal area, not the previously active browser/editor in another group. + activeTabType = 'terminal' + } else if (browserTabStillOpen) { + activeFileId = fileStillOpen ? restoredFileId : null + activeBrowserTabId = restoredBrowserTabId + activeTabType = 'browser' + } else if (fileStillOpen) { + activeFileId = restoredFileId + activeBrowserTabId = browserTabs[0]?.id ?? null + activeTabType = 'editor' + } else { + const fallbackFile = state.openFiles.find((file) => file.worktreeId === worktreeId) ?? null + const fallbackBrowserTab = browserTabs[0] ?? null + activeFileId = fallbackFile?.id ?? null + activeBrowserTabId = fallbackBrowserTab?.id ?? null + activeTabType = fallbackFile ? 'editor' : fallbackBrowserTab ? 'browser' : 'terminal' + } + + return { + activeBrowserTabId, + activeFileId, + activeTabId: + activeUnifiedTab?.contentType === 'terminal' + ? activeUnifiedTab.entityId + : terminalTabStillExists + ? restoredTerminalTabId + : (terminalTabs[0]?.id ?? null), + activeTabType + } +} + +export function buildActiveSurfacePatch( + state: Pick< + AppState, + | 'activeBrowserTabIdByWorktree' + | 'activeFileIdByWorktree' + | 'activeGroupIdByWorktree' + | 'activeTabIdByWorktree' + | 'activeTabTypeByWorktree' + | 'browserTabsByWorktree' + | 'groupsByWorktree' + | 'layoutByWorktree' + | 'openFiles' + | 'tabsByWorktree' + | 'unifiedTabsByWorktree' + >, + worktreeId: string, + preferredGroupId?: string | null +): Pick< + AppState, + | 'activeBrowserTabId' + | 'activeBrowserTabIdByWorktree' + | 'activeFileId' + | 'activeFileIdByWorktree' + | 'activeTabId' + | 'activeTabIdByWorktree' + | 'activeTabType' + | 'activeTabTypeByWorktree' +> { + const derived = deriveActiveSurfaceForWorktree(state, worktreeId, preferredGroupId) + return { + activeBrowserTabId: derived.activeBrowserTabId, + activeBrowserTabIdByWorktree: { + ...state.activeBrowserTabIdByWorktree, + [worktreeId]: derived.activeBrowserTabId + }, + activeFileId: derived.activeFileId, + activeFileIdByWorktree: { + ...state.activeFileIdByWorktree, + [worktreeId]: derived.activeFileId + }, + activeTabId: derived.activeTabId, + activeTabIdByWorktree: { + ...state.activeTabIdByWorktree, + [worktreeId]: derived.activeTabId + }, + activeTabType: derived.activeTabType, + activeTabTypeByWorktree: { + ...state.activeTabTypeByWorktree, + [worktreeId]: derived.activeTabType + } + } +} + +export function activeSurfacePatchMatchesState( + state: Pick< + AppState, + | 'activeBrowserTabId' + | 'activeBrowserTabIdByWorktree' + | 'activeFileId' + | 'activeFileIdByWorktree' + | 'activeTabId' + | 'activeTabIdByWorktree' + | 'activeTabType' + | 'activeTabTypeByWorktree' + >, + worktreeId: string, + patch: Pick< + AppState, + | 'activeBrowserTabId' + | 'activeBrowserTabIdByWorktree' + | 'activeFileId' + | 'activeFileIdByWorktree' + | 'activeTabId' + | 'activeTabIdByWorktree' + | 'activeTabType' + | 'activeTabTypeByWorktree' + > +): boolean { + return ( + state.activeBrowserTabId === patch.activeBrowserTabId && + state.activeBrowserTabIdByWorktree[worktreeId] === + patch.activeBrowserTabIdByWorktree[worktreeId] && + state.activeFileId === patch.activeFileId && + state.activeFileIdByWorktree[worktreeId] === patch.activeFileIdByWorktree[worktreeId] && + state.activeTabId === patch.activeTabId && + state.activeTabIdByWorktree[worktreeId] === patch.activeTabIdByWorktree[worktreeId] && + state.activeTabType === patch.activeTabType && + state.activeTabTypeByWorktree[worktreeId] === patch.activeTabTypeByWorktree[worktreeId] + ) +} diff --git a/src/renderer/src/store/slices/tabs/tabs-tab-order.ts b/src/renderer/src/store/slices/tabs/tabs-tab-order.ts new file mode 100644 index 00000000000..236465ba6d1 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-tab-order.ts @@ -0,0 +1,41 @@ +import type { Tab } from '../../../../../shared/tab-types' +import { dedupeTabOrder } from '../tab-group-state' + +export function partitionPinnedTabOrder( + tabOrder: string[], + tabs: Tab[], + movingTabId: string +): string[] { + const tabById = new Map(tabs.map((tab) => [tab.id, tab])) + const withoutMoving = dedupeTabOrder(tabOrder).filter((id) => id !== movingTabId) + const pinnedIds = withoutMoving.filter((id) => tabById.get(id)?.isPinned) + const unpinnedIds = withoutMoving.filter((id) => !tabById.get(id)?.isPinned) + return [...pinnedIds, movingTabId, ...unpinnedIds] +} + +export function applyTabOrderSortValues(tabs: Tab[], tabOrder: string[]): Tab[] { + const orderMap = new Map(tabOrder.map((id, index) => [id, index])) + return tabs.map((tab) => { + const sortOrder = orderMap.get(tab.id) + return sortOrder === undefined ? tab : { ...tab, sortOrder } + }) +} + +export function isReplaceablePreviewContentType(contentType: Tab['contentType']): boolean { + return ( + contentType === 'editor' || + contentType === 'diff' || + contentType === 'conflict-review' || + contentType === 'check-details' + ) +} + +export function canReplacePreviewContentType( + incomingContentType: Tab['contentType'], + existingContentType: Tab['contentType'] +): boolean { + if (isReplaceablePreviewContentType(incomingContentType)) { + return isReplaceablePreviewContentType(existingContentType) + } + return existingContentType === incomingContentType +} diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index c2a13d41b93..c62d1d64d24 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -1,2910 +1,43 @@ -/* eslint-disable max-lines */ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import { normalizeRightSidebarRoute } from '../right-sidebar-route' -import { settleEvictedModalData } from './modal-slot-dismissal' -import { - findPrevLiveNonTaskStackHistoryIndex, - rewindHistoryIndexPastView -} from './worktree-nav-history' -import type { GitHubWorkItem } from '../../../../shared/github/work-item-types' -import type { JiraIssue } from '../../../../shared/jira-types' -import type { LinearIssue } from '../../../../shared/linear/issue-types' -import type { PersistedTrustedOrcaHooks } from '../../../../shared/orca-yaml-hook-types' -import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types' -import type { CustomPet } from '../../../../shared/pet-types' -import type { TaskProvider } from '../../../../shared/task-providers' -import type { TuiAgent } from '../../../../shared/tui-agent' -import type { - AgentActivityDisplayMode, - ManualRepoOrderEntry, - ProjectOrderBy, - StatusBarItem, - TaskResumeState, - TaskViewPresetId, - TopLevelView, - VisibleWorkspaceHostIds, - WorkspaceHostOrder, - WorkspaceHostScope, - WorktreeCardMode, - WorktreeCardProperty -} from '../../../../shared/ui-chrome-types' -import type { ChangelogData, UpdateStatus } from '../../../../shared/update-status-types' -import type { WorkspaceStatusDefinition } from '../../../../shared/worktree/types' -import { - applyManualRepoOrder, - normalizeManualRepoOrder -} from '../../../../shared/manual-repo-order' -import { isTopLevelView } from '../../../../shared/top-level-view' -import { isReleaseChannel, type ReleaseChannel } from '../../../../shared/release-channel' -import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display' -import { - DEFAULT_USAGE_PERCENTAGE_DISPLAY, - normalizeUsagePercentageDisplay -} from '../../../../shared/usage-percentage-display' -import { - DEFAULT_STATUS_BAR_USAGE_MODE, - normalizeStatusBarUsageMode, - type StatusBarUsageMode -} from '../../../../shared/status-bar-usage-mode' -import type { GitLabWorkItem } from '../../../../shared/gitlab-types' -import type { LaunchSource } from '../../../../shared/telemetry-events' -import type { TaskSourceContext } from '../../../../shared/task-source-context' -import { PET_SIZE_DEFAULT, PET_SIZE_MAX, PET_SIZE_MIN } from '../../../../shared/pet-types' -import { - WORKSPACE_CLEANUP_CLASSIFIER_VERSION, - type WorkspaceCleanupDismissal -} from '../../../../shared/workspace-cleanup' -import { normalizeWorkspaceCleanupBrowseState } from '../../../../shared/workspace-cleanup-browse-state' -import { normalizeFeatureTipIds, type FeatureTipId } from '../../../../shared/feature-tips' -import { - hasFeatureInteraction, - normalizeFeatureInteractions, - type FeatureInteractionId, - type FeatureInteractionState -} from '../../../../shared/feature-interactions' -import { - getContextualTour, - normalizeContextualTourIds, - type ContextualTourId -} from '../../../../shared/contextual-tours' -import { PER_REPO_FETCH_LIMIT } from '../../../../shared/work-items' -import { - normalizeVisibleTaskProviders, - restoreAvailableDefaultTaskProvider, - resolveVisibleTaskProvider -} from '../../../../shared/task-providers' -import { - DEFAULT_HIDE_SLEEPING_WORKSPACES, - DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE, - DEFAULT_SHOW_SLEEPING_WORKSPACES, - DEFAULT_STATUS_BAR_ITEMS, - DEFAULT_WORKTREE_CARD_PROPERTIES, - getWorktreeCardModeUpdates, - normalizeAgentActivityDisplayMode, - normalizeWorktreeCardProperties -} from '../../../../shared/constants' -import { - DEFAULT_BROWSER_PAGE_ZOOM_LEVEL, - normalizeBrowserPageZoomLevel -} from '../../../../shared/browser-page-zoom' -import { persistedUIValuesEqual } from '../../../../shared/persisted-ui-equality' -import { - ALL_AUTOMATION_HOSTS_FILTER, - parsePersistedAutomationHostFilter, - toPersistedAutomationHostFilter, - type AutomationHostFilter -} from '../../../../shared/automation-host-filter' -import { - normalizeExecutionHostOrder, - normalizeExecutionHostScope, - normalizeVisibleExecutionHostIds, - type ExecutionHostId -} from '../../../../shared/execution-host' -import { - WORKSPACE_BOARD_COLUMN_WIDTH_DEFAULT, - clampWorkspaceBoardColumnWidth, - clampWorkspaceBoardOpacity, - cloneDefaultWorkspaceStatuses, - normalizeWorkspaceStatuses -} from '../../../../shared/workspace-statuses' -import { clampMarkdownTocPanelWidth } from '../../../../shared/markdown-toc-panel-width' -import { clampCombinedDiffFileTreeWidth } from '../../../../shared/combined-diff-file-tree-width' -import { normalizeKagiSessionLink } from '../../../../shared/browser-url' -import type { OrcaHookScriptKind } from '../../lib/orca-hook-trust' -import { - isSettingsNavigationTarget, - type SettingsNavigationTarget -} from '@/lib/settings-navigation-types' -import { - filterSetupScriptPromptDismissalsToValidRepos, - getSetupScriptPromptDismissalKey, - sanitizeSetupScriptPromptDismissals -} from '../../lib/setup-script-prompt' -import { DEFAULT_PET_ID, isBundledPetId } from '../../components/pet/pet-models' -import { revokeCustomPetBlobUrl } from '../../components/pet/pet-blob-cache' -import { isGitRepoKind } from '../../../../shared/repo-kind' -import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports' -import { - getContextualTourRequestDecision, - hasContextualTourTarget, - getNextVisibleContextualTourStepIndex, - getPreviousVisibleContextualTourStepIndex -} from '../../components/contextual-tours/contextual-tour-gate' -import { agentKindForAgentType, formatAgentTypeLabel } from '../../lib/agent-status' -import { - deriveRunningAgentSendTargets, - resolveRunningAgentSendTarget -} from '../../lib/running-agent-targets' -import { buildAgentNotificationId } from '../../../../shared/agent-notification-id' -import { parsePaneKey } from '../../../../shared/stable-pane-id' -import { translate } from '@/i18n/i18n' -import { getRepoHostIdentity } from './repo-host-identity' -import { - capturePersistedUIWriteBaseline, - diffPersistedUIWriteFields, - type PersistedUIWriteBaseline -} from './persisted-ui-write-baseline' - -export type PendingSidebarWorktreeReveal = { - worktreeId: string - executionHostId?: ExecutionHostId - behavior: 'auto' | 'smooth' - highlight?: boolean - beginRename?: boolean -} - -export type PendingSidebarRowReveal = { - rowKey: string - behavior: 'auto' | 'smooth' - highlight?: boolean -} - -export type AgentSendPopoverTargetMode = { - id: string - instanceId: string - worktreeId: string - source: 'diff-notes' | 'browser-annotations' - prompt: string - label: string - launchSource: LaunchSource - eligiblePaneKeys: string[] - disabledPaneKeys: Record - status: 'open' | 'sending' | 'error' - sendingPaneKey?: string - error?: string - onPromptDelivered?: () => void -} - -export type OpenAgentSendPopoverTargetModeArgs = { - id: string - worktreeId: string - source: AgentSendPopoverTargetMode['source'] - prompt: string - label: string - launchSource: LaunchSource - onPromptDelivered?: () => void -} - -function mergeFeatureInteractionState( - current: FeatureInteractionState, - incoming: PersistedUIState['featureInteractions'] -): FeatureInteractionState { - const currentNormalized = normalizeFeatureInteractions(current) - const incomingNormalized = normalizeFeatureInteractions(incoming) - const merged: FeatureInteractionState = { ...currentNormalized } - for (const [id, incomingRecord] of Object.entries(incomingNormalized)) { - const featureId = id as FeatureInteractionId - const currentRecord = currentNormalized[featureId] - merged[featureId] = currentRecord - ? { - firstInteractedAt: Math.min( - currentRecord.firstInteractedAt, - incomingRecord.firstInteractedAt - ), - interactionCount: Math.max( - currentRecord.interactionCount, - incomingRecord.interactionCount - ) - } - : incomingRecord - } - return merged -} - -function mergeContextualTourSeenIds( - current: readonly ContextualTourId[], - incoming: PersistedUIState['contextualToursSeenIds'] -): ContextualTourId[] { - const merged = new Set(normalizeContextualTourIds(current)) - for (const id of normalizeContextualTourIds(incoming)) { - merged.add(id) - } - return [...merged] -} - -function getContextualTourProgressionForFeatureInteraction( - state: AppState, - id: FeatureInteractionId -): 'advance' | 'complete' | 'reveal-sidebar-and-advance' | null { - if (!state.activeContextualTourId) { - return null - } - const tour = getContextualTour(state.activeContextualTourId) - const step = tour.steps[state.activeContextualTourStepIndex] - if (step?.advanceOnFeatureInteraction !== id) { - return null - } - const nextStepIndex = getNextVisibleContextualTourStepIndex({ - tour, - currentStepIndex: state.activeContextualTourStepIndex, - targetExists: hasContextualTourTarget - }) - if (nextStepIndex !== null) { - return 'advance' - } - if ( - state.activeContextualTourId === 'workspace-agent-sessions' && - state.activeContextualTourStepIndex === 0 && - id === 'terminal-pane-split' && - !state.sidebarOpen - ) { - return 'reveal-sidebar-and-advance' - } - return 'complete' -} - -function clampPetSize(size: number): number { - if (!Number.isFinite(size)) { - return PET_SIZE_DEFAULT - } - return Math.max(PET_SIZE_MIN, Math.min(PET_SIZE_MAX, Math.round(size))) -} - -// Why: local copy of TaskPage's preset→query mapping avoids a store ↔ lib circular import while warming the exact cache key. -function presetToQuery(presetId: TaskViewPresetId | null): string { - switch (presetId) { - case 'all': - case 'issues': - return 'is:issue is:open' - case 'my-issues': - return 'assignee:@me is:issue is:open' - case 'prs': - return 'is:pr is:open' - case 'review': - return 'review-requested:@me is:pr is:open' - case 'my-prs': - return 'author:@me is:pr is:open' - case null: - return 'is:issue is:open' - } -} - -// Why: migrate legacy memory+sessions ids → resource-usage; keep unknown ids so downgrade→upgrade can't strip a newer build's ids. -function migrateStatusBarItems(items: readonly string[] | undefined): StatusBarItem[] { - const source = items ?? DEFAULT_STATUS_BAR_ITEMS - const out: string[] = [] - for (const id of source) { - const mapped = id === 'memory' || id === 'sessions' ? 'resource-usage' : id - if (!out.includes(mapped)) { - out.push(mapped) - } - } - return out as StatusBarItem[] -} - -const DEFAULT_ON_PORTS_STATUS_BAR_ITEM: StatusBarItem = 'ports' -const DEFAULT_ON_KIMI_STATUS_BAR_ITEM: StatusBarItem = 'kimi' -const DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM: StatusBarItem = 'minimax' -const DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM: StatusBarItem = 'antigravity' -const DEFAULT_ON_GROK_STATUS_BAR_ITEM: StatusBarItem = 'grok' - -function normalizeHydratedVisibleWorkspaceHostIds(ui: PersistedUIState): VisibleWorkspaceHostIds { - const visibleHostIds = normalizeVisibleExecutionHostIds(ui.visibleWorkspaceHostIds) - if (visibleHostIds) { - return visibleHostIds - } - const legacyScope = normalizeExecutionHostScope(ui.workspaceHostScope) - return legacyScope === 'all' ? null : [legacyScope] -} - -const MIN_SIDEBAR_WIDTH = 220 -const MAX_LEFT_SIDEBAR_WIDTH = 500 -// Why: right-sidebar resize is window-relative, so widths can far exceed 500px on wide displays; this ceiling is only a corruption safety net. -const MAX_RIGHT_SIDEBAR_WIDTH = 4000 -const LINEAR_TASK_PREFETCH_LIMIT = 36 -// Why: bound disk growth across hard quits (crash paths leave acks pinned); mirrors HYDRATE_MAX_AGE_MS in agent-hooks/server.ts. -const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 -const VALID_TASK_PRESETS = new Set([ - 'all', - 'issues', - 'review', - 'my-issues', - 'my-prs', - 'prs' -]) -const VALID_LINEAR_PRESETS = new Set>([ - 'assigned', - 'created', - 'all', - 'completed' -]) -const VALID_LINEAR_MODES = new Set>([ - 'issues', - 'projects', - 'views', - 'in-orca' -]) -const VALID_JIRA_PRESETS = new Set>([ - 'assigned', - 'reported', - 'all', - 'done' -]) - -function resolvePaneKeyWorktreeIdFromTabs(state: AppState, paneKey: string): string | null { - const parsed = parsePaneKey(paneKey) - if (!parsed) { - return null - } - for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree ?? {})) { - if (tabs.some((tab) => tab.id === parsed.tabId)) { - return worktreeId - } - } - return null -} - -function collectAcknowledgedAgentNotificationId({ - ids, - worktreeId, - paneKey, - stateStartedAt, - previousAckAt -}: { - ids: Set - worktreeId: string | null | undefined - paneKey: string - stateStartedAt: number | null | undefined - previousAckAt: number -}): void { - if (typeof stateStartedAt !== 'number' || previousAckAt >= stateStartedAt) { - return - } - const id = buildAgentNotificationId({ worktreeId, paneKey, stateStartedAt }) - if (id) { - ids.add(id) - } -} - -function usableTimestamp(value: unknown): number { - return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0 -} - -/** Newest turn timestamp an unread check can compare against for one agent row. */ -function latestAgentTurnTimestamp(entry: { - stateStartedAt?: number - stateHistory?: { startedAt?: number }[] -}): number { - let latest = usableTimestamp(entry.stateStartedAt) - // Why history too: Activity renders one event per stateHistory entry, each with its own unread check. - for (const history of entry.stateHistory ?? []) { - latest = Math.max(latest, usableTimestamp(history.startedAt)) - } - return latest -} - -function isPlainPersistedRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} - -function sanitizePersistedRepoIds(value: unknown): string[] { - if (!Array.isArray(value)) { - return [] - } - return value.filter((repoId): repoId is string => typeof repoId === 'string') -} - -function sanitizeTrustedOrcaHooks(trust: unknown): PersistedTrustedOrcaHooks { - if (!isPlainPersistedRecord(trust)) { - return {} - } - const next: PersistedTrustedOrcaHooks = {} - for (const [repoId, entry] of Object.entries(trust)) { - if (!isSafePersistedRecordKey(repoId) || !isPlainPersistedRecord(entry)) { - continue - } - next[repoId] = entry as PersistedTrustedOrcaHooks[string] - } - return next -} - -function filterTrustedOrcaHooksToValidRepos( - trust: unknown, - validRepoIds: Set -): PersistedTrustedOrcaHooks { - const sanitized = sanitizeTrustedOrcaHooks(trust) - const next: PersistedTrustedOrcaHooks = {} - for (const [repoId, entry] of Object.entries(sanitized)) { - if (validRepoIds.has(repoId)) { - next[repoId] = entry - } - } - return next -} - -function hydrateTrustedOrcaHooks( - trust: unknown, - validRepoIds: Set -): PersistedTrustedOrcaHooks { - const sanitized = sanitizeTrustedOrcaHooks(trust) - if (validRepoIds.size === 0) { - return sanitized - } - return filterTrustedOrcaHooksToValidRepos(sanitized, validRepoIds) -} - -function isSafePersistedRecordKey(key: string): boolean { - return key !== '__proto__' && key !== 'constructor' && key !== 'prototype' -} - -function sanitizeShowDotfilesByWorktree(value: unknown): Record { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - return {} - } - const out: Record = {} - for (const [worktreeId, showDotfiles] of Object.entries(value as Record)) { - if (!worktreeId || !isSafePersistedRecordKey(worktreeId) || typeof showDotfiles !== 'boolean') { - continue - } - out[worktreeId] = showDotfiles - } - return out -} - -function sanitizePersistedSidebarWidth(width: unknown, fallback: number, maxWidth: number): number { - if (typeof width !== 'number' || !Number.isFinite(width)) { - return fallback - } - return Math.min(maxWidth, Math.max(MIN_SIDEBAR_WIDTH, width)) -} - -// Why: persisted JSON may be tampered/corrupt — reject arrays, prototype-pollution keys, and non-finite values; drop past-TTL entries. -function sanitizeAcknowledgedAgentsByPaneKey(value: unknown): Record { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - return {} - } - const cutoff = Date.now() - HYDRATE_MAX_AGE_MS - const out: Record = {} - for (const [key, ackAt] of Object.entries(value as Record)) { - if (typeof key !== 'string' || !isSafePersistedRecordKey(key)) { - continue - } - if (typeof ackAt !== 'number' || !Number.isFinite(ackAt) || ackAt <= 0) { - continue - } - if (ackAt < cutoff) { - continue - } - out[key] = ackAt - } - return out -} - -function sanitizeWorkspaceCleanupDismissals( - value: unknown -): Record { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - return {} - } - const out: Record = {} - for (const [key, raw] of Object.entries(value as Record)) { - if (key === '__proto__' || key === 'constructor' || key === 'prototype') { - continue - } - if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { - continue - } - const input = raw as Record - if ( - typeof input.worktreeId !== 'string' || - typeof input.dismissedAt !== 'number' || - !Number.isFinite(input.dismissedAt) || - typeof input.fingerprint !== 'string' || - input.classifierVersion !== WORKSPACE_CLEANUP_CLASSIFIER_VERSION - ) { - continue - } - out[key] = { - worktreeId: input.worktreeId, - dismissedAt: input.dismissedAt, - fingerprint: input.fingerprint, - classifierVersion: input.classifierVersion - } - } - return out -} - -function hydratedUIPartialMatchesState(state: AppState, hydrated: Partial): boolean { - return Object.entries(hydrated).every(([key, value]) => - persistedUIValuesEqual(state[key as keyof AppState], value) - ) -} - -function sanitizeHydratedActiveView( - value: PersistedUIState['activeView'], - experimentalActivityEnabled: boolean -): TopLevelView { - // Why: older data (pre-activeView) or a view a different build doesn't have - // falls back to terminal rather than rendering nothing. - if (!isTopLevelView(value)) { - return 'terminal' - } - // Why: activity is hidden when its setting is off, so gate only it (mobile/automations stay functional when hidden). - if (value === 'activity' && !experimentalActivityEnabled) { - return 'terminal' - } - return value -} - -let agentSendTargetModeInstanceCounter = 0 - -function createAgentSendTargetModeInstanceId(): string { - agentSendTargetModeInstanceCounter += 1 - return `${Date.now()}:${agentSendTargetModeInstanceCounter}` -} - -function sanitizeTaskResumeState(value: unknown): TaskResumeState | undefined { - if (!value || typeof value !== 'object') { - return undefined - } - const input = value as Record - const next: TaskResumeState = {} - - if (input.githubMode === 'items' || input.githubMode === 'project') { - next.githubMode = input.githubMode - } - if (input.githubItemsPreset === null) { - next.githubItemsPreset = null - } else if (typeof input.githubItemsPreset === 'string') { - if (VALID_TASK_PRESETS.has(input.githubItemsPreset as TaskViewPresetId)) { - next.githubItemsPreset = input.githubItemsPreset as TaskViewPresetId - } - } - if (typeof input.githubItemsQuery === 'string') { - next.githubItemsQuery = input.githubItemsQuery - } - if ( - typeof input.linearPreset === 'string' && - VALID_LINEAR_PRESETS.has(input.linearPreset as NonNullable) - ) { - next.linearPreset = input.linearPreset as NonNullable - } - if ( - typeof input.linearMode === 'string' && - VALID_LINEAR_MODES.has(input.linearMode as NonNullable) - ) { - next.linearMode = input.linearMode as NonNullable - } - if (typeof input.linearQuery === 'string') { - next.linearQuery = input.linearQuery - } - if (input.linearContext && typeof input.linearContext === 'object') { - const context = input.linearContext as Record - if ( - (context.kind === 'project' || context.kind === 'view') && - typeof context.id === 'string' && - context.id.trim() && - typeof context.workspaceId === 'string' && - context.workspaceId.trim() && - context.workspaceId !== 'all' - ) { - next.linearContext = { - kind: context.kind, - id: context.id, - workspaceId: context.workspaceId, - model: context.model === 'issue' || context.model === 'project' ? context.model : undefined - } - } - } - if ( - typeof input.jiraPreset === 'string' && - VALID_JIRA_PRESETS.has(input.jiraPreset as NonNullable) - ) { - next.jiraPreset = input.jiraPreset as NonNullable - } - if (typeof input.jiraQuery === 'string') { - next.jiraQuery = input.jiraQuery - } - - return Object.keys(next).length > 0 ? next : undefined -} - -export type UISlice = { - sidebarOpen: boolean - sidebarWidth: number - toggleSidebar: () => void - setSidebarOpen: (open: boolean) => void - setSidebarWidth: (width: number) => void - agentSendPopoverTargetMode: AgentSendPopoverTargetMode | null - openAgentSendPopoverTargetMode: (args: OpenAgentSendPopoverTargetModeArgs) => void - closeAgentSendPopoverTargetMode: (id?: string, instanceId?: string) => void - sendPromptToSidebarAgentTarget: (paneKey: string) => Promise - /** Bumped to ask the active worktree's Source Control notes send menu to open (keyboard shortcut). `issuedAt` bounds staleness so a request the menu never consumed can't reopen it much later. */ - diffNotesSendMenuOpenRequest: { worktreeId: string; nonce: number; issuedAt: number } | null - /** Reveal Source Control and request its notes send menu open; returns false (no-op) when the active worktree has no unsent notes. */ - openDiffNotesSendMenuForActiveWorktree: () => boolean - consumeDiffNotesSendMenuOpenRequest: (worktreeId: string) => void - /** Per-agent "I've looked at this" timestamps (paneKey → ts). A row is unvisited when no ack exists or stateStartedAt is newer than the last ack. Persisted so visited rows don't return bold on relaunch. */ - acknowledgedAgentsByPaneKey: Record - acknowledgeAgents: (paneKeys: string[]) => void - unacknowledgeAgents: (paneKeys: string[]) => void - activeView: TopLevelView - previousViewBeforeTasks: - | 'terminal' - | 'settings' - | 'activity' - | 'automations' - | 'space' - | 'skills' - | 'artifacts' - | 'mobile' - previousViewBeforeSettings: - | 'terminal' - | 'tasks' - | 'activity' - | 'automations' - | 'space' - | 'skills' - | 'artifacts' - | 'mobile' - previousViewBeforeActivity: - | 'terminal' - | 'settings' - | 'tasks' - | 'automations' - | 'space' - | 'skills' - | 'artifacts' - | 'mobile' - previousViewBeforeAutomations: - | 'terminal' - | 'settings' - | 'tasks' - | 'activity' - | 'space' - | 'skills' - | 'artifacts' - | 'mobile' - previousViewBeforeSpace: - | 'terminal' - | 'settings' - | 'tasks' - | 'activity' - | 'automations' - | 'skills' - | 'artifacts' - | 'mobile' - previousViewBeforeSkills: - | 'terminal' - | 'settings' - | 'tasks' - | 'activity' - | 'automations' - | 'space' - | 'artifacts' - | 'mobile' - previousViewBeforeMobile: - | 'terminal' - | 'settings' - | 'tasks' - | 'activity' - | 'automations' - | 'space' - | 'skills' - | 'artifacts' - previousViewBeforeArtifacts: - | 'terminal' - | 'settings' - | 'tasks' - | 'activity' - | 'automations' - | 'space' - | 'skills' - | 'mobile' - setActiveView: (view: UISlice['activeView']) => void - taskPageData: { - preselectedRepoId?: string - prefilledName?: string - taskSource?: TaskProvider - openGitHubWorkItem?: GitHubWorkItem - openGitHubSourceContext?: TaskSourceContext | null - openGitHubInitialTab?: 'conversation' | 'checks' | 'files' - openGitLabWorkItem?: GitLabWorkItem - openGitLabSourceContext?: TaskSourceContext | null - openLinearIssue?: LinearIssue - openLinearSourceContext?: TaskSourceContext | null - openJiraIssue?: JiraIssue - openJiraSourceContext?: TaskSourceContext | null - } - taskResumeState: TaskResumeState | undefined - setTaskResumeState: (updates: Partial) => void - taskListPosition: { contextKey: string; page: number; scrollTop: number } | null - setTaskListPosition: (position: UISlice['taskListPosition']) => void - githubTaskDrawerWorkItem: GitHubWorkItem | null - setGithubTaskDrawerWorkItem: (item: GitHubWorkItem | null) => void - newWorkspaceDraft: { - repoId: string | null - // Why: project-first creation uses these when present; old drafts keep using only repoId during the additive migration. - projectId?: string | null - projectGroupId?: string | null - hostId?: ExecutionHostId | null - projectHostSetupId?: string | null - name: string - prompt: string - note: string - attachments: string[] - linkedWorkItem: { - provider?: 'github' | 'gitlab' | 'linear' | 'jira' - type: 'issue' | 'pr' | 'mr' - number: number - title: string - url: string - linearIdentifier?: string - linearBranchName?: string - jiraIdentifier?: string - repoId?: string - } | null - /** Preserve where provider data came from, separately from the host chosen to run the workspace. */ - taskSourceContext?: TaskSourceContext | null - linkedTaskSourceContext?: TaskSourceContext | null - agent: TuiAgent - linkedIssue: string - linkedPR: number | null - /** GitLab parallels — number for an issue, iid for an MR. Optional so pre-GitLab drafts still load without migration. */ - linkedGitLabIssue?: number | null - linkedGitLabMR?: number | null - // Why: repo-scoped start ref from the "Start from" picker; absent means "use the repo's effective base ref". - baseBranch?: string - // Why: review worktrees start from a head ref/SHA while Source Control compares against the provider target branch. - compareBaseRef?: string - } | null - openTaskPage: ( - data?: UISlice['taskPageData'], - options?: { recordTasksInteraction?: boolean } - ) => void - closeTaskPage: () => void - openActivityPage: () => void - closeActivityPage: () => void - selectedAutomationId: string | null - setSelectedAutomationId: (id: string | null) => void - pendingAutomationRunNavigation: { - automationId: string - runId: string | null - hostId?: ExecutionHostId - } | null - setPendingAutomationRunNavigation: ( - navigation: { automationId: string; runId: string | null; hostId?: ExecutionHostId } | null - ) => void - openAutomationsPage: () => void - closeAutomationsPage: () => void - openSpacePage: () => void - closeSpacePage: () => void - openSkillsPage: () => void - closeSkillsPage: () => void - pendingSkillShareId: string | null - openSkillShare: (shareId: string) => void - clearPendingSkillShare: () => void - /** Set when another surface links straight to the page's shared-links view. */ - pendingSkillsSharedView: boolean - openSkillsSharedLinks: () => void - clearPendingSkillsSharedView: () => void - openArtifactsPage: () => void - closeArtifactsPage: () => void - openMobilePage: () => void - closeMobilePage: () => void - setNewWorkspaceDraft: (draft: NonNullable) => void - clearNewWorkspaceDraft: () => void - openSettingsPage: () => void - closeSettingsPage: () => void - settingsNavigationTarget: SettingsNavigationTarget | null - openSettingsTarget: (target: NonNullable) => void - clearSettingsTarget: () => void - /** Which host the Projects Settings pane shows per project (keyed by projectId). Ephemeral on purpose — never persisted, so reload reopens on the effective host. */ - settingsProjectHostSelection: Record - settingsProjectSetupSelection: Record - setSettingsProjectHostSelection: ( - projectId: string, - hostId: ExecutionHostId, - setupId?: string - ) => void - /** One-shot Appearance accordion to expand for nested Settings deep links (e.g. Usage percentages under Window & Sidebar). Cleared when Appearance consumes it. */ - appearanceAccordionDeepLink: 'interface' | 'terminal' | 'window' | null - setAppearanceAccordionDeepLink: ( - section: NonNullable - ) => void - clearAppearanceAccordionDeepLink: () => void - activeModal: - | 'none' - | 'create-worktree' - | 'edit-meta' - | 'delete-worktree' - | 'preserved-branch-review' - | 'forget-ssh-workspace' - | 'confirm-add-project-from-folder' - | 'confirm-non-git-folder' - | 'confirm-remove-folder' - | 'add-repo' - | 'quick-open' - | 'worktree-palette' - | 'workspace-cleanup' - | 'project-added' - | 'worktree-visibility' - | 'setup-guide' - | 'feature-wall' - | 'feature-tips' - | 'new-workspace-composer' - | 'confirm-orca-yaml-hooks' - modalData: Record - openModal: (modal: UISlice['activeModal'], data?: Record) => void - closeModal: () => void - featureTipsSeenIds: FeatureTipId[] - markFeatureTipsSeen: (ids: FeatureTipId[]) => void - featureInteractions: FeatureInteractionState - recordFeatureInteraction: (id: FeatureInteractionId) => Promise - contextualToursSeenIds: ContextualTourId[] - contextualToursAutoEligible: boolean | null - activeContextualTourId: ContextualTourId | null - activeContextualTourStepIndex: number - activeContextualTourSource: string | null - activeContextualTourSourceDetached: boolean - activeContextualTourWasFeaturePreviouslyInteracted: boolean - contextualTourNavigationInteractionSnapshot: Partial> - activeContextualTourSuppressed: boolean - contextualTourShownThisSession: boolean - contextualToursOnboardingVisible: boolean - contextualToursBlockingSurfaceVisible: boolean - lastCompletedContextualTourId: ContextualTourId | null - setContextualToursAutoEligible: (eligible: boolean) => void - setContextualToursOnboardingVisible: (visible: boolean) => void - setContextualToursBlockingSurfaceVisible: (visible: boolean) => void - requestContextualTour: ( - id: ContextualTourId, - source: string, - wasFeaturePreviouslyInteracted?: boolean, - options?: { force?: boolean } - ) => void - suppressContextualTour: (id: ContextualTourId, source: string) => void - detachContextualTourSource: (id: ContextualTourId, source: string) => void - advanceContextualTour: () => void - regressContextualTour: () => void - dismissContextualTour: (id?: ContextualTourId) => void - completeContextualTour: (id?: ContextualTourId) => void - cancelContextualTour: (id?: ContextualTourId) => void - markContextualToursSeen: (ids: ContextualTourId[]) => void - trustedOrcaHooks: PersistedTrustedOrcaHooks - markOrcaHookScriptConfirmed: ( - repoId: string, - kind: OrcaHookScriptKind, - contentHash: string - ) => void - markOrcaHookRepoAlwaysTrusted: (repoId: string) => void - clearOrcaHookTrustForRepo: (repoId: string) => void - setupScriptPromptDismissedRepoIds: readonly string[] - dismissSetupScriptPrompt: (repoHostIdentity: string) => void - setupGuideSidebarDismissed: boolean - setSetupGuideSidebarDismissed: (dismissed: boolean) => void - setupGuideBrowserMilestoneMigrated: boolean - setupGuideBrowserMilestoneLegacyComplete: boolean - markSetupGuideBrowserMilestoneMigrated: (legacyComplete: boolean) => void - browserImportHintHidden: boolean - setBrowserImportHintHidden: (hidden: boolean) => void - mobileEmulatorTabIntroDismissed: boolean - dismissMobileEmulatorTabIntro: () => void - mobileEmulatorAgentSetupDismissed: boolean - dismissMobileEmulatorAgentSetup: () => void - projectOrderManualDefaultNoticeDismissed: boolean - dismissProjectOrderManualDefaultNotice: () => void - usagePercentageDisplayChangeNoticeDismissed: boolean - dismissUsagePercentageDisplayChangeNotice: () => void - usageEmptyStateDismissed: boolean - dismissUsageEmptyState: () => void - groupBy: 'none' | 'workspace-status' | 'repo' | 'pr-status' - setGroupBy: (g: UISlice['groupBy']) => void - sortBy: 'name' | 'smart' | 'recent' | 'repo' | 'manual' - setSortBy: (s: UISlice['sortBy']) => void - projectOrderBy: ProjectOrderBy - setProjectOrderBy: (p: ProjectOrderBy) => void - showActiveOnly: boolean - setShowActiveOnly: (v: boolean) => void - showSleepingWorkspaces: boolean - setShowSleepingWorkspaces: (v: boolean) => void - workspaceHostScope: WorkspaceHostScope - setWorkspaceHostScope: (scope: WorkspaceHostScope) => void - visibleWorkspaceHostIds: VisibleWorkspaceHostIds - setVisibleWorkspaceHostIds: (ids: VisibleWorkspaceHostIds) => void - workspaceHostOrder: WorkspaceHostOrder - setWorkspaceHostOrder: (ids: WorkspaceHostOrder) => void - /** Automations page host filter, in stable form. Never written from an unhydrated catalog. */ - automationHostFilter: AutomationHostFilter - setAutomationHostFilter: (filter: AutomationHostFilter) => void - manualRepoOrder: ManualRepoOrderEntry[] - hideDefaultBranchWorkspace: boolean - setHideDefaultBranchWorkspace: (v: boolean) => void - hideAutomationGeneratedWorkspaces: boolean - setHideAutomationGeneratedWorkspaces: (v: boolean) => void - hideCliCreatedWorkspaces: boolean - setHideCliCreatedWorkspaces: (v: boolean) => void - hideDetachedHeadWorkspaces: boolean - setHideDetachedHeadWorkspaces: (v: boolean) => void - hideWorkspacesFromOtherDevices: boolean - setHideWorkspacesFromOtherDevices: (v: boolean) => void - alwaysShowDefaultBranchWorkspace: boolean - setAlwaysShowDefaultBranchWorkspace: (v: boolean) => void - showDotfilesByWorktree: Record - setShowDotfilesForWorktree: (worktreeId: string, showDotfiles: boolean) => void - toggleShowDotfilesForWorktree: (worktreeId: string) => void - filterRepoIds: readonly string[] - setFilterRepoIds: (ids: readonly string[]) => void - collapsedGroups: Set - toggleCollapsedGroup: (key: string) => void - worktreeCardProperties: WorktreeCardProperty[] - _worktreeCardModeDefaulted: boolean - setWorktreeCardMode: (mode: WorktreeCardMode) => void - setWorktreeCardProperties: (properties: readonly WorktreeCardProperty[]) => void - agentActivityDisplayMode: AgentActivityDisplayMode - setAgentActivityDisplayMode: (mode: AgentActivityDisplayMode) => void - workspaceStatuses: WorkspaceStatusDefinition[] - setWorkspaceStatuses: (statuses: WorkspaceStatusDefinition[]) => void - workspaceBoardOpacity: number - setWorkspaceBoardOpacity: (opacity: number) => void - workspaceBoardColumnWidth: number - setWorkspaceBoardColumnWidth: (width: number) => void - syncTaskStatusFromWorkspaceBoard: boolean - setSyncTaskStatusFromWorkspaceBoard: (enabled: boolean) => void - /** Transient: the in-window Agent Dashboard companion drawer is open. Not persisted. */ - agentDashboardDrawerOpen: boolean - setAgentDashboardDrawerOpen: (open: boolean) => void - statusBarItems: StatusBarItem[] - toggleStatusBarItem: (item: StatusBarItem) => void - statusBarVisible: boolean - setStatusBarVisible: (v: boolean) => void - usagePercentageDisplay: UsagePercentageDisplay - setUsagePercentageDisplay: (display: UsagePercentageDisplay) => void - statusBarUsageMode: StatusBarUsageMode - setStatusBarUsageMode: (mode: StatusBarUsageMode) => void - workspacePortScan: { key: string; result: WorkspacePortScanResult } | null - workspacePortScansByKey: Record - workspacePortScanRefreshing: boolean - setWorkspacePortScan: (scan: { key: string; result: WorkspacePortScanResult } | null) => void - setWorkspacePortScanProjection: ( - scan: { key: string; result: WorkspacePortScanResult } | null - ) => void - replaceWorkspacePortScans: ( - scansByKey: Record, - projection: { key: string; result: WorkspacePortScanResult } | null - ) => void - setWorkspacePortScanForKey: (key: string, result: WorkspacePortScanResult | null) => void - setWorkspacePortScanRefreshing: (refreshing: boolean) => void - /** Whether the pet overlay is currently visible. Persisted so "Hide pet" survives reload. Independent of the experimentalPet flag (which gates whether it can render at all). */ - petVisible: boolean - setPetVisible: (v: boolean) => void - /** Which pet is active — a bundled id or a custom UUID. Persisted via PersistedUIState. */ - petId: string - setPetId: (id: string) => void - /** User-uploaded pet images. Metadata only — bytes live in main's userData. */ - customPets: CustomPet[] - addCustomPet: (model: CustomPet) => void - removeCustomPet: (id: string) => void - /** Pet overlay size in CSS pixels (square). User-adjustable so an oversized imported sprite isn't stuck on screen. */ - petSize: number - setPetSize: (size: number) => void - pendingRevealWorktree: PendingSidebarWorktreeReveal | null - pendingRevealSidebarRow: PendingSidebarRowReveal | null - revealWorktreeInSidebar: ( - worktreeId: string, - options?: { - behavior?: PendingSidebarWorktreeReveal['behavior'] - highlight?: boolean - beginRename?: boolean - executionHostId?: ExecutionHostId - } - ) => void - revealSidebarRow: ( - rowKey: string, - options?: { - behavior?: PendingSidebarRowReveal['behavior'] - highlight?: boolean - } - ) => void - clearPendingRevealWorktreeId: () => void - clearPendingRevealSidebarRow: () => void - // Why: cleared by the diff decorator after it reveals the line, so the same id can be requested again without a stale value. - scrollToDiffCommentId: string | null - setScrollToDiffCommentId: (id: string | null) => void - persistedUIReady: boolean - /** Writer-owned fields as last hydrated from main or flushed by the writer; the debounced writer diffs against this so it only persists fields this client changed (STA-5781). */ - persistedUIWriteBaseline: PersistedUIWriteBaseline | null - /** Fields with a ui.set round-trip in flight; hydration keeps the mirror's value for them so an echo of the in-flight write can't revert a newer flip-back. */ - persistedUIWriteInFlightCounts: Partial> - /** Bumped whenever hydration replaces the baseline; an ack whose write predates the bump must not fold, or it would erase a remote write that landed during the round trip. */ - persistedUIWriteBaselineGeneration: number - notePersistedUIWriteStarted: (fields: readonly (keyof PersistedUIWriteBaseline)[]) => void - /** Settle an in-flight write: fold the acked patch into the baseline (null = rejected, leaving the fields dirty so the next change re-flushes them). */ - notePersistedUIWriteSettled: ( - fields: readonly (keyof PersistedUIWriteBaseline)[], - flushed: Partial | null, - options?: { sentAtGeneration: number } - ) => void - uiZoomLevel: number - setUIZoomLevel: (level: number) => void - editorFontZoomLevel: number - setEditorFontZoomLevel: (level: number) => void - hydratePersistedUI: (ui: PersistedUIState, source?: 'startup' | 'sync') => void - updateStatus: UpdateStatus - setUpdateStatus: (status: UpdateStatus) => void - // Why: cache last-'available' changelog so the card keeps rich content while downloading; cleared on idle/checking to avoid staleness. - updateChangelog: ChangelogData | null - // Why: UpdateCard is lazy-loaded and may miss the transient checking status; hold manual-check intent until a terminal state consumes it. - updateUserInitiatedCycle: boolean - dismissedUpdateVersion: string | null - dismissUpdate: (versionOverride?: string) => void - clearDismissedUpdateVersion: () => void - /** Dev-only channel override; null follows the running build's own channel. */ - releaseChannelOverride: ReleaseChannel | null - setReleaseChannelOverride: (channel: ReleaseChannel | null) => void - // Why: ephemeral, renderer-only — never persisted; resets each session and on every phase transition (see setUpdateStatus). - updateCardCollapsed: boolean - setUpdateCardCollapsed: (collapsed: boolean) => void - updateReassuranceSeen: boolean - markUpdateReassuranceSeen: () => void - /** True on the launch where the OSC 52 default-on migration overrode a persisted `false`. */ - osc52ClipboardDefaultOnNoticePending: boolean - clearOsc52ClipboardDefaultOnNotice: () => void - isFullScreen: boolean - setIsFullScreen: (v: boolean) => void - /** URL opened when a new browser tab is created. Null = blank tab (default). */ - browserDefaultUrl: string | null - setBrowserDefaultUrl: (url: string | null) => void - browserDefaultSearchEngine: 'google' | 'duckduckgo' | 'bing' | 'kagi' | null - setBrowserDefaultSearchEngine: (engine: 'google' | 'duckduckgo' | 'bing' | 'kagi' | null) => void - browserDefaultZoomLevel: number - setBrowserDefaultZoomLevel: (level: number) => void - browserKagiSessionLink: string | null - setBrowserKagiSessionLink: (link: string | null) => void -} - -export const createUISlice: StateCreator = (set, get) => ({ - sidebarOpen: true, - sidebarWidth: 280, - toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })), - setSidebarOpen: (open) => set({ sidebarOpen: open }), - setSidebarWidth: (width) => set({ sidebarWidth: width }), - agentSendPopoverTargetMode: null, - openAgentSendPopoverTargetMode: (args) => { - const targets = deriveRunningAgentSendTargets(get(), args.worktreeId) - const previousMode = get().agentSendPopoverTargetMode - if (previousMode?.id === args.id && previousMode.status === 'sending') { - return - } - const disabledPaneKeys: Record = {} - for (const target of targets) { - if (target.status === 'disabled' && target.disabledReason) { - disabledPaneKeys[target.paneKey] = target.disabledReason - } - } - set({ - agentSendPopoverTargetMode: { - ...args, - instanceId: createAgentSendTargetModeInstanceId(), - eligiblePaneKeys: targets - .filter((target) => target.status === 'eligible') - .map((target) => target.paneKey), - disabledPaneKeys, - status: 'open' - } - }) - if ( - targets.some((target) => target.status === 'eligible') && - (previousMode?.id !== args.id || previousMode.worktreeId !== args.worktreeId) - ) { - get().revealWorktreeInSidebar(args.worktreeId, { behavior: 'auto', highlight: true }) - } - }, - diffNotesSendMenuOpenRequest: null, - openDiffNotesSendMenuForActiveWorktree: () => { - const worktreeId = get().activeWorktreeId - if (!worktreeId) { - return false - } - // Why: no unsent notes means nothing to send, so don't hijack focus or reveal the panel. - if ( - !get() - .getDiffComments(worktreeId) - .some((comment) => !comment.sentAt) - ) { - return false - } - get().setRightSidebarTab('source-control') - get().setRightSidebarOpen(true) - const nonce = (get().diffNotesSendMenuOpenRequest?.nonce ?? 0) + 1 - set({ diffNotesSendMenuOpenRequest: { worktreeId, nonce, issuedAt: Date.now() } }) - return true - }, - consumeDiffNotesSendMenuOpenRequest: (worktreeId) => - set((s) => - s.diffNotesSendMenuOpenRequest?.worktreeId === worktreeId - ? { diffNotesSendMenuOpenRequest: null } - : s - ), - closeAgentSendPopoverTargetMode: (id, instanceId) => - set((s) => { - if (!s.agentSendPopoverTargetMode) { - return s - } - if (id && s.agentSendPopoverTargetMode.id !== id) { - return s - } - if (instanceId && s.agentSendPopoverTargetMode.instanceId !== instanceId) { - return s - } - return { agentSendPopoverTargetMode: null } - }), - sendPromptToSidebarAgentTarget: async (paneKey) => { - const mode = get().agentSendPopoverTargetMode - if (!mode || mode.status === 'sending') { - return false - } - - const target = resolveRunningAgentSendTarget(get(), mode.worktreeId, paneKey) - if (!target || target.status !== 'eligible' || !target.ptyId) { - // Why: eligibility can drop after the menu opened; keep the picker open (row title explains) rather than adding toast noise. - return false - } - - set((s) => - s.agentSendPopoverTargetMode?.id === mode.id && - s.agentSendPopoverTargetMode.instanceId === mode.instanceId - ? { - agentSendPopoverTargetMode: { - ...s.agentSendPopoverTargetMode, - status: 'sending', - sendingPaneKey: paneKey, - error: undefined - } - } - : s - ) - - const label = formatAgentTypeLabel(target.entry.agentType) - const { activeAgentNotesSendFailureMessage, sendNotesToActiveAgentSession } = - await import('@/lib/active-agent-note-send') - const result = await sendNotesToActiveAgentSession({ - worktreeId: mode.worktreeId, - prompt: mode.prompt, - noteTarget: { tabId: target.tabId, leafId: target.leafId } - }).catch((error) => { - console.error('Failed to send notes to sidebar agent target:', error) - return { status: 'no-active-terminal' as const } - }) - - const stillCurrent = (): boolean => { - const current = get().agentSendPopoverTargetMode - return current?.id === mode.id && current.instanceId === mode.instanceId - } - - if (!stillCurrent()) { - return false - } - - if (result.status !== 'sent') { - const message = activeAgentNotesSendFailureMessage(result.status, { explicitTarget: true }) - set((s) => - s.agentSendPopoverTargetMode?.id === mode.id && - s.agentSendPopoverTargetMode.instanceId === mode.instanceId - ? { - agentSendPopoverTargetMode: { - ...s.agentSendPopoverTargetMode, - status: 'error', - sendingPaneKey: undefined, - error: message - } - } - : s - ) - const { toast } = await import('sonner') - if (!stillCurrent()) { - return false - } - toast.error( - translate('auto.store.slices.ui.53883b7bc3', "Couldn't send to {{value0}}", { - value0: label - }), - { description: message } - ) - return false - } - - const [{ toast }, { track }] = await Promise.all([import('sonner'), import('@/lib/telemetry')]) - if (!stillCurrent()) { - return false - } - mode.onPromptDelivered?.() - track('agent_prompt_sent', { - agent_kind: agentKindForAgentType(target.entry.agentType), - launch_source: mode.launchSource, - request_kind: 'followup' - }) - toast.success( - translate('auto.store.slices.ui.66e3bd7ce6', 'Sent to {{value0}}', { value0: label }) - ) - get().closeAgentSendPopoverTargetMode(mode.id, mode.instanceId) - return true - }, - - acknowledgedAgentsByPaneKey: {}, - acknowledgeAgents: (paneKeys) => { - const notificationIdsToDismiss = new Set() - set((s) => { - if (paneKeys.length === 0) { - return s - } - const now = Date.now() - const migrationUnsupported = Object.values(s.migrationUnsupportedByPtyId ?? {}) - // Why: only reallocate if an ack advances; compare prev | null = null - for (const key of paneKeys) { - const prev = s.acknowledgedAgentsByPaneKey[key] ?? 0 - // Why not plain Date.now(): a remote/SSH execution host can stamp a turn ahead of this clock, - // and every unread rule is `ackAt < turnTimestamp`. A behind-the-turn ack can never clear the - // row, so its auto-ack effect re-fires on each new millisecond forever (React #185). - let stamp = now - const liveEntry = s.agentStatusByPaneKey?.[key] - if (liveEntry) { - collectAcknowledgedAgentNotificationId({ - ids: notificationIdsToDismiss, - worktreeId: resolvePaneKeyWorktreeIdFromTabs(s, key) ?? liveEntry.worktreeId, - paneKey: key, - stateStartedAt: liveEntry.stateStartedAt, - previousAckAt: prev - }) - stamp = Math.max(stamp, latestAgentTurnTimestamp(liveEntry)) - } - const retained = s.retainedAgentsByPaneKey?.[key] - if (retained) { - collectAcknowledgedAgentNotificationId({ - ids: notificationIdsToDismiss, - worktreeId: retained.worktreeId, - paneKey: key, - stateStartedAt: retained.entry.stateStartedAt, - previousAckAt: prev - }) - stamp = Math.max(stamp, latestAgentTurnTimestamp(retained.entry)) - } - for (const unsupported of migrationUnsupported) { - // Why: Activity synthesizes a blocked row from this entry, stamped by the pane's host like any turn. - if (unsupported.paneKey === key) { - stamp = Math.max(stamp, usableTimestamp(unsupported.updatedAt)) - } - } - if (prev < stamp) { - if (next === null) { - next = { ...s.acknowledgedAgentsByPaneKey } - } - next[key] = stamp - } - } - return next ? { acknowledgedAgentsByPaneKey: next } : s - }) - const notificationIds = [...notificationIdsToDismiss] - if (notificationIds.length > 0 && typeof window !== 'undefined') { - void window.api?.notifications?.dismiss?.(notificationIds) - } - }, - unacknowledgeAgents: (paneKeys) => - set((s) => { - if (paneKeys.length === 0) { - return s - } - let next: Record | null = null - for (const key of paneKeys) { - if (s.acknowledgedAgentsByPaneKey[key] !== undefined) { - if (next === null) { - next = { ...s.acknowledgedAgentsByPaneKey } - } - delete next[key] - } - } - return next ? { acknowledgedAgentsByPaneKey: next } : s - }), - - activeView: 'terminal', - previousViewBeforeTasks: 'terminal', - previousViewBeforeSettings: 'terminal', - previousViewBeforeActivity: 'terminal', - previousViewBeforeAutomations: 'terminal', - previousViewBeforeSpace: 'terminal', - previousViewBeforeSkills: 'terminal', - pendingSkillShareId: null, - pendingSkillsSharedView: false, - previousViewBeforeMobile: 'terminal', - previousViewBeforeArtifacts: 'terminal', - setActiveView: (view) => set({ activeView: view }), - taskPageData: {}, - taskResumeState: undefined, - taskListPosition: null, - githubTaskDrawerWorkItem: null, - newWorkspaceDraft: null, - openTaskPage: (data = {}, options = {}) => { - if (options.recordTasksInteraction !== false) { - const wasTasksPreviouslyInteracted = hasFeatureInteraction(get().featureInteractions, 'tasks') - set((state) => ({ - contextualTourNavigationInteractionSnapshot: { - ...state.contextualTourNavigationInteractionSnapshot, - tasks: wasTasksPreviouslyInteracted - } - })) - get().recordFeatureInteraction?.('tasks') - } - if (data.openGitHubWorkItem) { - get().recordFeatureInteraction?.('github-tasks') - } - if (data.openGitLabWorkItem) { - get().recordFeatureInteraction?.('gitlab-tasks') - } - if (data.openLinearIssue) { - get().recordFeatureInteraction?.('linear-tasks') - } - if (data.openJiraIssue) { - get().recordFeatureInteraction?.('jira-tasks') - } - // Why: record a Tasks visit in shared back/forward history; all task-source variants collapse to one deduped 'tasks' entry. - const detailEntry = data.openGitHubWorkItem - ? ({ - kind: 'task-detail', - source: 'github', - workItem: data.openGitHubWorkItem, - sourceContext: data.openGitHubSourceContext, - initialTab: data.openGitHubInitialTab - } as const) - : data.openGitLabWorkItem - ? ({ - kind: 'task-detail', - source: 'gitlab', - workItem: data.openGitLabWorkItem, - sourceContext: data.openGitLabSourceContext - } as const) - : data.openLinearIssue - ? ({ - kind: 'task-detail', - source: 'linear', - issue: data.openLinearIssue, - sourceContext: data.openLinearSourceContext - } as const) - : data.openJiraIssue - ? ({ - kind: 'task-detail', - source: 'jira', - issue: data.openJiraIssue, - sourceContext: data.openJiraSourceContext - } as const) - : null - const currentEntry = get().worktreeNavHistory[get().worktreeNavHistoryIndex] - const currentIsTaskStack = - currentEntry === 'tasks' || - (typeof currentEntry === 'object' && currentEntry.kind === 'task-detail') - if (!detailEntry || !currentIsTaskStack) { - get().recordViewVisit('tasks') - } - if (detailEntry) { - get().recordViewVisit(detailEntry) - } - set((state) => ({ - activeView: 'tasks', - previousViewBeforeTasks: - state.activeView === 'tasks' ? state.previousViewBeforeTasks : state.activeView, - taskPageData: data - })) - // Why: prefetch the work-item list during first render so the page's effect hits a warm/in-flight SWR cache (~300–800ms win). - const state = get() - const preferredVisibleTaskProviders = normalizeVisibleTaskProviders( - state.settings?.visibleTaskProviders - ) - const visibleTaskProviders = restoreAvailableDefaultTaskProvider( - preferredVisibleTaskProviders, - { - gitlabInstalled: state.preflightStatus?.glab?.installed === true, - linearConnected: state.linearStatus?.connected === true - }, - state.settings?.defaultTaskSource - ) - const resolvedSource = resolveVisibleTaskProvider( - data.taskSource ?? state.settings?.defaultTaskSource, - visibleTaskProviders - ) - const resolvedMode = state.taskResumeState?.githubMode ?? 'items' - if (resolvedSource === 'github' && resolvedMode === 'items') { - const eligibleRepos = state.repos.filter((repo) => isGitRepoKind(repo) && repo.path) - const selectedRepos = (() => { - const preferred = data.preselectedRepoId - if (preferred) { - const repo = eligibleRepos.find((r) => r.id === preferred) - return repo ? [repo] : [] - } - const persisted = state.settings?.defaultRepoSelection - if (Array.isArray(persisted)) { - const selected = eligibleRepos.filter((repo) => persisted.includes(repo.id)) - if (selected.length > 0) { - return selected - } - } - return eligibleRepos - })() - - const resume = state.taskResumeState - const defaultPreset = state.settings?.defaultTaskViewPreset ?? 'all' - // Why: must match the query TaskPage's resume effect mounts with, else the warm cache key misses and prefetch is wasted. - const query = - resume?.githubItemsPreset === null - ? (resume.githubItemsQuery ?? '').trim() - : presetToQuery(resume?.githubItemsPreset ?? defaultPreset) - for (const repo of selectedRepos) { - state.prefetchWorkItems(repo.id, repo.path, PER_REPO_FETCH_LIMIT, query, { - sourceContext: - data.openGitHubSourceContext?.provider === 'github' && - data.openGitHubSourceContext.repoId === repo.id - ? data.openGitHubSourceContext - : null - }) - } - } - if (resolvedSource === 'linear' && typeof state.prefetchLinearIssues === 'function') { - const resume = state.taskResumeState - const query = (resume?.linearQuery ?? '').trim() - const sourceContext = - data.openLinearSourceContext?.provider === 'linear' ? data.openLinearSourceContext : null - if (query) { - state.prefetchLinearIssues( - { kind: 'search', query, limit: LINEAR_TASK_PREFETCH_LIMIT }, - { sourceContext } - ) - } else { - // Why: TaskPage no longer exposes Linear preset filters; keep prefetch aligned with the default unsearched issue list. - state.prefetchLinearIssues( - { - kind: 'list', - filter: 'all', - limit: LINEAR_TASK_PREFETCH_LIMIT - }, - { sourceContext } - ) - } - } - }, - setTaskResumeState: (updates) => - set((s) => { - const next = { ...s.taskResumeState, ...updates } - window.api.ui.set({ taskResumeState: next }).catch(console.error) - return { taskResumeState: next } - }), - setTaskListPosition: (taskListPosition) => set({ taskListPosition }), - setGithubTaskDrawerWorkItem: (item) => set({ githubTaskDrawerWorkItem: item }), - closeTaskPage: () => - set((state) => { - // Why: if parked on a 'tasks' entry, rewind the history index so Back/Forward aren't no-ops; keep 0 if it's the only entry. - const currentEntry = state.worktreeNavHistory[state.worktreeNavHistoryIndex] - let nextHistoryIndex = state.worktreeNavHistoryIndex - if ( - currentEntry === 'tasks' || - (typeof currentEntry === 'object' && currentEntry.kind === 'task-detail') - ) { - const prev = findPrevLiveNonTaskStackHistoryIndex(state) - if (prev !== null) { - nextHistoryIndex = prev - } else if (typeof currentEntry === 'object' && state.worktreeNavHistory[0] === 'tasks') { - nextHistoryIndex = 0 - } - } - return { - activeView: state.previousViewBeforeTasks, - taskPageData: {}, - githubTaskDrawerWorkItem: null, - worktreeNavHistoryIndex: nextHistoryIndex - } - }), - openActivityPage: () => { - if (get().settings?.experimentalActivity !== true) { - return - } - set((state) => ({ - activeView: 'activity', - previousViewBeforeActivity: - state.activeView === 'activity' ? state.previousViewBeforeActivity : state.activeView - })) - }, - closeActivityPage: () => - set((state) => ({ - activeView: state.previousViewBeforeActivity - })), - selectedAutomationId: null, - setSelectedAutomationId: (id) => set({ selectedAutomationId: id }), - pendingAutomationRunNavigation: null, - setPendingAutomationRunNavigation: (navigation) => - set({ pendingAutomationRunNavigation: navigation }), - openAutomationsPage: () => { - get().recordViewVisit('automations') - set((state) => ({ - activeView: 'automations', - previousViewBeforeAutomations: - state.activeView === 'automations' ? state.previousViewBeforeAutomations : state.activeView - })) - }, - closeAutomationsPage: () => - set((state) => ({ - activeView: state.previousViewBeforeAutomations, - worktreeNavHistoryIndex: rewindHistoryIndexPastView(state, 'automations') - })), - openSpacePage: () => { - get().recordFeatureInteraction?.('workspace-cleanup') - set((state) => ({ - activeView: 'space', - previousViewBeforeSpace: - state.activeView === 'space' ? state.previousViewBeforeSpace : state.activeView - })) - }, - closeSpacePage: () => - set((state) => ({ - activeView: state.previousViewBeforeSpace - })), - openSkillsPage: () => { - get().recordViewVisit('skills') - set((state) => ({ - activeView: 'skills', - previousViewBeforeSkills: - state.activeView === 'skills' ? state.previousViewBeforeSkills : state.activeView - })) - }, - closeSkillsPage: () => - set((state) => ({ - activeView: state.previousViewBeforeSkills, - worktreeNavHistoryIndex: rewindHistoryIndexPastView(state, 'skills') - })), - openSkillShare: (shareId) => { - get().recordViewVisit('skills') - set((state) => ({ - activeView: 'skills', - previousViewBeforeSkills: - state.activeView === 'skills' ? state.previousViewBeforeSkills : state.activeView, - pendingSkillShareId: shareId - })) - }, - clearPendingSkillShare: () => set({ pendingSkillShareId: null }), - openSkillsSharedLinks: () => { - get().recordViewVisit('skills') - set((state) => ({ - activeView: 'skills', - previousViewBeforeSkills: - state.activeView === 'skills' ? state.previousViewBeforeSkills : state.activeView, - pendingSkillsSharedView: true - })) - }, - clearPendingSkillsSharedView: () => set({ pendingSkillsSharedView: false }), - openArtifactsPage: () => { - get().recordViewVisit('artifacts') - set((state) => ({ - activeView: 'artifacts', - previousViewBeforeArtifacts: - state.activeView === 'artifacts' ? state.previousViewBeforeArtifacts : state.activeView - })) - }, - closeArtifactsPage: () => - set((state) => ({ - activeView: state.previousViewBeforeArtifacts, - worktreeNavHistoryIndex: rewindHistoryIndexPastView(state, 'artifacts') - })), - openMobilePage: () => - set((state) => ({ - activeView: 'mobile', - previousViewBeforeMobile: - state.activeView === 'mobile' ? state.previousViewBeforeMobile : state.activeView - })), - closeMobilePage: () => - set((state) => ({ - activeView: state.previousViewBeforeMobile - })), - setNewWorkspaceDraft: (draft) => set({ newWorkspaceDraft: draft }), - clearNewWorkspaceDraft: () => set({ newWorkspaceDraft: null }), - openSettingsPage: () => { - // Why: settings search is a transient filter; opening Settings shouldn't inherit hidden sections from last visit. - get().setSettingsSearchQuery('') - set((state) => ({ - activeView: 'settings', - // Why: preserve the originating view so Settings back returns there (e.g. in-progress draft), not always terminal. - previousViewBeforeSettings: - state.activeView === 'settings' ? state.previousViewBeforeSettings : state.activeView - })) - }, - closeSettingsPage: () => - set((state) => { - const previousView = - state.previousViewBeforeSettings === 'activity' && - state.settings?.experimentalActivity !== true - ? 'terminal' - : state.previousViewBeforeSettings - return { activeView: previousView } - }), - settingsNavigationTarget: null, - openSettingsTarget: (target) => { - if (!isSettingsNavigationTarget(target)) { - if (import.meta.env.DEV) { - throw new TypeError('openSettingsTarget received an invalid navigation target') - } - return - } - set({ settingsNavigationTarget: target }) - }, - clearSettingsTarget: () => set({ settingsNavigationTarget: null }), - settingsProjectHostSelection: {}, - settingsProjectSetupSelection: {}, - // Why: renderer-only, never persisted — no window.api.ui.set, and absent from the debounced UI writer in App.tsx. - setSettingsProjectHostSelection: (projectId, hostId, setupId) => - set((s) => { - const nextSetupSelections = { ...s.settingsProjectSetupSelection } - if (setupId) { - nextSetupSelections[projectId] = setupId - } else { - delete nextSetupSelections[projectId] - } - if ( - s.settingsProjectHostSelection[projectId] === hostId && - s.settingsProjectSetupSelection[projectId] === setupId - ) { - return s - } - return { - settingsProjectHostSelection: { - ...s.settingsProjectHostSelection, - [projectId]: hostId - }, - settingsProjectSetupSelection: nextSetupSelections - } - }), - appearanceAccordionDeepLink: null, - setAppearanceAccordionDeepLink: (section) => set({ appearanceAccordionDeepLink: section }), - clearAppearanceAccordionDeepLink: () => set({ appearanceAccordionDeepLink: null }), - - activeModal: 'none', - modalData: {}, - openModal: (modal, data = {}) => { - if (modal === 'add-repo' || modal === 'create-worktree') { - get().recordFeatureInteraction?.('workspace-creation') - } - const evicted = get().modalData - set({ - activeModal: modal, - modalData: data - }) - settleEvictedModalData(evicted) - }, - closeModal: () => { - const evicted = get().modalData - set({ activeModal: 'none', modalData: {} }) - settleEvictedModalData(evicted) - }, - featureTipsSeenIds: [], - markFeatureTipsSeen: (ids) => - set((s) => { - if (ids.length === 0) { - return s - } - const current = new Set(s.featureTipsSeenIds) - let changed = false - for (const id of ids) { - if (!current.has(id)) { - current.add(id) - changed = true - } - } - if (!changed) { - return s - } - const next = [...current] - window.api.ui.set({ featureTipsSeenIds: next }).catch(console.error) - return { featureTipsSeenIds: next } - }), - featureInteractions: {}, - recordFeatureInteraction: (id) => { - let tourProgression: ReturnType = null - let persistPromise = Promise.resolve() - set((s) => { - if (!s.persistedUIReady) { - return s - } - tourProgression = getContextualTourProgressionForFeatureInteraction(s, id) - const existing = s.featureInteractions[id] - const next: FeatureInteractionState = { - ...s.featureInteractions, - [id]: { - firstInteractedAt: existing?.firstInteractedAt ?? Date.now(), - interactionCount: (existing?.interactionCount ?? 0) + 1 - } - } - if (typeof window !== 'undefined') { - const recordInteraction = window.api.ui.recordFeatureInteraction - const persist = recordInteraction - ? recordInteraction(id).then((ui) => { - set((current) => ({ - featureInteractions: mergeFeatureInteractionState( - current.featureInteractions, - ui.featureInteractions - ), - contextualToursSeenIds: mergeContextualTourSeenIds( - current.contextualToursSeenIds, - ui.contextualToursSeenIds - ) - })) - }) - : window.api.ui.set({ featureInteractions: next }) - persistPromise = persist.catch(console.error) - } - if (tourProgression === 'reveal-sidebar-and-advance') { - // Why: split can fire from keyboard/menu with the sidebar closed, but the next tour target lives in the sidebar. - return { - featureInteractions: next, - sidebarOpen: true, - activeContextualTourStepIndex: s.activeContextualTourStepIndex + 1 - } - } - return { featureInteractions: next } - }) - if (tourProgression === 'complete') { - get().completeContextualTour() - } else if (tourProgression === 'advance') { - get().advanceContextualTour() - } - return persistPromise - }, - contextualToursSeenIds: [], - contextualToursAutoEligible: null, - activeContextualTourId: null, - activeContextualTourStepIndex: 0, - activeContextualTourSource: null, - activeContextualTourSourceDetached: false, - activeContextualTourWasFeaturePreviouslyInteracted: false, - contextualTourNavigationInteractionSnapshot: {}, - activeContextualTourSuppressed: false, - contextualTourShownThisSession: false, - contextualToursOnboardingVisible: false, - contextualToursBlockingSurfaceVisible: false, - lastCompletedContextualTourId: null, - setContextualToursAutoEligible: (eligible) => - set((s) => { - if (s.contextualToursAutoEligible === eligible) { - return s - } - if (typeof window !== 'undefined') { - window.api.ui.set({ contextualToursAutoEligible: eligible }).catch(console.error) - } - return { contextualToursAutoEligible: eligible } - }), - setContextualToursOnboardingVisible: (visible) => - set((s) => - s.contextualToursOnboardingVisible === visible - ? s - : { contextualToursOnboardingVisible: visible } - ), - setContextualToursBlockingSurfaceVisible: (visible) => - set((s) => - s.contextualToursBlockingSurfaceVisible === visible - ? s - : { contextualToursBlockingSurfaceVisible: visible } - ), - requestContextualTour: (id, source, wasFeaturePreviouslyInteracted, options) => - set((s) => { - const tour = getContextualTour(id) - const decision = getContextualTourRequestDecision({ - tour, - persistedUIReady: s.persistedUIReady, - autoEligible: options?.force === true || s.contextualToursAutoEligible === true, - onboardingVisible: s.contextualToursOnboardingVisible, - seenIds: options?.force === true ? [] : s.contextualToursSeenIds, - sessionConsumed: options?.force === true ? false : s.contextualTourShownThisSession, - activeTourId: s.activeContextualTourId, - activeModal: s.activeModal, - blockingSurfaceVisible: s.contextualToursBlockingSurfaceVisible, - targetExists: hasContextualTourTarget - }) - if (decision.kind !== 'start') { - if (s.contextualTourNavigationInteractionSnapshot[id] === undefined) { - return s - } - const { [id]: _consumed, ...remainingNavigationSnapshot } = - s.contextualTourNavigationInteractionSnapshot - void _consumed - return { contextualTourNavigationInteractionSnapshot: remainingNavigationSnapshot } - } - const navigationSnapshot = s.contextualTourNavigationInteractionSnapshot[id] - const { [id]: _consumed, ...remainingNavigationSnapshot } = - s.contextualTourNavigationInteractionSnapshot - void _consumed - return { - activeContextualTourId: id, - activeContextualTourStepIndex: decision.stepIndex, - activeContextualTourSource: source, - activeContextualTourSourceDetached: false, - activeContextualTourWasFeaturePreviouslyInteracted: - wasFeaturePreviouslyInteracted ?? - navigationSnapshot ?? - hasFeatureInteraction(s.featureInteractions, id), - contextualTourNavigationInteractionSnapshot: remainingNavigationSnapshot, - activeContextualTourSuppressed: false, - contextualTourShownThisSession: true, - lastCompletedContextualTourId: null - } - }), - suppressContextualTour: (id, source) => - set((s) => { - if ( - s.activeContextualTourId !== id || - s.activeContextualTourSource !== source || - s.activeContextualTourSourceDetached - ) { - return s - } - return s.activeContextualTourSuppressed ? s : { activeContextualTourSuppressed: true } - }), - detachContextualTourSource: (id, source) => - set((s) => { - if (s.activeContextualTourId !== id || s.activeContextualTourSource !== source) { - return s - } - return s.activeContextualTourSourceDetached ? s : { activeContextualTourSourceDetached: true } - }), - advanceContextualTour: () => - set((s) => { - if (!s.activeContextualTourId) { - return s - } - const tour = getContextualTour(s.activeContextualTourId) - const nextStepIndex = getNextVisibleContextualTourStepIndex({ - tour, - currentStepIndex: s.activeContextualTourStepIndex, - targetExists: hasContextualTourTarget - }) - if (nextStepIndex !== null) { - return { activeContextualTourStepIndex: nextStepIndex } - } - // Why: browser step 3's target lives in a closed menu until that step is active. - if ( - s.activeContextualTourId === 'browser' && - s.activeContextualTourStepIndex + 1 < tour.steps.length - ) { - return { activeContextualTourStepIndex: s.activeContextualTourStepIndex + 1 } - } - return s - }), - regressContextualTour: () => - set((s) => { - if (!s.activeContextualTourId) { - return s - } - const previousStepIndex = getPreviousVisibleContextualTourStepIndex({ - tour: getContextualTour(s.activeContextualTourId), - currentStepIndex: s.activeContextualTourStepIndex, - targetExists: hasContextualTourTarget - }) - if (previousStepIndex === null) { - return s - } - return { activeContextualTourStepIndex: previousStepIndex } - }), - dismissContextualTour: (id) => { - const activeTourId = get().activeContextualTourId - if (id && activeTourId !== id) { - return - } - const tourId = id ?? activeTourId - if (tourId) { - get().markContextualToursSeen([tourId]) - } - set((s) => { - if (id && s.activeContextualTourId !== id) { - return s - } - return { - activeContextualTourId: null, - activeContextualTourStepIndex: 0, - activeContextualTourSource: null, - activeContextualTourSourceDetached: false, - activeContextualTourWasFeaturePreviouslyInteracted: false, - activeContextualTourSuppressed: false, - lastCompletedContextualTourId: null - } - }) - }, - completeContextualTour: (id) => { - const activeTourId = get().activeContextualTourId - if (id && activeTourId !== id) { - return - } - const tourId = id ?? activeTourId - if (tourId) { - get().markContextualToursSeen([tourId]) - } - set((s) => { - if (id && s.activeContextualTourId !== id) { - return s - } - return { - activeContextualTourId: null, - activeContextualTourStepIndex: 0, - activeContextualTourSource: null, - activeContextualTourSourceDetached: false, - activeContextualTourWasFeaturePreviouslyInteracted: false, - activeContextualTourSuppressed: false, - lastCompletedContextualTourId: tourId ?? null - } - }) - }, - cancelContextualTour: (id) => - set((s) => { - const activeTourId = s.activeContextualTourId - const tourId = id ?? activeTourId - if (!tourId || (id && activeTourId !== id)) { - return s - } - const alreadyShown = s.contextualToursSeenIds.includes(tourId) - return { - activeContextualTourId: null, - activeContextualTourStepIndex: 0, - activeContextualTourSource: null, - activeContextualTourSourceDetached: false, - activeContextualTourWasFeaturePreviouslyInteracted: false, - activeContextualTourSuppressed: false, - lastCompletedContextualTourId: null, - contextualTourShownThisSession: alreadyShown ? s.contextualTourShownThisSession : false - } - }), - markContextualToursSeen: (ids) => - set((s) => { - if (ids.length === 0) { - return s - } - const current = new Set(s.contextualToursSeenIds) - let changed = false - for (const id of ids) { - if (!current.has(id)) { - current.add(id) - changed = true - } - } - if (!changed) { - return s - } - const next = [...current] - if (typeof window !== 'undefined') { - window.api.ui.set({ contextualToursSeenIds: next }).catch(console.error) - } - return { contextualToursSeenIds: next } - }), - trustedOrcaHooks: {}, - markOrcaHookScriptConfirmed: (repoId, kind, contentHash) => - set((s) => { - const existing = s.trustedOrcaHooks[repoId] - const currentEntry = existing?.[kind] - if (currentEntry?.contentHash === contentHash) { - return s - } - const nextRepo = { - ...existing, - [kind]: { contentHash, approvedAt: Date.now() } - } - const next = { ...s.trustedOrcaHooks, [repoId]: nextRepo } - window.api.ui.set({ trustedOrcaHooks: next }).catch(console.error) - return { trustedOrcaHooks: next } - }), - markOrcaHookRepoAlwaysTrusted: (repoId) => - set((s) => { - const existing = s.trustedOrcaHooks[repoId] - if (existing?.all) { - return s - } - const next = { - ...s.trustedOrcaHooks, - [repoId]: { - ...existing, - all: { approvedAt: Date.now() } - } - } - window.api.ui.set({ trustedOrcaHooks: next }).catch(console.error) - return { trustedOrcaHooks: next } - }), - clearOrcaHookTrustForRepo: (repoId) => - set((s) => { - if (!(repoId in s.trustedOrcaHooks)) { - return s - } - const next = { ...s.trustedOrcaHooks } - delete next[repoId] - window.api.ui.set({ trustedOrcaHooks: next }).catch(console.error) - return { trustedOrcaHooks: next } - }), - setupScriptPromptDismissedRepoIds: [], - dismissSetupScriptPrompt: (repoHostIdentity) => - set((s) => { - const dismissalKey = getSetupScriptPromptDismissalKey(repoHostIdentity) - if (!repoHostIdentity || s.setupScriptPromptDismissedRepoIds.includes(dismissalKey)) { - return s - } - const next = [...s.setupScriptPromptDismissedRepoIds, dismissalKey] - window.api.ui.set({ setupScriptPromptDismissedRepoIds: next }).catch(console.error) - return { setupScriptPromptDismissedRepoIds: next } - }), - setupGuideSidebarDismissed: false, - setSetupGuideSidebarDismissed: (dismissed) => - set((s) => { - if (s.setupGuideSidebarDismissed === dismissed) { - return s - } - window.api.ui.set({ setupGuideSidebarDismissed: dismissed }).catch(console.error) - return { setupGuideSidebarDismissed: dismissed } - }), - setupGuideBrowserMilestoneMigrated: true, - setupGuideBrowserMilestoneLegacyComplete: false, - markSetupGuideBrowserMilestoneMigrated: (legacyComplete) => - set((s) => { - if ( - s.setupGuideBrowserMilestoneMigrated && - s.setupGuideBrowserMilestoneLegacyComplete === legacyComplete - ) { - return s - } - const updates = { - setupGuideBrowserMilestoneMigrated: true, - setupGuideBrowserMilestoneLegacyComplete: legacyComplete - } - window.api.ui.set(updates).catch(console.error) - return updates - }), - browserImportHintHidden: false, - setBrowserImportHintHidden: (hidden) => - set((s) => { - if (s.browserImportHintHidden === hidden) { - return s - } - window.api.ui.set({ browserImportHintHidden: hidden }).catch(console.error) - return { browserImportHintHidden: hidden } - }), - mobileEmulatorTabIntroDismissed: false, - dismissMobileEmulatorTabIntro: () => - set((s) => { - if (s.mobileEmulatorTabIntroDismissed) { - return s - } - window.api.ui.set({ mobileEmulatorTabIntroDismissed: true }).catch(console.error) - return { mobileEmulatorTabIntroDismissed: true } - }), - mobileEmulatorAgentSetupDismissed: false, - dismissMobileEmulatorAgentSetup: () => - set((s) => { - if (s.mobileEmulatorAgentSetupDismissed) { - return s - } - window.api.ui.set({ mobileEmulatorAgentSetupDismissed: true }).catch(console.error) - return { mobileEmulatorAgentSetupDismissed: true } - }), - projectOrderManualDefaultNoticeDismissed: true, - dismissProjectOrderManualDefaultNotice: () => - set((s) => { - if (s.projectOrderManualDefaultNoticeDismissed) { - return s - } - window.api.ui.set({ projectOrderManualDefaultNoticeDismissed: true }).catch(console.error) - return { projectOrderManualDefaultNoticeDismissed: true } - }), - // Why: default true so pre-hydration / new sessions never flash the change notice before persistence resolves. - usagePercentageDisplayChangeNoticeDismissed: true, - dismissUsagePercentageDisplayChangeNotice: () => - set((s) => { - if (s.usagePercentageDisplayChangeNoticeDismissed) { - return s - } - window.api.ui.set({ usagePercentageDisplayChangeNoticeDismissed: true }).catch(console.error) - return { usagePercentageDisplayChangeNoticeDismissed: true } - }), - usageEmptyStateDismissed: false, - dismissUsageEmptyState: () => - set((s) => { - if (s.usageEmptyStateDismissed) { - return s - } - window.api.ui.set({ usageEmptyStateDismissed: true }).catch(console.error) - return { usageEmptyStateDismissed: true } - }), - - groupBy: 'repo', - // Why: group keys are mode-specific, so clear collapsed state on mode switch — stale keys are meaningless and accumulate. - setGroupBy: (g) => { - window.api.ui.set({ groupBy: g, collapsedGroups: [] }).catch(console.error) - set({ groupBy: g, collapsedGroups: new Set() }) - }, - - sortBy: 'recent', - setSortBy: (s) => set({ sortBy: s }), - - // Why: bare set — persists only via the debounced window.api.ui.set writer in App.tsx, not on its own. - projectOrderBy: 'manual', - setProjectOrderBy: (p) => set({ projectOrderBy: p }), - - showActiveOnly: false, - setShowActiveOnly: (v) => set({ showActiveOnly: v }), - - showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES, - setShowSleepingWorkspaces: (v) => set({ showSleepingWorkspaces: v }), - - workspaceHostScope: 'all', - // Why: host scope is presentation/filtering only — must never trigger resource teardown (terminals, browser pages). - setWorkspaceHostScope: (scope) => { - const normalized = normalizeExecutionHostScope(scope) - const visibleWorkspaceHostIds = normalized === 'all' ? null : [normalized] - set({ workspaceHostScope: normalized, visibleWorkspaceHostIds }) - window.api.ui - .set({ workspaceHostScope: normalized, visibleWorkspaceHostIds }) - .catch(console.error) - }, - visibleWorkspaceHostIds: null, - setVisibleWorkspaceHostIds: (ids) => { - const normalized = normalizeVisibleExecutionHostIds(ids) - // Why: workspaceHostScope stays the compat/default-host signal for creation flows; visibility can now be multi-select. - let workspaceHostScope: WorkspaceHostScope = get().workspaceHostScope - if (normalized === null) { - workspaceHostScope = 'all' - } else if (normalized.length === 1) { - workspaceHostScope = normalized[0] - } - set({ visibleWorkspaceHostIds: normalized, workspaceHostScope }) - window.api.ui - .set({ visibleWorkspaceHostIds: normalized, workspaceHostScope }) - .catch(console.error) - }, - workspaceHostOrder: [], - setWorkspaceHostOrder: (ids) => { - const workspaceHostOrder = normalizeExecutionHostOrder(ids) - set({ workspaceHostOrder }) - window.api.ui.set({ workspaceHostOrder }).catch(console.error) - }, - automationHostFilter: ALL_AUTOMATION_HOSTS_FILTER, - setAutomationHostFilter: (filter) => { - window.api.ui - .set({ automationHostFilter: toPersistedAutomationHostFilter(filter) }) - .catch(console.error) - set({ automationHostFilter: filter }) - }, - manualRepoOrder: [], - - hideDefaultBranchWorkspace: false, - setHideDefaultBranchWorkspace: (v) => set({ hideDefaultBranchWorkspace: v }), - hideAutomationGeneratedWorkspaces: false, - setHideAutomationGeneratedWorkspaces: (v) => set({ hideAutomationGeneratedWorkspaces: v }), - hideCliCreatedWorkspaces: false, - setHideCliCreatedWorkspaces: (v) => set({ hideCliCreatedWorkspaces: v }), - hideDetachedHeadWorkspaces: false, - setHideDetachedHeadWorkspaces: (v) => set({ hideDetachedHeadWorkspaces: v }), - hideWorkspacesFromOtherDevices: false, - setHideWorkspacesFromOtherDevices: (v) => set({ hideWorkspacesFromOtherDevices: v }), - alwaysShowDefaultBranchWorkspace: true, - setAlwaysShowDefaultBranchWorkspace: (v) => set({ alwaysShowDefaultBranchWorkspace: v }), - - showDotfilesByWorktree: {}, - setShowDotfilesForWorktree: (worktreeId, showDotfiles) => - set((s) => { - if (!worktreeId) { - return s - } - const current = s.showDotfilesByWorktree[worktreeId] ?? true - if (current === showDotfiles) { - return s - } - const next = { ...s.showDotfilesByWorktree } - // Why: showing dotfiles is the default; only persist worktree-level opt-outs. - if (showDotfiles) { - delete next[worktreeId] - } else { - next[worktreeId] = false - } - return { showDotfilesByWorktree: next } - }), - toggleShowDotfilesForWorktree: (worktreeId) => - set((s) => { - if (!worktreeId) { - return s - } - const nextShowDotfiles = !(s.showDotfilesByWorktree[worktreeId] ?? true) - const next = { ...s.showDotfilesByWorktree } - if (nextShowDotfiles) { - delete next[worktreeId] - } else { - next[worktreeId] = false - } - return { showDotfilesByWorktree: next } - }), - - filterRepoIds: [], - setFilterRepoIds: (ids) => set({ filterRepoIds: ids }), - - collapsedGroups: new Set(), - toggleCollapsedGroup: (key) => - set((s) => { - const next = new Set(s.collapsedGroups) - if (next.has(key)) { - next.delete(key) - } else { - next.add(key) - } - window.api.ui.set({ collapsedGroups: [...next] }).catch(console.error) - return { collapsedGroups: next } - }), - - worktreeCardProperties: [...DEFAULT_WORKTREE_CARD_PROPERTIES], - _worktreeCardModeDefaulted: true, - setWorktreeCardMode: (mode) => { - const updates = getWorktreeCardModeUpdates(mode) - set((s) => ({ - settings: s.settings ? { ...s.settings, ...updates.settings } : s.settings, - worktreeCardProperties: updates.ui.worktreeCardProperties, - _worktreeCardModeDefaulted: true - })) - void Promise.all([ - window.api.settings.set(updates.settings).then((nextSettings) => { - if (nextSettings) { - set({ settings: nextSettings }) - } - }), - window.api.ui.set(updates.ui) - ]).catch(console.error) - }, - setWorktreeCardProperties: (properties) => { - const normalized = normalizeWorktreeCardProperties(properties) - set({ worktreeCardProperties: normalized, _worktreeCardModeDefaulted: false }) - window.api.ui - .set({ worktreeCardProperties: normalized, _worktreeCardModeDefaulted: false }) - .catch(console.error) - }, - agentActivityDisplayMode: DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE, - setAgentActivityDisplayMode: (mode) => { - const normalized = normalizeAgentActivityDisplayMode(mode) - window.api.ui.set({ agentActivityDisplayMode: normalized }).catch(console.error) - set({ agentActivityDisplayMode: normalized }) - }, - - workspaceStatuses: cloneDefaultWorkspaceStatuses(), - setWorkspaceStatuses: (statuses) => { - const normalized = normalizeWorkspaceStatuses(statuses) - window.api.ui.set({ workspaceStatuses: normalized }).catch(console.error) - set({ workspaceStatuses: normalized }) - }, - - workspaceBoardOpacity: 1, - setWorkspaceBoardOpacity: (opacity) => { - const clamped = clampWorkspaceBoardOpacity(opacity) - window.api.ui.set({ workspaceBoardOpacity: clamped }).catch(console.error) - set({ workspaceBoardOpacity: clamped }) - }, - - workspaceBoardColumnWidth: WORKSPACE_BOARD_COLUMN_WIDTH_DEFAULT, - setWorkspaceBoardColumnWidth: (width) => { - const clamped = clampWorkspaceBoardColumnWidth(width) - window.api.ui.set({ workspaceBoardColumnWidth: clamped }).catch(console.error) - set({ workspaceBoardColumnWidth: clamped }) - }, - - syncTaskStatusFromWorkspaceBoard: false, - setSyncTaskStatusFromWorkspaceBoard: (enabled) => { - window.api.ui.set({ syncTaskStatusFromWorkspaceBoard: enabled }).catch(console.error) - set({ syncTaskStatusFromWorkspaceBoard: enabled }) - }, - - statusBarItems: [...DEFAULT_STATUS_BAR_ITEMS], - toggleStatusBarItem: (item) => - set((s) => { - const current = s.statusBarItems || DEFAULT_STATUS_BAR_ITEMS - const updated = current.includes(item) - ? current.filter((i) => i !== item) - : [...current, item] - window.api.ui.set({ statusBarItems: updated }).catch(console.error) - return { statusBarItems: updated } - }), - - agentDashboardDrawerOpen: false, - setAgentDashboardDrawerOpen: (open) => set({ agentDashboardDrawerOpen: open }), - statusBarVisible: true, - setStatusBarVisible: (v) => { - window.api.ui.set({ statusBarVisible: v }).catch(console.error) - set({ statusBarVisible: v }) - }, - usagePercentageDisplay: DEFAULT_USAGE_PERCENTAGE_DISPLAY, - setUsagePercentageDisplay: (display) => { - const normalized = normalizeUsagePercentageDisplay(display) - // Why: changing the control is the discovery path, so permanently dismiss the one-time change notice. - window.api.ui - .set({ - usagePercentageDisplay: normalized, - usagePercentageDisplayChangeNoticeDismissed: true - }) - .catch(console.error) - set({ - usagePercentageDisplay: normalized, - usagePercentageDisplayChangeNoticeDismissed: true - }) - }, - statusBarUsageMode: DEFAULT_STATUS_BAR_USAGE_MODE, - setStatusBarUsageMode: (mode) => { - const normalized = normalizeStatusBarUsageMode(mode) - window.api.ui.set({ statusBarUsageMode: normalized }).catch(console.error) - set({ statusBarUsageMode: normalized }) - }, - workspacePortScan: null, - workspacePortScansByKey: {}, - workspacePortScanRefreshing: false, - setWorkspacePortScan: (scan) => - set((state) => { - if (!scan) { - if (!state.workspacePortScan && Object.keys(state.workspacePortScansByKey).length === 0) { - return state - } - return { workspacePortScan: null, workspacePortScansByKey: {} } - } - if ( - state.workspacePortScan?.key === scan.key && - state.workspacePortScan.result === scan.result && - state.workspacePortScansByKey[scan.key] === scan.result - ) { - return state - } - return { - workspacePortScan: scan, - workspacePortScansByKey: { ...state.workspacePortScansByKey, [scan.key]: scan.result } - } - }), - // Why: target changes rebuild the aggregate without republishing or clearing per-host scans. - setWorkspacePortScanProjection: (scan) => - set((state) => { - if ( - state.workspacePortScan?.key === scan?.key && - state.workspacePortScan?.result === scan?.result - ) { - return state - } - return { workspacePortScan: scan } - }), - // Why: drop stale per-host scans in one store update so a large host set can't fan out notifications to every subscriber. - replaceWorkspacePortScans: (scansByKey, projection) => - set((state) => { - if ( - state.workspacePortScansByKey === scansByKey && - state.workspacePortScan?.key === projection?.key && - state.workspacePortScan?.result === projection?.result - ) { - return state - } - return { workspacePortScansByKey: scansByKey, workspacePortScan: projection } - }), - setWorkspacePortScanForKey: (key, result) => - set((state) => { - const currentResult = state.workspacePortScansByKey[key] - if (currentResult === result || (!result && !currentResult)) { - return state - } - const nextScansByKey = { ...state.workspacePortScansByKey } - if (result) { - nextScansByKey[key] = result - } else { - delete nextScansByKey[key] - } - return { - workspacePortScansByKey: nextScansByKey, - workspacePortScan: - state.workspacePortScan?.key === key - ? result - ? { key, result } - : null - : state.workspacePortScan - } - }), - setWorkspacePortScanRefreshing: (refreshing) => set({ workspacePortScanRefreshing: refreshing }), - - // Why: default true so enabling experimentalPet shows the pet immediately (persisted; "Hide pet" flips it false). - petVisible: true, - setPetVisible: (v) => { - window.api.ui.set({ petVisible: v }).catch(console.error) - set({ petVisible: v }) - }, - - petId: DEFAULT_PET_ID, - setPetId: (id) => { - window.api.ui.set({ petId: id }).catch(console.error) - set({ petId: id }) - }, - - petSize: PET_SIZE_DEFAULT, - setPetSize: (size) => { - const clamped = clampPetSize(size) - window.api.ui.set({ petSize: clamped }).catch(console.error) - set({ petSize: clamped }) - }, - - customPets: [], - addCustomPet: (model) => - set((s) => { - const next = [...s.customPets.filter((m) => m.id !== model.id), model] - window.api.ui.set({ customPets: next }).catch(console.error) - return { customPets: next } - }), - removeCustomPet: (id) => - set((s) => { - const target = s.customPets.find((m) => m.id === id) - if (!target) { - return s - } - const next = s.customPets.filter((m) => m.id !== id) - // Why: removing the active custom pet falls back to bundled default so the overlay isn't empty. - const fallback = s.petId === id ? DEFAULT_PET_ID : s.petId - // Why: single combined IPC update so customPets and petId persist atomically. - const ipcPayload: { customPets: CustomPet[]; petId?: string } = { - customPets: next - } - if (fallback !== s.petId) { - ipcPayload.petId = fallback - } - window.api.ui.set(ipcPayload).catch(console.error) - // Why: revoke the cached blob: URL so the Blob is released, not leaked for the session. - revokeCustomPetBlobUrl(id) - // Why: best-effort delete — bytes owned by main; fresh-UUID imports mean an orphaned file is never re-referenced. - window.api.pet.delete(id, target.fileName, target.kind).catch(console.error) - const partial: Partial = { customPets: next } - if (fallback !== s.petId) { - partial.petId = fallback - } - return partial - }), - - pendingRevealWorktree: null, - pendingRevealSidebarRow: null, - revealWorktreeInSidebar: (worktreeId, options) => - set({ - pendingRevealWorktree: { - worktreeId, - ...(options?.executionHostId ? { executionHostId: options.executionHostId } : {}), - behavior: options?.behavior ?? 'smooth', - ...(options?.highlight ? { highlight: true } : {}), - ...(options?.beginRename ? { beginRename: true } : {}) - } - }), - revealSidebarRow: (rowKey, options) => - set({ - pendingRevealSidebarRow: { - rowKey, - behavior: options?.behavior ?? 'smooth', - ...(options?.highlight === false ? {} : { highlight: true }) - } - }), - clearPendingRevealWorktreeId: () => set({ pendingRevealWorktree: null }), - clearPendingRevealSidebarRow: () => set({ pendingRevealSidebarRow: null }), - scrollToDiffCommentId: null, - setScrollToDiffCommentId: (id) => set({ scrollToDiffCommentId: id }), - persistedUIReady: false, - persistedUIWriteBaseline: null, - persistedUIWriteInFlightCounts: {}, - notePersistedUIWriteStarted: (fields) => - set((s) => { - const counts = { ...s.persistedUIWriteInFlightCounts } - for (const field of fields) { - counts[field] = (counts[field] ?? 0) + 1 - } - return { persistedUIWriteInFlightCounts: counts } - }), - persistedUIWriteBaselineGeneration: 0, - notePersistedUIWriteSettled: (fields, flushed, options) => - set((s) => { - const counts = { ...s.persistedUIWriteInFlightCounts } - for (const field of fields) { - const next = (counts[field] ?? 0) - 1 - if (next > 0) { - counts[field] = next - } else { - delete counts[field] - } - } - // Why the generation guard: a hydration during the round trip made the - // baseline authoritative for state NEWER than this write; folding the - // sent values over it would blank the mirror-vs-baseline diff and leave - // mirror and authority divergent with nothing left to reconcile them. - // Skipping the fold keeps the diff alive so the trailing flush re-sends. - // Why options is required for folding: an unguarded fold from a future - // caller could silently erase a remote write that landed mid-round-trip. - const foldable = - flushed && - s.persistedUIWriteBaseline && - options !== undefined && - options.sentAtGeneration === s.persistedUIWriteBaselineGeneration - return { - persistedUIWriteInFlightCounts: counts, - ...(foldable - ? { persistedUIWriteBaseline: { ...s.persistedUIWriteBaseline!, ...flushed } } - : {}) - } - }), - uiZoomLevel: 0, - setUIZoomLevel: (level) => set({ uiZoomLevel: level }), - editorFontZoomLevel: 0, - setEditorFontZoomLevel: (level) => set({ editorFontZoomLevel: level }), - - hydratePersistedUI: (ui, source = 'sync') => - set((s) => { - const manualRepoOrder = normalizeManualRepoOrder(ui.manualRepoOrder) - const orderedRepos = applyManualRepoOrder(s.repos, manualRepoOrder) - const validRepoIds = new Set(s.repos.map((repo) => repo.id)) - const validRepoHostIdentities = new Set(s.repos.map(getRepoHostIdentity)) - const persistedFilterRepoIds = sanitizePersistedRepoIds(ui.filterRepoIds) - // Why: pre-rename builds used sidekick* keys; read as fallback only so new pet* writes win after upgrade. - const customPets = Array.isArray(ui.customPets) - ? ui.customPets - : Array.isArray(ui.customSidekicks) - ? ui.customSidekicks - : [] - const petId = ui.petId ?? ui.sidekickId - // Migration: one-shot old-'recent'→'smart' runs in main (_sortBySmartMigrated), not here, so a deliberate 'recent' choice survives restart. - const sortBy = ui.sortBy - const migratedStatusBarItems = migrateStatusBarItems(ui.statusBarItems) - const statusBarItemsWithPorts = - ui._portsStatusBarDefaultAdded || migratedStatusBarItems.includes('ports') - ? migratedStatusBarItems - : [...migratedStatusBarItems, DEFAULT_ON_PORTS_STATUS_BAR_ITEM] - const statusBarItems = - ui._kimiStatusBarDefaultAdded || statusBarItemsWithPorts.includes('kimi') - ? statusBarItemsWithPorts - : [...statusBarItemsWithPorts, DEFAULT_ON_KIMI_STATUS_BAR_ITEM] - const statusBarItemsWithMiniMax = - ui._minimaxStatusBarDefaultAdded || statusBarItems.includes('minimax') - ? statusBarItems - : [...statusBarItems, DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM] - const statusBarItemsWithAntigravity = - ui._antigravityStatusBarDefaultAdded || statusBarItemsWithMiniMax.includes('antigravity') - ? statusBarItemsWithMiniMax - : [...statusBarItemsWithMiniMax, DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM] - const statusBarItemsWithGrok = - ui._grokStatusBarDefaultAdded || statusBarItemsWithAntigravity.includes('grok') - ? statusBarItemsWithAntigravity - : [...statusBarItemsWithAntigravity, DEFAULT_ON_GROK_STATUS_BAR_ITEM] - if ( - (!ui._portsStatusBarDefaultAdded || - !ui._kimiStatusBarDefaultAdded || - !ui._minimaxStatusBarDefaultAdded || - !ui._antigravityStatusBarDefaultAdded || - !ui._grokStatusBarDefaultAdded) && - typeof window !== 'undefined' - ) { - window.api.ui - .set({ - statusBarItems: statusBarItemsWithGrok, - _portsStatusBarDefaultAdded: true, - _kimiStatusBarDefaultAdded: true, - _minimaxStatusBarDefaultAdded: true, - _antigravityStatusBarDefaultAdded: true, - _grokStatusBarDefaultAdded: true - }) - .catch(console.error) - } - const rightSidebarRoute = normalizeRightSidebarRoute( - ui.rightSidebarTab, - ui.rightSidebarExplorerView - ) - const hydrated = { - // Why: persisted widths may be stale/corrupt/hand-edited; clamp during hydration so invalid values can't break layout. - sidebarWidth: sanitizePersistedSidebarWidth( - ui.sidebarWidth, - s.sidebarWidth, - MAX_LEFT_SIDEBAR_WIDTH - ), - rightSidebarWidth: sanitizePersistedSidebarWidth( - ui.rightSidebarWidth, - s.rightSidebarWidth, - MAX_RIGHT_SIDEBAR_WIDTH - ), - markdownTocPanelWidth: clampMarkdownTocPanelWidth( - ui.markdownTocPanelWidth, - undefined, - s.markdownTocPanelWidth - ), - combinedDiffFileTreeWidth: clampCombinedDiffFileTreeWidth( - ui.combinedDiffFileTreeWidth, - undefined, - s.combinedDiffFileTreeWidth - ), - rightSidebarOpen: typeof ui.rightSidebarOpen === 'boolean' ? ui.rightSidebarOpen : true, - rightSidebarTab: rightSidebarRoute.rightSidebarTab, - rightSidebarExplorerView: rightSidebarRoute.rightSidebarExplorerView, - groupBy: (ui.groupBy as UISlice['groupBy'] | 'parent') === 'parent' ? 'repo' : ui.groupBy, - sortBy, - // Why: main-process getUI() already normalized this (defaulting to 'manual'); read it through without migrating. - projectOrderBy: ui.projectOrderBy, - // Why: Active-only was retired; force the old flag off so an old profile can't invisibly narrow the workspace list. - showActiveOnly: false, - // Why: ignore older positive-form keys so old profiles start from the new default (sleeping workspaces visible). - showSleepingWorkspaces: !(ui.hideSleepingWorkspaces ?? DEFAULT_HIDE_SLEEPING_WORKSPACES), - workspaceHostScope: normalizeExecutionHostScope(ui.workspaceHostScope), - visibleWorkspaceHostIds: normalizeHydratedVisibleWorkspaceHostIds(ui), - workspaceHostOrder: normalizeExecutionHostOrder(ui.workspaceHostOrder), - // Why: a malformed or legacy filter value must degrade to All hosts, never throw during hydration. - automationHostFilter: parsePersistedAutomationHostFilter(ui.automationHostFilter), - manualRepoOrder, - // Why: apply the desktop-owned overlay immediately since UI state can arrive after a catalog or from another client. - repos: orderedRepos, - hideDefaultBranchWorkspace: ui.hideDefaultBranchWorkspace ?? false, - hideAutomationGeneratedWorkspaces: ui.hideAutomationGeneratedWorkspaces === true, - hideCliCreatedWorkspaces: ui.hideCliCreatedWorkspaces === true, - hideDetachedHeadWorkspaces: ui.hideDetachedHeadWorkspaces === true, - hideWorkspacesFromOtherDevices: ui.hideWorkspacesFromOtherDevices === true, - // Why !== false: profiles written before #8873 have no key, and they are - // precisely the ones showing the bug, so absence must mean "exempt". - alwaysShowDefaultBranchWorkspace: ui.alwaysShowDefaultBranchWorkspace !== false, - showDotfilesByWorktree: sanitizeShowDotfilesByWorktree(ui.showDotfilesByWorktree), - // Why: startup hydrates UI before repo catalogs, so defer repo-filter validation to the all-host refresh. - filterRepoIds: - validRepoIds.size === 0 - ? persistedFilterRepoIds - : persistedFilterRepoIds.filter((repoId) => validRepoIds.has(repoId)), - collapsedGroups: new Set(ui.collapsedGroups ?? []), - uiZoomLevel: ui.uiZoomLevel ?? 0, - editorFontZoomLevel: ui.editorFontZoomLevel ?? 0, - worktreeCardProperties: normalizeWorktreeCardProperties(ui.worktreeCardProperties), - _worktreeCardModeDefaulted: ui._worktreeCardModeDefaulted === true, - agentActivityDisplayMode: normalizeAgentActivityDisplayMode(ui.agentActivityDisplayMode), - workspaceStatuses: normalizeWorkspaceStatuses(ui.workspaceStatuses), - workspaceBoardOpacity: clampWorkspaceBoardOpacity(ui.workspaceBoardOpacity), - workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(ui.workspaceBoardColumnWidth), - syncTaskStatusFromWorkspaceBoard: ui.syncTaskStatusFromWorkspaceBoard === true, - statusBarItems: statusBarItemsWithGrok, - statusBarVisible: ui.statusBarVisible ?? true, - usagePercentageDisplay: normalizeUsagePercentageDisplay(ui.usagePercentageDisplay), - statusBarUsageMode: normalizeStatusBarUsageMode(ui.statusBarUsageMode), - // Why: default true so existing users see the pet on first enabling the flag; only an explicit Hide persists false. - petVisible: ui.petVisible ?? ui.sidekickVisible ?? true, - petSize: clampPetSize(ui.petSize ?? ui.sidekickSize ?? PET_SIZE_DEFAULT), - customPets, - // Why: fall back to default when the persisted id is unknown (e.g. custom pet removed elsewhere) so the overlay renders. - petId: ((): string => { - const id = petId - if (typeof id !== 'string') { - return DEFAULT_PET_ID - } - if (isBundledPetId(id)) { - return id - } - if (customPets.some((m) => m.id === id)) { - return id - } - return DEFAULT_PET_ID - })(), - dismissedUpdateVersion: ui.dismissedUpdateVersion ?? null, - // Why: a persisted value from a build that knew a different channel set - // would otherwise survive as-is; activeChannel only falls back on null, - // so an unknown string reaches listBuilds and the segmented control. - releaseChannelOverride: isReleaseChannel(ui.releaseChannelOverride) - ? ui.releaseChannelOverride - : null, - updateReassuranceSeen: ui.updateReassuranceSeen ?? false, - osc52ClipboardDefaultOnNoticePending: ui.osc52ClipboardDefaultOnNoticePending === true, - browserDefaultUrl: ui.browserDefaultUrl ?? null, - browserDefaultSearchEngine: ui.browserDefaultSearchEngine ?? null, - browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(ui.browserDefaultZoomLevel), - browserKagiSessionLink: normalizeKagiSessionLink(ui.browserKagiSessionLink ?? ''), - taskResumeState: sanitizeTaskResumeState(ui.taskResumeState), - featureTipsSeenIds: normalizeFeatureTipIds(ui.featureTipsSeenIds), - featureInteractions: normalizeFeatureInteractions(ui.featureInteractions), - contextualToursSeenIds: normalizeContextualTourIds(ui.contextualToursSeenIds), - contextualToursAutoEligible: - typeof ui.contextualToursAutoEligible === 'boolean' - ? ui.contextualToursAutoEligible - : null, - trustedOrcaHooks: hydrateTrustedOrcaHooks(ui.trustedOrcaHooks, validRepoIds), - setupScriptPromptDismissedRepoIds: - validRepoHostIdentities.size === 0 - ? sanitizeSetupScriptPromptDismissals(ui.setupScriptPromptDismissedRepoIds) - : filterSetupScriptPromptDismissalsToValidRepos( - ui.setupScriptPromptDismissedRepoIds, - validRepoHostIdentities - ), - setupGuideSidebarDismissed: ui.setupGuideSidebarDismissed === true, - setupGuideBrowserMilestoneMigrated: ui.setupGuideBrowserMilestoneMigrated === true, - setupGuideBrowserMilestoneLegacyComplete: - ui.setupGuideBrowserMilestoneLegacyComplete === true, - browserImportHintHidden: ui.browserImportHintHidden === true, - mobileEmulatorTabIntroDismissed: ui.mobileEmulatorTabIntroDismissed === true, - mobileEmulatorAgentSetupDismissed: ui.mobileEmulatorAgentSetupDismissed === true, - projectOrderManualDefaultNoticeDismissed: - ui.projectOrderManualDefaultNoticeDismissed === true, - // Why: treat only explicit true as dismissed so a false from migration still surfaces. - usagePercentageDisplayChangeNoticeDismissed: - ui.usagePercentageDisplayChangeNoticeDismissed === true, - // Why: default false so existing users still see the CTA; only explicit dismissal persists true. - usageEmptyStateDismissed: ui.usageEmptyStateDismissed === true, - // Why: stale acks are inert (paneKey reuse beats them via stateStartedAt); sanitizer bounds growth past HYDRATE_MAX_AGE_MS. - acknowledgedAgentsByPaneKey: sanitizeAcknowledgedAgentsByPaneKey( - ui.acknowledgedAgentsByPaneKey - ), - workspaceCleanupDismissals: sanitizeWorkspaceCleanupDismissals( - ui.workspaceCleanup?.dismissals - ), - // Why the normalizer rather than a cast: this blob is hand-editable and - // may come from an older or newer build; it degrades field by field - // instead of bricking the cleanup dialog. - // Why: a sync broadcast can carry stale browse state while its writer is debounced. - workspaceCleanupBrowse: - source === 'startup' - ? normalizeWorkspaceCleanupBrowseState(ui.workspaceCleanup?.browse) - : s.workspaceCleanupBrowse, - // Why: restore only on startup; on 'sync' broadcasts it would clobber the window's current per-window view. - activeView: - source === 'startup' - ? sanitizeHydratedActiveView(ui.activeView, s.settings?.experimentalActivity === true) - : s.activeView, - persistedUIReady: true - } - // The incoming payload is authoritative for the writer-owned fields, so it becomes the - // writer's new diff baseline — but fields with an unflushed local edit (mirror diverged - // from the previous baseline) keep the local value so a broadcast arriving inside the - // writer's debounce window can't silently revert what the user just toggled (STA-5781). - // Order matters: capture the baseline BEFORE overlaying pending edits, or the baseline - // would equal the pending value, the diff would go empty, and the toggle would be dropped. - // Note the width sanitizers above fall back to the CURRENT store value only for - // non-numeric input (numbers are clamped in place), so a captured width can differ - // from what main holds only for garbage payloads; at worst main keeps an - // out-of-range width until the next drag re-writes it. - const nextWriteBaseline = capturePersistedUIWriteBaseline(hydrated) - const previousBaseline = s.persistedUIWriteBaseline - if (previousBaseline) { - const pendingLocalEdits = diffPersistedUIWriteFields( - capturePersistedUIWriteBaseline(s), - previousBaseline - ) - Object.assign(hydrated, pendingLocalEdits) - // In-flight fields too: a flip-back to the baseline value diffs empty, - // yet the in-flight write's echo must not revert it (PR#17057 review). - for (const field of Object.keys( - s.persistedUIWriteInFlightCounts - ) as (keyof PersistedUIWriteBaseline)[]) { - ;(hydrated as Record)[field] = s[field] - } - } - // Why: return the same ref on identical hydration so App's debounced writer doesn't echo it back to main. - // The baseline must still advance when it moved (a remote same-field write during an in-flight - // ack pins the only visibly differing field, and our own echo precedes every ack) — but only - // the two baseline keys, or every ordinary write's echo would churn the store's collection - // identities and re-render identity-compared selectors once per write. - // Why the generation bumps only on baseline movement: an unrelated-field - // broadcast during an in-flight write would otherwise void that write's - // fold and cost a redundant trailing re-send of identical values. - const writeBaselineMoved = - !previousBaseline || - Object.keys(diffPersistedUIWriteFields(nextWriteBaseline, previousBaseline)).length > 0 - const nextWriteBaselineGeneration = writeBaselineMoved - ? s.persistedUIWriteBaselineGeneration + 1 - : s.persistedUIWriteBaselineGeneration - if (hydratedUIPartialMatchesState(s, hydrated)) { - if (!writeBaselineMoved) { - return s - } - return { - persistedUIWriteBaseline: nextWriteBaseline, - persistedUIWriteBaselineGeneration: nextWriteBaselineGeneration - } - } - return { - ...hydrated, - persistedUIWriteBaseline: nextWriteBaseline, - persistedUIWriteBaselineGeneration: nextWriteBaselineGeneration - } - }), - - updateStatus: { state: 'idle' }, - setUpdateStatus: (status) => { - const prevState = get().updateStatus.state - const update: Partial< - Pick< - UISlice, - 'updateStatus' | 'updateChangelog' | 'updateCardCollapsed' | 'updateUserInitiatedCycle' - > - > = { - updateStatus: status - } - if (status.state === 'checking') { - update.updateUserInitiatedCycle = status.userInitiated === true - } else if (status.state === 'idle') { - update.updateUserInitiatedCycle = false - } - if (status.state === 'available') { - // Why: always overwrite (even with null) so a prior version's changelog can't leak into a later simple-mode update. - update.updateChangelog = status.changelog ?? null - } else if ( - status.state === 'idle' || - status.state === 'checking' || - status.state === 'not-available' - ) { - // Why: reset on cycle-boundary states so stale rich content from a previous cycle can't resurface. - update.updateChangelog = null - } - // 'downloading'/'downloaded'/'error': leave updateChangelog untouched to keep the original 'available' content. - if (status.state !== prevState) { - // Why: re-surface the card on each phase transition so a collapsed `downloading` doesn't bury `downloaded`/`error`. - update.updateCardCollapsed = false - } - set(update) - }, - updateChangelog: null, - updateUserInitiatedCycle: false, - dismissedUpdateVersion: null, - clearDismissedUpdateVersion: () => { - set({ dismissedUpdateVersion: null }) - }, - releaseChannelOverride: null, - setReleaseChannelOverride: (channel) => { - void window.api.ui.set({ releaseChannelOverride: channel }).catch(console.error) - set({ releaseChannelOverride: channel }) - }, - dismissUpdate: (versionOverride?: string) => - set((s) => { - // Why: the 'error' variant has no version field, so the card passes it via versionOverride. - const dismissedUpdateVersion = - versionOverride ?? ('version' in s.updateStatus ? (s.updateStatus.version ?? null) : null) - const activeNudgeId = - 'activeNudgeId' in s.updateStatus ? (s.updateStatus.activeNudgeId ?? null) : null - // Why: persist dismissal so relaunch doesn't immediately re-show the same card until a newer release. - void window.api.ui.set({ dismissedUpdateVersion }).catch(console.error) - // Why: main can't otherwise tell an offered update was abandoned, which keeps a local-build session pinned and stalls background checks. - void window.api.updater.dismissAvailableUpdate().catch(console.error) - // Why: only consume the nudge campaign for cards from a nudge cycle, not ordinary dismissals. - if (activeNudgeId) { - void window.api.updater.dismissNudge().catch(console.error) - } - return { dismissedUpdateVersion, updateUserInitiatedCycle: false } - }), - updateCardCollapsed: false, - setUpdateCardCollapsed: (collapsed) => set({ updateCardCollapsed: collapsed }), - updateReassuranceSeen: false, - markUpdateReassuranceSeen: () => { - void window.api.ui.set({ updateReassuranceSeen: true }).catch(console.error) - set({ updateReassuranceSeen: true }) - }, - osc52ClipboardDefaultOnNoticePending: false, - clearOsc52ClipboardDefaultOnNotice: () => { - // Why clear locally first: a failed persist must not re-toast this session. It will - // re-arm on the next launch, which is the safe direction for a one-shot notice. - set({ osc52ClipboardDefaultOnNoticePending: false }) - void window.api.ui.set({ osc52ClipboardDefaultOnNoticePending: false }).catch(console.error) - }, - isFullScreen: false, - setIsFullScreen: (v) => set({ isFullScreen: v }), - browserDefaultUrl: null, - setBrowserDefaultUrl: (url) => { - void window.api.ui.set({ browserDefaultUrl: url }).catch(console.error) - set({ browserDefaultUrl: url }) - }, - browserDefaultSearchEngine: null, - setBrowserDefaultSearchEngine: (engine) => { - void window.api.ui.set({ browserDefaultSearchEngine: engine }).catch(console.error) - set({ browserDefaultSearchEngine: engine }) - }, - browserDefaultZoomLevel: DEFAULT_BROWSER_PAGE_ZOOM_LEVEL, - setBrowserDefaultZoomLevel: (level) => { - const normalized = normalizeBrowserPageZoomLevel(level) - void window.api.ui.set({ browserDefaultZoomLevel: normalized }).catch(console.error) - set({ browserDefaultZoomLevel: normalized }) - }, - browserKagiSessionLink: null, - setBrowserKagiSessionLink: (link) => { - const normalized = link ? normalizeKagiSessionLink(link) : null - void window.api.ui.set({ browserKagiSessionLink: normalized }).catch(console.error) - set({ browserKagiSessionLink: normalized }) - } -}) +import type { UISlice } from './ui/ui-slice-contract' +import { createUiAgentActions } from './ui/ui-slice-agent-actions' +import { createUiTaskActions } from './ui/ui-slice-task-actions' +import { createUiViewActions } from './ui/ui-slice-view-actions' +import { createUiSettingsActions } from './ui/ui-slice-settings-actions' +import { createUiModalActions } from './ui/ui-slice-modal-actions' +import { createUiFeatureActions } from './ui/ui-slice-feature-actions' +import { createUiTourActions } from './ui/ui-slice-tour-actions' +import { createUiTrustActions } from './ui/ui-slice-trust-actions' +import { createUiPreferenceActions } from './ui/ui-slice-preference-actions' +import { createUiSurfaceActions } from './ui/ui-slice-surface-actions' +import { createUiPersistenceActions } from './ui/ui-slice-persistence-actions' +import { createUiHydrationActions } from './ui/ui-slice-hydration-actions' +import { createUiUpdateActions } from './ui/ui-slice-update-actions' + +export type { + AgentSendPopoverTargetMode, + NewWorkspaceDraft, + OpenAgentSendPopoverTargetModeArgs, + PendingSidebarRowReveal, + PendingSidebarWorktreeReveal, + TaskPageData, + UISlice +} from './ui/ui-slice-contract' + +export const createUISlice: StateCreator = (set, get) => + ({ + ...createUiAgentActions(set, get), + ...createUiTaskActions(set, get), + ...createUiViewActions(set, get), + ...createUiSettingsActions(set, get), + ...createUiModalActions(set, get), + ...createUiFeatureActions(set, get), + ...createUiTourActions(set, get), + ...createUiTrustActions(set, get), + ...createUiPreferenceActions(set, get), + ...createUiSurfaceActions(set, get), + ...createUiPersistenceActions(set, get), + ...createUiHydrationActions(set, get), + ...createUiUpdateActions(set, get) + }) as UISlice diff --git a/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts new file mode 100644 index 00000000000..166ea56c007 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts @@ -0,0 +1,284 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' +import { formatAgentTypeLabel, agentKindForAgentType } from '../../../lib/agent-status' +import { + deriveRunningAgentSendTargets, + resolveRunningAgentSendTarget +} from '../../../lib/running-agent-targets' +import { translate } from '@/i18n/i18n' +import { + collectAcknowledgedAgentNotificationId, + createAgentSendTargetModeInstanceId, + latestAgentTurnTimestamp, + resolvePaneKeyWorktreeIdFromTabs, + usableTimestamp +} from './ui-slice-agent-helpers' + +export function createUiAgentActions( + set: UISliceSet, + get: UISliceGet +): Pick< + UISlice, + | 'sidebarOpen' + | 'sidebarWidth' + | 'toggleSidebar' + | 'setSidebarOpen' + | 'setSidebarWidth' + | 'agentSendPopoverTargetMode' + | 'openAgentSendPopoverTargetMode' + | 'closeAgentSendPopoverTargetMode' + | 'sendPromptToSidebarAgentTarget' + | 'diffNotesSendMenuOpenRequest' + | 'openDiffNotesSendMenuForActiveWorktree' + | 'consumeDiffNotesSendMenuOpenRequest' + | 'acknowledgedAgentsByPaneKey' + | 'acknowledgeAgents' + | 'unacknowledgeAgents' +> { + return { + sidebarOpen: true, + sidebarWidth: 280, + toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })), + setSidebarOpen: (open) => set({ sidebarOpen: open }), + setSidebarWidth: (width) => set({ sidebarWidth: width }), + agentSendPopoverTargetMode: null, + openAgentSendPopoverTargetMode: (args) => { + const targets = deriveRunningAgentSendTargets(get(), args.worktreeId) + const previousMode = get().agentSendPopoverTargetMode + if (previousMode?.id === args.id && previousMode.status === 'sending') { + return + } + const disabledPaneKeys: Record = {} + for (const target of targets) { + if (target.status === 'disabled' && target.disabledReason) { + disabledPaneKeys[target.paneKey] = target.disabledReason + } + } + set({ + agentSendPopoverTargetMode: { + ...args, + instanceId: createAgentSendTargetModeInstanceId(), + eligiblePaneKeys: targets + .filter((target) => target.status === 'eligible') + .map((target) => target.paneKey), + disabledPaneKeys, + status: 'open' + } + }) + if ( + targets.some((target) => target.status === 'eligible') && + (previousMode?.id !== args.id || previousMode.worktreeId !== args.worktreeId) + ) { + get().revealWorktreeInSidebar(args.worktreeId, { behavior: 'auto', highlight: true }) + } + }, + diffNotesSendMenuOpenRequest: null, + openDiffNotesSendMenuForActiveWorktree: () => { + const worktreeId = get().activeWorktreeId + if (!worktreeId) { + return false + } + // Why: no unsent notes means nothing to send, so don't hijack focus or reveal the panel. + if ( + !get() + .getDiffComments(worktreeId) + .some((comment) => !comment.sentAt) + ) { + return false + } + get().setRightSidebarTab('source-control') + get().setRightSidebarOpen(true) + const nonce = (get().diffNotesSendMenuOpenRequest?.nonce ?? 0) + 1 + set({ diffNotesSendMenuOpenRequest: { worktreeId, nonce, issuedAt: Date.now() } }) + return true + }, + consumeDiffNotesSendMenuOpenRequest: (worktreeId) => + set((s) => + s.diffNotesSendMenuOpenRequest?.worktreeId === worktreeId + ? { diffNotesSendMenuOpenRequest: null } + : s + ), + closeAgentSendPopoverTargetMode: (id, instanceId) => + set((s) => { + if (!s.agentSendPopoverTargetMode) { + return s + } + if (id && s.agentSendPopoverTargetMode.id !== id) { + return s + } + if (instanceId && s.agentSendPopoverTargetMode.instanceId !== instanceId) { + return s + } + return { agentSendPopoverTargetMode: null } + }), + sendPromptToSidebarAgentTarget: async (paneKey) => { + const mode = get().agentSendPopoverTargetMode + if (!mode || mode.status === 'sending') { + return false + } + + const target = resolveRunningAgentSendTarget(get(), mode.worktreeId, paneKey) + if (!target || target.status !== 'eligible' || !target.ptyId) { + // Why: eligibility can drop after the menu opened; keep the picker open (row title explains) rather than adding toast noise. + return false + } + + set((s) => + s.agentSendPopoverTargetMode?.id === mode.id && + s.agentSendPopoverTargetMode.instanceId === mode.instanceId + ? { + agentSendPopoverTargetMode: { + ...s.agentSendPopoverTargetMode, + status: 'sending', + sendingPaneKey: paneKey, + error: undefined + } + } + : s + ) + + const label = formatAgentTypeLabel(target.entry.agentType) + const { activeAgentNotesSendFailureMessage, sendNotesToActiveAgentSession } = + await import('@/lib/active-agent-note-send') + const result = await sendNotesToActiveAgentSession({ + worktreeId: mode.worktreeId, + prompt: mode.prompt, + noteTarget: { tabId: target.tabId, leafId: target.leafId } + }).catch((error) => { + console.error('Failed to send notes to sidebar agent target:', error) + return { status: 'no-active-terminal' as const } + }) + + const stillCurrent = (): boolean => { + const current = get().agentSendPopoverTargetMode + return current?.id === mode.id && current.instanceId === mode.instanceId + } + + if (!stillCurrent()) { + return false + } + + if (result.status !== 'sent') { + const message = activeAgentNotesSendFailureMessage(result.status, { explicitTarget: true }) + set((s) => + s.agentSendPopoverTargetMode?.id === mode.id && + s.agentSendPopoverTargetMode.instanceId === mode.instanceId + ? { + agentSendPopoverTargetMode: { + ...s.agentSendPopoverTargetMode, + status: 'error', + sendingPaneKey: undefined, + error: message + } + } + : s + ) + const { toast } = await import('sonner') + if (!stillCurrent()) { + return false + } + toast.error( + translate('auto.store.slices.ui.53883b7bc3', "Couldn't send to {{value0}}", { + value0: label + }), + { description: message } + ) + return false + } + + const [{ toast }, { track }] = await Promise.all([ + import('sonner'), + import('@/lib/telemetry') + ]) + if (!stillCurrent()) { + return false + } + mode.onPromptDelivered?.() + track('agent_prompt_sent', { + agent_kind: agentKindForAgentType(target.entry.agentType), + launch_source: mode.launchSource, + request_kind: 'followup' + }) + toast.success( + translate('auto.store.slices.ui.66e3bd7ce6', 'Sent to {{value0}}', { value0: label }) + ) + get().closeAgentSendPopoverTargetMode(mode.id, mode.instanceId) + return true + }, + + acknowledgedAgentsByPaneKey: {}, + acknowledgeAgents: (paneKeys) => { + const notificationIdsToDismiss = new Set() + set((s) => { + if (paneKeys.length === 0) { + return s + } + const now = Date.now() + const migrationUnsupported = Object.values(s.migrationUnsupportedByPtyId ?? {}) + // Why: only reallocate if an ack advances; compare prev | null = null + for (const key of paneKeys) { + const prev = s.acknowledgedAgentsByPaneKey[key] ?? 0 + // Why not plain Date.now(): a remote/SSH execution host can stamp a turn ahead of this clock, + // and every unread rule is `ackAt < turnTimestamp`. A behind-the-turn ack can never clear the + // row, so its auto-ack effect re-fires on each new millisecond forever (React #185). + let stamp = now + const liveEntry = s.agentStatusByPaneKey?.[key] + if (liveEntry) { + collectAcknowledgedAgentNotificationId({ + ids: notificationIdsToDismiss, + worktreeId: resolvePaneKeyWorktreeIdFromTabs(s, key) ?? liveEntry.worktreeId, + paneKey: key, + stateStartedAt: liveEntry.stateStartedAt, + previousAckAt: prev + }) + stamp = Math.max(stamp, latestAgentTurnTimestamp(liveEntry)) + } + const retained = s.retainedAgentsByPaneKey?.[key] + if (retained) { + collectAcknowledgedAgentNotificationId({ + ids: notificationIdsToDismiss, + worktreeId: retained.worktreeId, + paneKey: key, + stateStartedAt: retained.entry.stateStartedAt, + previousAckAt: prev + }) + stamp = Math.max(stamp, latestAgentTurnTimestamp(retained.entry)) + } + for (const unsupported of migrationUnsupported) { + // Why: Activity synthesizes a blocked row from this entry, stamped by the pane's host like any turn. + if (unsupported.paneKey === key) { + stamp = Math.max(stamp, usableTimestamp(unsupported.updatedAt)) + } + } + if (prev < stamp) { + if (next === null) { + next = { ...s.acknowledgedAgentsByPaneKey } + } + next[key] = stamp + } + } + return next ? { acknowledgedAgentsByPaneKey: next } : s + }) + const notificationIds = [...notificationIdsToDismiss] + if (notificationIds.length > 0 && typeof window !== 'undefined') { + void window.api?.notifications?.dismiss?.(notificationIds) + } + }, + unacknowledgeAgents: (paneKeys) => + set((s) => { + if (paneKeys.length === 0) { + return s + } + let next: Record | null = null + for (const key of paneKeys) { + if (s.acknowledgedAgentsByPaneKey[key] !== undefined) { + if (next === null) { + next = { ...s.acknowledgedAgentsByPaneKey } + } + delete next[key] + } + } + return next ? { acknowledgedAgentsByPaneKey: next } : s + }) + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-agent-helpers.ts b/src/renderer/src/store/slices/ui/ui-slice-agent-helpers.ts new file mode 100644 index 00000000000..b9b578d5476 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-agent-helpers.ts @@ -0,0 +1,62 @@ +import type { AppState } from '../../types' +import { buildAgentNotificationId } from '../../../../../shared/agent-notification-id' +import { parsePaneKey } from '../../../../../shared/stable-pane-id' + +export function resolvePaneKeyWorktreeIdFromTabs(state: AppState, paneKey: string): string | null { + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return null + } + for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree ?? {})) { + if (tabs.some((tab) => tab.id === parsed.tabId)) { + return worktreeId + } + } + return null +} + +export function collectAcknowledgedAgentNotificationId({ + ids, + worktreeId, + paneKey, + stateStartedAt, + previousAckAt +}: { + ids: Set + worktreeId: string | null | undefined + paneKey: string + stateStartedAt: number | null | undefined + previousAckAt: number +}): void { + if (typeof stateStartedAt !== 'number' || previousAckAt >= stateStartedAt) { + return + } + const id = buildAgentNotificationId({ worktreeId, paneKey, stateStartedAt }) + if (id) { + ids.add(id) + } +} + +export function usableTimestamp(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0 +} + +/** Newest turn timestamp an unread check can compare against for one agent row. */ +export function latestAgentTurnTimestamp(entry: { + stateStartedAt?: number + stateHistory?: { startedAt?: number }[] +}): number { + let latest = usableTimestamp(entry.stateStartedAt) + // Why history too: Activity renders one event per stateHistory entry, each with its own unread check. + for (const history of entry.stateHistory ?? []) { + latest = Math.max(latest, usableTimestamp(history.startedAt)) + } + return latest +} + +let agentSendTargetModeInstanceCounter = 0 + +export function createAgentSendTargetModeInstanceId(): string { + agentSendTargetModeInstanceCounter += 1 + return `${Date.now()}:${agentSendTargetModeInstanceCounter}` +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts new file mode 100644 index 00000000000..0f997f5aeea --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-contextual.ts @@ -0,0 +1,117 @@ +import type { PersistedTrustedOrcaHooks } from '../../../../../shared/orca-yaml-hook-types' +import type { + FeatureInteractionId, + FeatureInteractionState +} from '../../../../../shared/feature-interactions' +import type { FeatureTipId } from '../../../../../shared/feature-tips' +import type { ContextualTourId } from '../../../../../shared/contextual-tours' +import type { OrcaHookScriptKind } from '../../../lib/orca-hook-trust' +import type { SettingsNavigationTarget } from '../../../lib/settings-navigation-types' +import type { ExecutionHostId } from '../../../../../shared/execution-host' + +export type UISliceContextual = { + openSettingsPage: () => void + closeSettingsPage: () => void + settingsNavigationTarget: SettingsNavigationTarget | null + openSettingsTarget: (target: NonNullable) => void + clearSettingsTarget: () => void + /** Which host the Projects Settings pane shows per project (keyed by projectId). Ephemeral on purpose — never persisted, so reload reopens on the effective host. */ + settingsProjectHostSelection: Record + settingsProjectSetupSelection: Record + setSettingsProjectHostSelection: ( + projectId: string, + hostId: ExecutionHostId, + setupId?: string + ) => void + /** One-shot Appearance accordion to expand for nested Settings deep links (e.g. Usage percentages under Window & Sidebar). Cleared when Appearance consumes it. */ + appearanceAccordionDeepLink: 'interface' | 'terminal' | 'window' | null + setAppearanceAccordionDeepLink: ( + section: NonNullable + ) => void + clearAppearanceAccordionDeepLink: () => void + activeModal: + | 'none' + | 'create-worktree' + | 'edit-meta' + | 'delete-worktree' + | 'preserved-branch-review' + | 'forget-ssh-workspace' + | 'confirm-add-project-from-folder' + | 'confirm-non-git-folder' + | 'confirm-remove-folder' + | 'add-repo' + | 'quick-open' + | 'worktree-palette' + | 'workspace-cleanup' + | 'project-added' + | 'worktree-visibility' + | 'setup-guide' + | 'feature-wall' + | 'feature-tips' + | 'new-workspace-composer' + | 'confirm-orca-yaml-hooks' + modalData: Record + openModal: (modal: UISliceContextual['activeModal'], data?: Record) => void + closeModal: () => void + featureTipsSeenIds: FeatureTipId[] + markFeatureTipsSeen: (ids: FeatureTipId[]) => void + featureInteractions: FeatureInteractionState + recordFeatureInteraction: (id: FeatureInteractionId) => Promise + contextualToursSeenIds: ContextualTourId[] + contextualToursAutoEligible: boolean | null + activeContextualTourId: ContextualTourId | null + activeContextualTourStepIndex: number + activeContextualTourSource: string | null + activeContextualTourSourceDetached: boolean + activeContextualTourWasFeaturePreviouslyInteracted: boolean + contextualTourNavigationInteractionSnapshot: Partial> + activeContextualTourSuppressed: boolean + contextualTourShownThisSession: boolean + contextualToursOnboardingVisible: boolean + contextualToursBlockingSurfaceVisible: boolean + lastCompletedContextualTourId: ContextualTourId | null + setContextualToursAutoEligible: (eligible: boolean) => void + setContextualToursOnboardingVisible: (visible: boolean) => void + setContextualToursBlockingSurfaceVisible: (visible: boolean) => void + requestContextualTour: ( + id: ContextualTourId, + source: string, + wasFeaturePreviouslyInteracted?: boolean, + options?: { force?: boolean } + ) => void + suppressContextualTour: (id: ContextualTourId, source: string) => void + detachContextualTourSource: (id: ContextualTourId, source: string) => void + advanceContextualTour: () => void + regressContextualTour: () => void + dismissContextualTour: (id?: ContextualTourId) => void + completeContextualTour: (id?: ContextualTourId) => void + cancelContextualTour: (id?: ContextualTourId) => void + markContextualToursSeen: (ids: ContextualTourId[]) => void + trustedOrcaHooks: PersistedTrustedOrcaHooks + markOrcaHookScriptConfirmed: ( + repoId: string, + kind: OrcaHookScriptKind, + contentHash: string + ) => void + markOrcaHookRepoAlwaysTrusted: (repoId: string) => void + clearOrcaHookTrustForRepo: (repoId: string) => void + setupScriptPromptDismissedRepoIds: readonly string[] + dismissSetupScriptPrompt: (repoHostIdentity: string) => void + setupGuideSidebarDismissed: boolean + setSetupGuideSidebarDismissed: (dismissed: boolean) => void + setupGuideBrowserMilestoneMigrated: boolean + setupGuideBrowserMilestoneLegacyComplete: boolean + markSetupGuideBrowserMilestoneMigrated: (legacyComplete: boolean) => void + browserImportHintHidden: boolean + setBrowserImportHintHidden: (hidden: boolean) => void + mobileEmulatorTabIntroDismissed: boolean + dismissMobileEmulatorTabIntro: () => void + mobileEmulatorAgentSetupDismissed: boolean + dismissMobileEmulatorAgentSetup: () => void + projectOrderManualDefaultNoticeDismissed: boolean + dismissProjectOrderManualDefaultNotice: () => void + usagePercentageDisplayChangeNoticeDismissed: boolean + dismissUsagePercentageDisplayChangeNotice: () => void + usageEmptyStateDismissed: boolean + dismissUsageEmptyState: () => void +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-core.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-core.ts new file mode 100644 index 00000000000..cd2af73b123 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-core.ts @@ -0,0 +1,211 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../../types' +import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' +import type { GitLabWorkItem } from '../../../../../shared/gitlab-types' +import type { JiraIssue } from '../../../../../shared/jira-types' +import type { LinearIssue } from '../../../../../shared/linear/issue-types' +import type { TaskProvider } from '../../../../../shared/task-providers' +import type { TuiAgent } from '../../../../../shared/tui-agent' +import type { LaunchSource } from '../../../../../shared/telemetry-events' +import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import type { ExecutionHostId } from '../../../../../shared/execution-host' +import type { TaskResumeState, TopLevelView } from '../../../../../shared/ui-chrome-types' + +export type PendingSidebarWorktreeReveal = { + worktreeId: string + executionHostId?: ExecutionHostId + behavior: 'auto' | 'smooth' + highlight?: boolean + beginRename?: boolean +} + +export type PendingSidebarRowReveal = { + rowKey: string + behavior: 'auto' | 'smooth' + highlight?: boolean +} + +export type AgentSendPopoverTargetMode = { + id: string + instanceId: string + worktreeId: string + source: 'diff-notes' | 'browser-annotations' + prompt: string + label: string + launchSource: LaunchSource + eligiblePaneKeys: string[] + disabledPaneKeys: Record + status: 'open' | 'sending' | 'error' + sendingPaneKey?: string + error?: string + onPromptDelivered?: () => void +} + +export type OpenAgentSendPopoverTargetModeArgs = { + id: string + worktreeId: string + source: AgentSendPopoverTargetMode['source'] + prompt: string + label: string + launchSource: LaunchSource + onPromptDelivered?: () => void +} + +export type TaskPageData = { + preselectedRepoId?: string + prefilledName?: string + taskSource?: TaskProvider + openGitHubWorkItem?: GitHubWorkItem + openGitHubSourceContext?: TaskSourceContext | null + openGitHubInitialTab?: 'conversation' | 'checks' | 'files' + openGitLabWorkItem?: GitLabWorkItem + openGitLabSourceContext?: TaskSourceContext | null + openLinearIssue?: LinearIssue + openLinearSourceContext?: TaskSourceContext | null + openJiraIssue?: JiraIssue + openJiraSourceContext?: TaskSourceContext | null +} + +export type NewWorkspaceDraft = { + repoId: string | null + // Why: project-first creation uses these when present; old drafts keep using only repoId during the additive migration. + projectId?: string | null + projectGroupId?: string | null + hostId?: ExecutionHostId | null + projectHostSetupId?: string | null + name: string + prompt: string + note: string + attachments: string[] + linkedWorkItem: { + provider?: 'github' | 'gitlab' | 'linear' | 'jira' + type: 'issue' | 'pr' | 'mr' + number: number + title: string + url: string + linearIdentifier?: string + linearBranchName?: string + jiraIdentifier?: string + repoId?: string + } | null + /** Preserve where provider data came from, separately from the host chosen to run the workspace. */ + taskSourceContext?: TaskSourceContext | null + linkedTaskSourceContext?: TaskSourceContext | null + agent: TuiAgent + linkedIssue: string + linkedPR: number | null + /** GitLab parallels — number for an issue, iid for an MR. Optional so pre-GitLab drafts still load without migration. */ + linkedGitLabIssue?: number | null + linkedGitLabMR?: number | null + // Why: repo-scoped start ref from the "Start from" picker; absent means "use the repo's effective base ref". + baseBranch?: string + // Why: review worktrees start from a head ref/SHA while Source Control compares against the provider target branch. + compareBaseRef?: string +} + +export type UiViewHistory = + | 'terminal' + | 'settings' + | 'tasks' + | 'activity' + | 'automations' + | 'space' + | 'skills' + | 'artifacts' + | 'mobile' + +export type UISliceCore = { + sidebarOpen: boolean + sidebarWidth: number + toggleSidebar: () => void + setSidebarOpen: (open: boolean) => void + setSidebarWidth: (width: number) => void + agentSendPopoverTargetMode: AgentSendPopoverTargetMode | null + openAgentSendPopoverTargetMode: (args: OpenAgentSendPopoverTargetModeArgs) => void + closeAgentSendPopoverTargetMode: (id?: string, instanceId?: string) => void + sendPromptToSidebarAgentTarget: (paneKey: string) => Promise + /** Bumped to ask the active worktree's Source Control notes send menu to open (keyboard shortcut). `issuedAt` bounds staleness so a request the menu never consumed can't reopen it much later. */ + diffNotesSendMenuOpenRequest: { worktreeId: string; nonce: number; issuedAt: number } | null + /** Reveal Source Control and request its notes send menu open; returns false (no-op) when the active worktree has no unsent notes. */ + openDiffNotesSendMenuForActiveWorktree: () => boolean + consumeDiffNotesSendMenuOpenRequest: (worktreeId: string) => void + /** Per-agent "I've looked at this" timestamps (paneKey → ts). A row is unvisited when no ack exists or stateStartedAt is newer than the last ack. Persisted so visited rows don't return bold on relaunch. */ + acknowledgedAgentsByPaneKey: Record + acknowledgeAgents: (paneKeys: string[]) => void + unacknowledgeAgents: (paneKeys: string[]) => void + activeView: TopLevelView + previousViewBeforeTasks: Exclude + previousViewBeforeSettings: Exclude + previousViewBeforeActivity: Exclude + previousViewBeforeAutomations: Exclude + previousViewBeforeSpace: Exclude + previousViewBeforeSkills: Exclude + previousViewBeforeMobile: Exclude + previousViewBeforeArtifacts: Exclude + setActiveView: (view: UISliceCore['activeView']) => void + taskPageData: TaskPageData + taskResumeState: TaskResumeState | undefined + setTaskResumeState: (updates: Partial) => void + taskListPosition: { contextKey: string; page: number; scrollTop: number } | null + setTaskListPosition: (position: UISliceCore['taskListPosition']) => void + githubTaskDrawerWorkItem: GitHubWorkItem | null + setGithubTaskDrawerWorkItem: (item: GitHubWorkItem | null) => void + newWorkspaceDraft: NewWorkspaceDraft | null + openTaskPage: ( + data?: UISliceCore['taskPageData'], + options?: { recordTasksInteraction?: boolean } + ) => void + closeTaskPage: () => void + openActivityPage: () => void + closeActivityPage: () => void + selectedAutomationId: string | null + setSelectedAutomationId: (id: string | null) => void + pendingAutomationRunNavigation: { + automationId: string + runId: string | null + hostId?: ExecutionHostId + } | null + setPendingAutomationRunNavigation: ( + navigation: { automationId: string; runId: string | null; hostId?: ExecutionHostId } | null + ) => void + openAutomationsPage: () => void + closeAutomationsPage: () => void + openSpacePage: () => void + closeSpacePage: () => void + openSkillsPage: () => void + closeSkillsPage: () => void + pendingSkillShareId: string | null + openSkillShare: (shareId: string) => void + clearPendingSkillShare: () => void + /** Set when another surface links straight to the page's shared-links view. */ + pendingSkillsSharedView: boolean + openSkillsSharedLinks: () => void + clearPendingSkillsSharedView: () => void + openArtifactsPage: () => void + closeArtifactsPage: () => void + openMobilePage: () => void + closeMobilePage: () => void + setNewWorkspaceDraft: (draft: NonNullable) => void + clearNewWorkspaceDraft: () => void + pendingRevealWorktree: PendingSidebarWorktreeReveal | null + pendingRevealSidebarRow: PendingSidebarRowReveal | null + revealWorktreeInSidebar: ( + worktreeId: string, + options?: { + behavior?: PendingSidebarWorktreeReveal['behavior'] + highlight?: boolean + beginRename?: boolean + executionHostId?: ExecutionHostId + } + ) => void + revealSidebarRow: ( + rowKey: string, + options?: { + behavior?: PendingSidebarRowReveal['behavior'] + highlight?: boolean + } + ) => void +} + +export type UISliceSet = Parameters>[0] +export type UISliceGet = Parameters>[1] diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts new file mode 100644 index 00000000000..8d7561b7642 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts @@ -0,0 +1,182 @@ +import type { PersistedUIState } from '../../../../../shared/persisted-ui-state-types' +import type { + AgentActivityDisplayMode, + ManualRepoOrderEntry, + ProjectOrderBy, + StatusBarItem, + WorktreeCardMode, + WorktreeCardProperty, + WorkspaceHostOrder +} from '../../../../../shared/ui-chrome-types' +import type { UsagePercentageDisplay } from '../../../../../shared/usage-percentage-display' +import type { AutomationHostFilter } from '../../../../../shared/automation-host-filter' +import type { WorkspaceStatusDefinition } from '../../../../../shared/worktree/types' +import type { WorkspacePortScanResult } from '../../../../../shared/workspace-ports' +import type { CustomPet } from '../../../../../shared/pet-types' +import type { ReleaseChannel } from '../../../../../shared/release-channel' +import type { ChangelogData, UpdateStatus } from '../../../../../shared/update-status-types' +import type { StatusBarUsageMode } from '../../../../../shared/status-bar-usage-mode' +import type { PersistedUIWriteBaseline } from '../persisted-ui-write-baseline' +import type { + WorkspaceHostScope, + VisibleWorkspaceHostIds +} from '../../../../../shared/ui-chrome-types' +import type { UISliceCore } from './ui-slice-contract-core' + +export type UISlicePreferences = { + groupBy: 'none' | 'workspace-status' | 'repo' | 'pr-status' + setGroupBy: (g: UISlicePreferences['groupBy']) => void + sortBy: 'name' | 'smart' | 'recent' | 'repo' | 'manual' + setSortBy: (s: UISlicePreferences['sortBy']) => void + projectOrderBy: ProjectOrderBy + setProjectOrderBy: (p: ProjectOrderBy) => void + showActiveOnly: boolean + setShowActiveOnly: (v: boolean) => void + showSleepingWorkspaces: boolean + setShowSleepingWorkspaces: (v: boolean) => void + workspaceHostScope: WorkspaceHostScope + setWorkspaceHostScope: (scope: WorkspaceHostScope) => void + visibleWorkspaceHostIds: VisibleWorkspaceHostIds + setVisibleWorkspaceHostIds: (ids: VisibleWorkspaceHostIds) => void + workspaceHostOrder: WorkspaceHostOrder + setWorkspaceHostOrder: (ids: WorkspaceHostOrder) => void + /** Automations page host filter, in stable form. Never written from an unhydrated catalog. */ + automationHostFilter: AutomationHostFilter + setAutomationHostFilter: (filter: AutomationHostFilter) => void + manualRepoOrder: ManualRepoOrderEntry[] + hideDefaultBranchWorkspace: boolean + setHideDefaultBranchWorkspace: (v: boolean) => void + hideAutomationGeneratedWorkspaces: boolean + setHideAutomationGeneratedWorkspaces: (v: boolean) => void + hideCliCreatedWorkspaces: boolean + setHideCliCreatedWorkspaces: (v: boolean) => void + hideDetachedHeadWorkspaces: boolean + setHideDetachedHeadWorkspaces: (v: boolean) => void + hideWorkspacesFromOtherDevices: boolean + setHideWorkspacesFromOtherDevices: (v: boolean) => void + alwaysShowDefaultBranchWorkspace: boolean + setAlwaysShowDefaultBranchWorkspace: (v: boolean) => void + showDotfilesByWorktree: Record + setShowDotfilesForWorktree: (worktreeId: string, showDotfiles: boolean) => void + toggleShowDotfilesForWorktree: (worktreeId: string) => void + filterRepoIds: readonly string[] + setFilterRepoIds: (ids: readonly string[]) => void + collapsedGroups: Set + toggleCollapsedGroup: (key: string) => void + worktreeCardProperties: WorktreeCardProperty[] + _worktreeCardModeDefaulted: boolean + setWorktreeCardMode: (mode: WorktreeCardMode) => void + setWorktreeCardProperties: (properties: readonly WorktreeCardProperty[]) => void + agentActivityDisplayMode: AgentActivityDisplayMode + setAgentActivityDisplayMode: (mode: AgentActivityDisplayMode) => void + workspaceStatuses: WorkspaceStatusDefinition[] + setWorkspaceStatuses: (statuses: WorkspaceStatusDefinition[]) => void + workspaceBoardOpacity: number + setWorkspaceBoardOpacity: (opacity: number) => void + workspaceBoardColumnWidth: number + setWorkspaceBoardColumnWidth: (width: number) => void + syncTaskStatusFromWorkspaceBoard: boolean + setSyncTaskStatusFromWorkspaceBoard: (enabled: boolean) => void + /** Transient: the in-window Agent Dashboard companion drawer is open. Not persisted. */ + agentDashboardDrawerOpen: boolean + setAgentDashboardDrawerOpen: (open: boolean) => void + statusBarItems: StatusBarItem[] + toggleStatusBarItem: (item: StatusBarItem) => void + statusBarVisible: boolean + setStatusBarVisible: (v: boolean) => void + usagePercentageDisplay: UsagePercentageDisplay + setUsagePercentageDisplay: (display: UsagePercentageDisplay) => void + statusBarUsageMode: StatusBarUsageMode + setStatusBarUsageMode: (mode: StatusBarUsageMode) => void +} + +export type UISliceSurfaces = { + workspacePortScan: { key: string; result: WorkspacePortScanResult } | null + workspacePortScansByKey: Record + workspacePortScanRefreshing: boolean + setWorkspacePortScan: (scan: { key: string; result: WorkspacePortScanResult } | null) => void + setWorkspacePortScanProjection: ( + scan: { key: string; result: WorkspacePortScanResult } | null + ) => void + replaceWorkspacePortScans: ( + scansByKey: Record, + projection: { key: string; result: WorkspacePortScanResult } | null + ) => void + setWorkspacePortScanForKey: (key: string, result: WorkspacePortScanResult | null) => void + setWorkspacePortScanRefreshing: (refreshing: boolean) => void + /** Whether the pet overlay is currently visible. Persisted so "Hide pet" survives reload. Independent of the experimentalPet flag (which gates whether it can render at all). */ + petVisible: boolean + setPetVisible: (v: boolean) => void + /** Which pet is active — a bundled id or a custom UUID. Persisted via PersistedUIState. */ + petId: string + setPetId: (id: string) => void + /** User-uploaded pet images. Metadata only — bytes live in main's userData. */ + customPets: CustomPet[] + addCustomPet: (model: CustomPet) => void + removeCustomPet: (id: string) => void + /** Pet overlay size in CSS pixels (square). User-adjustable so an oversized imported sprite isn't stuck on screen. */ + petSize: number + setPetSize: (size: number) => void + pendingRevealWorktree: UISliceCore['pendingRevealWorktree'] + pendingRevealSidebarRow: UISliceCore['pendingRevealSidebarRow'] + revealWorktreeInSidebar: UISliceCore['revealWorktreeInSidebar'] + revealSidebarRow: UISliceCore['revealSidebarRow'] + clearPendingRevealWorktreeId: () => void + clearPendingRevealSidebarRow: () => void + // Why: cleared by the diff decorator after it reveals the line, so the same id can be requested again without a stale value. + scrollToDiffCommentId: string | null + setScrollToDiffCommentId: (id: string | null) => void +} + +export type UISlicePersistence = { + persistedUIReady: boolean + /** Writer-owned fields as last hydrated from main or flushed by the writer; the debounced writer diffs against this so it only persists fields this client changed (STA-5781). */ + persistedUIWriteBaseline: PersistedUIWriteBaseline | null + /** Fields with a ui.set round-trip in flight; hydration keeps the mirror's value for them so an echo of the in-flight write can't revert a newer flip-back. */ + persistedUIWriteInFlightCounts: Partial> + /** Bumped whenever hydration replaces the baseline; an ack whose write predates the bump must not fold, or it would erase a remote write that landed during the round trip. */ + persistedUIWriteBaselineGeneration: number + notePersistedUIWriteStarted: (fields: readonly (keyof PersistedUIWriteBaseline)[]) => void + /** Settle an in-flight write: fold the acked patch into the baseline (null = rejected, leaving the fields dirty so the next change re-flushes them). */ + notePersistedUIWriteSettled: ( + fields: readonly (keyof PersistedUIWriteBaseline)[], + flushed: Partial | null, + options?: { sentAtGeneration: number } + ) => void + uiZoomLevel: number + setUIZoomLevel: (level: number) => void + editorFontZoomLevel: number + setEditorFontZoomLevel: (level: number) => void + hydratePersistedUI: (ui: PersistedUIState, source?: 'startup' | 'sync') => void + updateStatus: UpdateStatus + setUpdateStatus: (status: UpdateStatus) => void + // Why: cache last-'available' changelog so the card keeps rich content while downloading; cleared on idle/checking to avoid staleness. + updateChangelog: ChangelogData | null + // Why: UpdateCard is lazy-loaded and may miss the transient checking status; hold manual-check intent until a terminal state consumes it. + updateUserInitiatedCycle: boolean + dismissedUpdateVersion: string | null + dismissUpdate: (versionOverride?: string) => void + clearDismissedUpdateVersion: () => void + /** Dev-only channel override; null follows the running build's own channel. */ + releaseChannelOverride: ReleaseChannel | null + setReleaseChannelOverride: (channel: ReleaseChannel | null) => void + // Why: ephemeral, renderer-only — never persisted; resets each session and on every phase transition (see setUpdateStatus). + updateCardCollapsed: boolean + setUpdateCardCollapsed: (collapsed: boolean) => void + updateReassuranceSeen: boolean + markUpdateReassuranceSeen: () => void + /** True on the launch where the OSC 52 default-on migration overrode a persisted `false`. */ + osc52ClipboardDefaultOnNoticePending: boolean + clearOsc52ClipboardDefaultOnNotice: () => void + isFullScreen: boolean + setIsFullScreen: (v: boolean) => void + /** URL opened when a new browser tab is created. Null = blank tab (default). */ + browserDefaultUrl: string | null + setBrowserDefaultUrl: (url: string | null) => void + browserDefaultSearchEngine: 'google' | 'duckduckgo' | 'bing' | 'kagi' | null + setBrowserDefaultSearchEngine: (engine: 'google' | 'duckduckgo' | 'bing' | 'kagi' | null) => void + browserDefaultZoomLevel: number + setBrowserDefaultZoomLevel: (level: number) => void + browserKagiSessionLink: string | null + setBrowserKagiSessionLink: (link: string | null) => void +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract.ts b/src/renderer/src/store/slices/ui/ui-slice-contract.ts new file mode 100644 index 00000000000..e330d069cc0 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-contract.ts @@ -0,0 +1,36 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../../types' +import type { UISliceCore } from './ui-slice-contract-core' +import type { UISliceContextual } from './ui-slice-contract-contextual' +import type { + UISlicePersistence, + UISlicePreferences, + UISliceSurfaces +} from './ui-slice-contract-preferences' + +export type { + AgentSendPopoverTargetMode, + NewWorkspaceDraft, + OpenAgentSendPopoverTargetModeArgs, + PendingSidebarRowReveal, + PendingSidebarWorktreeReveal, + TaskPageData, + UISliceCore, + UiViewHistory +} from './ui-slice-contract-core' +export type { UISliceContextual } from './ui-slice-contract-contextual' +export type { + UISlicePersistence, + UISlicePreferences, + UISliceSurfaces +} from './ui-slice-contract-preferences' + +export type UISlice = UISliceCore & + UISliceContextual & + UISlicePreferences & + UISliceSurfaces & + UISlicePersistence + +type UISliceStateCreator = StateCreator +export type UISliceSet = Parameters[0] +export type UISliceGet = Parameters[1] diff --git a/src/renderer/src/store/slices/ui/ui-slice-feature-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-feature-actions.ts new file mode 100644 index 00000000000..ee96ec86d46 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-feature-actions.ts @@ -0,0 +1,84 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' +import { mergeFeatureInteractionState } from './ui-slice-hydration-values' +import { mergeContextualTourSeenIds } from './ui-slice-hydration-values' +import { getContextualTourProgressionForFeatureInteraction } from './ui-slice-tour-helpers' +import type { FeatureInteractionState } from '../../../../../shared/feature-interactions' + +export function createUiFeatureActions(set: UISliceSet, get: UISliceGet): Partial { + return { + featureTipsSeenIds: [], + markFeatureTipsSeen: (ids) => + set((s) => { + if (ids.length === 0) { + return s + } + const current = new Set(s.featureTipsSeenIds) + let changed = false + for (const id of ids) { + if (!current.has(id)) { + current.add(id) + changed = true + } + } + if (!changed) { + return s + } + const next = [...current] + window.api.ui.set({ featureTipsSeenIds: next }).catch(console.error) + return { featureTipsSeenIds: next } + }), + featureInteractions: {}, + recordFeatureInteraction: (id) => { + let tourProgression: ReturnType = + null + let persistPromise = Promise.resolve() + set((s) => { + if (!s.persistedUIReady) { + return s + } + tourProgression = getContextualTourProgressionForFeatureInteraction(s, id) + const existing = s.featureInteractions[id] + const next: FeatureInteractionState = { + ...s.featureInteractions, + [id]: { + firstInteractedAt: existing?.firstInteractedAt ?? Date.now(), + interactionCount: (existing?.interactionCount ?? 0) + 1 + } + } + if (typeof window !== 'undefined') { + const recordInteraction = window.api.ui.recordFeatureInteraction + const persist = recordInteraction + ? recordInteraction(id).then((ui) => { + set((current) => ({ + featureInteractions: mergeFeatureInteractionState( + current.featureInteractions, + ui.featureInteractions + ), + contextualToursSeenIds: mergeContextualTourSeenIds( + current.contextualToursSeenIds, + ui.contextualToursSeenIds + ) + })) + }) + : window.api.ui.set({ featureInteractions: next }) + persistPromise = persist.catch(console.error) + } + if (tourProgression === 'reveal-sidebar-and-advance') { + // Why: split can fire from keyboard/menu with the sidebar closed, but the next tour target lives in the sidebar. + return { + featureInteractions: next, + sidebarOpen: true, + activeContextualTourStepIndex: s.activeContextualTourStepIndex + 1 + } + } + return { featureInteractions: next } + }) + if (tourProgression === 'complete') { + get().completeContextualTour() + } else if (tourProgression === 'advance') { + get().advanceContextualTour() + } + return persistPromise + } + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts new file mode 100644 index 00000000000..41dfbaa079c --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts @@ -0,0 +1,343 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' +import { normalizeRightSidebarRoute } from '../../right-sidebar-route' +import { + applyManualRepoOrder, + normalizeManualRepoOrder +} from '../../../../../shared/manual-repo-order' +import { normalizeWorkspaceCleanupBrowseState } from '../../../../../shared/workspace-cleanup-browse-state' +import { + normalizeExecutionHostScope, + normalizeExecutionHostOrder +} from '../../../../../shared/execution-host' +import { normalizeFeatureInteractions } from '../../../../../shared/feature-interactions' +import { normalizeContextualTourIds } from '../../../../../shared/contextual-tours' +import { normalizeFeatureTipIds } from '../../../../../shared/feature-tips' +import { DEFAULT_HIDE_SLEEPING_WORKSPACES } from '../../../../../shared/constants' +import { + clampWorkspaceBoardColumnWidth, + clampWorkspaceBoardOpacity, + normalizeWorkspaceStatuses +} from '../../../../../shared/workspace-statuses' +import { + normalizeWorktreeCardProperties, + normalizeAgentActivityDisplayMode +} from '../../../../../shared/constants' +import { PET_SIZE_DEFAULT, PET_SIZE_MAX, PET_SIZE_MIN } from '../../../../../shared/pet-types' +import { clampMarkdownTocPanelWidth } from '../../../../../shared/markdown-toc-panel-width' +import { clampCombinedDiffFileTreeWidth } from '../../../../../shared/combined-diff-file-tree-width' +import { parsePersistedAutomationHostFilter } from '../../../../../shared/automation-host-filter' +import { normalizeUsagePercentageDisplay } from '../../../../../shared/usage-percentage-display' +import { normalizeStatusBarUsageMode } from '../../../../../shared/status-bar-usage-mode' +import { normalizeBrowserPageZoomLevel } from '../../../../../shared/browser-page-zoom' +import { normalizeKagiSessionLink } from '../../../../../shared/browser-url' +import { isReleaseChannel } from '../../../../../shared/release-channel' +import type { StatusBarItem } from '../../../../../shared/ui-chrome-types' +import { + filterSetupScriptPromptDismissalsToValidRepos, + sanitizeSetupScriptPromptDismissals +} from '../../../lib/setup-script-prompt' +import { isBundledPetId, DEFAULT_PET_ID } from '../../../components/pet/pet-models' +import { getRepoHostIdentity } from '../repo-host-identity' +import type { PersistedUIWriteBaseline } from '../persisted-ui-write-baseline' +import { + capturePersistedUIWriteBaseline, + diffPersistedUIWriteFields +} from '../persisted-ui-write-baseline' +import { + hydrateTrustedOrcaHooks, + normalizeHydratedVisibleWorkspaceHostIds, + sanitizeAcknowledgedAgentsByPaneKey, + sanitizeHydratedActiveView, + sanitizePersistedRepoIds, + sanitizeShowDotfilesByWorktree, + sanitizeWorkspaceCleanupDismissals, + sanitizePersistedSidebarWidth, + hydratedUIPartialMatchesState, + migrateStatusBarItems +} from './ui-slice-hydration-sanitizers' +import { sanitizeTaskResumeState } from './ui-slice-hydration-values' +import { clampPetSize } from './ui-slice-hydration-sanitizers' + +const MAX_LEFT_SIDEBAR_WIDTH = 500 +const MAX_RIGHT_SIDEBAR_WIDTH = 4000 +const DEFAULT_ON_PORTS_STATUS_BAR_ITEM: StatusBarItem = 'ports' +const DEFAULT_ON_KIMI_STATUS_BAR_ITEM: StatusBarItem = 'kimi' +const DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM: StatusBarItem = 'minimax' +const DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM: StatusBarItem = 'antigravity' +const DEFAULT_ON_GROK_STATUS_BAR_ITEM: StatusBarItem = 'grok' + +export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Partial { + return { + hydratePersistedUI: (ui, source = 'sync') => + set((s) => { + const manualRepoOrder = normalizeManualRepoOrder(ui.manualRepoOrder) + const orderedRepos = applyManualRepoOrder(s.repos, manualRepoOrder) + const validRepoIds = new Set(s.repos.map((repo) => repo.id)) + const validRepoHostIdentities = new Set(s.repos.map(getRepoHostIdentity)) + const persistedFilterRepoIds = sanitizePersistedRepoIds(ui.filterRepoIds) + // Why: pre-rename builds used sidekick* keys; read as fallback only so new pet* writes win after upgrade. + const customPets = Array.isArray(ui.customPets) + ? ui.customPets + : Array.isArray(ui.customSidekicks) + ? ui.customSidekicks + : [] + const petId = ui.petId ?? ui.sidekickId + // Migration: one-shot old-'recent'→'smart' runs in main (_sortBySmartMigrated), not here, so a deliberate 'recent' choice survives restart. + const sortBy = ui.sortBy + const migratedStatusBarItems = migrateStatusBarItems(ui.statusBarItems) + const statusBarItemsWithPorts: StatusBarItem[] = + ui._portsStatusBarDefaultAdded || migratedStatusBarItems.includes('ports') + ? migratedStatusBarItems + : [...migratedStatusBarItems, DEFAULT_ON_PORTS_STATUS_BAR_ITEM] + const statusBarItems: StatusBarItem[] = + ui._kimiStatusBarDefaultAdded || statusBarItemsWithPorts.includes('kimi') + ? statusBarItemsWithPorts + : [...statusBarItemsWithPorts, DEFAULT_ON_KIMI_STATUS_BAR_ITEM] + const statusBarItemsWithMiniMax: StatusBarItem[] = + ui._minimaxStatusBarDefaultAdded || statusBarItems.includes('minimax') + ? statusBarItems + : [...statusBarItems, DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM] + const statusBarItemsWithAntigravity: StatusBarItem[] = + ui._antigravityStatusBarDefaultAdded || statusBarItemsWithMiniMax.includes('antigravity') + ? statusBarItemsWithMiniMax + : [...statusBarItemsWithMiniMax, DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM] + const statusBarItemsWithGrok: StatusBarItem[] = + ui._grokStatusBarDefaultAdded || statusBarItemsWithAntigravity.includes('grok') + ? statusBarItemsWithAntigravity + : [...statusBarItemsWithAntigravity, DEFAULT_ON_GROK_STATUS_BAR_ITEM] + if ( + (!ui._portsStatusBarDefaultAdded || + !ui._kimiStatusBarDefaultAdded || + !ui._minimaxStatusBarDefaultAdded || + !ui._antigravityStatusBarDefaultAdded || + !ui._grokStatusBarDefaultAdded) && + typeof window !== 'undefined' + ) { + window.api.ui + .set({ + statusBarItems: statusBarItemsWithGrok, + _portsStatusBarDefaultAdded: true, + _kimiStatusBarDefaultAdded: true, + _minimaxStatusBarDefaultAdded: true, + _antigravityStatusBarDefaultAdded: true, + _grokStatusBarDefaultAdded: true + }) + .catch(console.error) + } + const rightSidebarRoute = normalizeRightSidebarRoute( + ui.rightSidebarTab, + ui.rightSidebarExplorerView + ) + const hydrated = { + // Why: persisted widths may be stale/corrupt/hand-edited; clamp during hydration so invalid values can't break layout. + sidebarWidth: sanitizePersistedSidebarWidth( + ui.sidebarWidth, + s.sidebarWidth, + MAX_LEFT_SIDEBAR_WIDTH + ), + rightSidebarWidth: sanitizePersistedSidebarWidth( + ui.rightSidebarWidth, + s.rightSidebarWidth, + MAX_RIGHT_SIDEBAR_WIDTH + ), + markdownTocPanelWidth: clampMarkdownTocPanelWidth( + ui.markdownTocPanelWidth, + undefined, + s.markdownTocPanelWidth + ), + combinedDiffFileTreeWidth: clampCombinedDiffFileTreeWidth( + ui.combinedDiffFileTreeWidth, + undefined, + s.combinedDiffFileTreeWidth + ), + rightSidebarOpen: typeof ui.rightSidebarOpen === 'boolean' ? ui.rightSidebarOpen : true, + rightSidebarTab: rightSidebarRoute.rightSidebarTab, + rightSidebarExplorerView: rightSidebarRoute.rightSidebarExplorerView, + groupBy: (ui.groupBy as UISlice['groupBy'] | 'parent') === 'parent' ? 'repo' : ui.groupBy, + sortBy, + // Why: main-process getUI() already normalized this (defaulting to 'manual'); read it through without migrating. + projectOrderBy: ui.projectOrderBy, + // Why: Active-only was retired; force the old flag off so an old profile can't invisibly narrow the workspace list. + showActiveOnly: false, + // Why: ignore older positive-form keys so old profiles start from the new default (sleeping workspaces visible). + showSleepingWorkspaces: !(ui.hideSleepingWorkspaces ?? DEFAULT_HIDE_SLEEPING_WORKSPACES), + workspaceHostScope: normalizeExecutionHostScope(ui.workspaceHostScope), + visibleWorkspaceHostIds: normalizeHydratedVisibleWorkspaceHostIds(ui), + workspaceHostOrder: normalizeExecutionHostOrder(ui.workspaceHostOrder), + // Why: a malformed or legacy filter value must degrade to All hosts, never throw during hydration. + automationHostFilter: parsePersistedAutomationHostFilter(ui.automationHostFilter), + manualRepoOrder, + // Why: apply the desktop-owned overlay immediately since UI state can arrive after a catalog or from another client. + repos: orderedRepos, + hideDefaultBranchWorkspace: ui.hideDefaultBranchWorkspace ?? false, + hideAutomationGeneratedWorkspaces: ui.hideAutomationGeneratedWorkspaces === true, + hideCliCreatedWorkspaces: ui.hideCliCreatedWorkspaces === true, + hideDetachedHeadWorkspaces: ui.hideDetachedHeadWorkspaces === true, + hideWorkspacesFromOtherDevices: ui.hideWorkspacesFromOtherDevices === true, + // Why !== false: profiles written before #8873 have no key, and they are + // precisely the ones showing the bug, so absence must mean "exempt". + alwaysShowDefaultBranchWorkspace: ui.alwaysShowDefaultBranchWorkspace !== false, + showDotfilesByWorktree: sanitizeShowDotfilesByWorktree(ui.showDotfilesByWorktree), + // Why: startup hydrates UI before repo catalogs, so defer repo-filter validation to the all-host refresh. + filterRepoIds: + validRepoIds.size === 0 + ? persistedFilterRepoIds + : persistedFilterRepoIds.filter((repoId) => validRepoIds.has(repoId)), + collapsedGroups: new Set(ui.collapsedGroups ?? []), + uiZoomLevel: ui.uiZoomLevel ?? 0, + editorFontZoomLevel: ui.editorFontZoomLevel ?? 0, + worktreeCardProperties: normalizeWorktreeCardProperties(ui.worktreeCardProperties), + _worktreeCardModeDefaulted: ui._worktreeCardModeDefaulted === true, + agentActivityDisplayMode: normalizeAgentActivityDisplayMode(ui.agentActivityDisplayMode), + workspaceStatuses: normalizeWorkspaceStatuses(ui.workspaceStatuses), + workspaceBoardOpacity: clampWorkspaceBoardOpacity(ui.workspaceBoardOpacity), + workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(ui.workspaceBoardColumnWidth), + syncTaskStatusFromWorkspaceBoard: ui.syncTaskStatusFromWorkspaceBoard === true, + statusBarItems: statusBarItemsWithGrok, + statusBarVisible: ui.statusBarVisible ?? true, + usagePercentageDisplay: normalizeUsagePercentageDisplay(ui.usagePercentageDisplay), + statusBarUsageMode: normalizeStatusBarUsageMode(ui.statusBarUsageMode), + // Why: default true so existing users see the pet on first enabling the flag; only an explicit Hide persists false. + petVisible: ui.petVisible ?? ui.sidekickVisible ?? true, + petSize: clampPetSize(ui.petSize ?? ui.sidekickSize ?? PET_SIZE_DEFAULT, { + min: PET_SIZE_MIN, + max: PET_SIZE_MAX, + fallback: PET_SIZE_DEFAULT + }), + customPets, + // Why: fall back to default when the persisted id is unknown (e.g. custom pet removed elsewhere) so the overlay renders. + petId: ((): string => { + const id = petId + if (typeof id !== 'string') { + return DEFAULT_PET_ID + } + if (isBundledPetId(id)) { + return id + } + if (customPets.some((m) => m.id === id)) { + return id + } + return DEFAULT_PET_ID + })(), + dismissedUpdateVersion: ui.dismissedUpdateVersion ?? null, + // Why: a persisted value from a build that knew a different channel set + // would otherwise survive as-is; activeChannel only falls back on null, + // so an unknown string reaches listBuilds and the segmented control. + releaseChannelOverride: isReleaseChannel(ui.releaseChannelOverride) + ? ui.releaseChannelOverride + : null, + updateReassuranceSeen: ui.updateReassuranceSeen ?? false, + osc52ClipboardDefaultOnNoticePending: ui.osc52ClipboardDefaultOnNoticePending === true, + browserDefaultUrl: ui.browserDefaultUrl ?? null, + browserDefaultSearchEngine: ui.browserDefaultSearchEngine ?? null, + browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(ui.browserDefaultZoomLevel), + browserKagiSessionLink: normalizeKagiSessionLink(ui.browserKagiSessionLink ?? ''), + taskResumeState: sanitizeTaskResumeState(ui.taskResumeState), + featureTipsSeenIds: normalizeFeatureTipIds(ui.featureTipsSeenIds), + featureInteractions: normalizeFeatureInteractions(ui.featureInteractions), + contextualToursSeenIds: normalizeContextualTourIds(ui.contextualToursSeenIds), + contextualToursAutoEligible: + typeof ui.contextualToursAutoEligible === 'boolean' + ? ui.contextualToursAutoEligible + : null, + trustedOrcaHooks: hydrateTrustedOrcaHooks(ui.trustedOrcaHooks, validRepoIds), + setupScriptPromptDismissedRepoIds: + validRepoHostIdentities.size === 0 + ? sanitizeSetupScriptPromptDismissals(ui.setupScriptPromptDismissedRepoIds) + : filterSetupScriptPromptDismissalsToValidRepos( + ui.setupScriptPromptDismissedRepoIds, + validRepoHostIdentities + ), + setupGuideSidebarDismissed: ui.setupGuideSidebarDismissed === true, + setupGuideBrowserMilestoneMigrated: ui.setupGuideBrowserMilestoneMigrated === true, + setupGuideBrowserMilestoneLegacyComplete: + ui.setupGuideBrowserMilestoneLegacyComplete === true, + browserImportHintHidden: ui.browserImportHintHidden === true, + mobileEmulatorTabIntroDismissed: ui.mobileEmulatorTabIntroDismissed === true, + mobileEmulatorAgentSetupDismissed: ui.mobileEmulatorAgentSetupDismissed === true, + projectOrderManualDefaultNoticeDismissed: + ui.projectOrderManualDefaultNoticeDismissed === true, + // Why: treat only explicit true as dismissed so a false from migration still surfaces. + usagePercentageDisplayChangeNoticeDismissed: + ui.usagePercentageDisplayChangeNoticeDismissed === true, + // Why: default false so existing users still see the CTA; only explicit dismissal persists true. + usageEmptyStateDismissed: ui.usageEmptyStateDismissed === true, + // Why: stale acks are inert (paneKey reuse beats them via stateStartedAt); sanitizer bounds growth past HYDRATE_MAX_AGE_MS. + acknowledgedAgentsByPaneKey: sanitizeAcknowledgedAgentsByPaneKey( + ui.acknowledgedAgentsByPaneKey + ), + workspaceCleanupDismissals: sanitizeWorkspaceCleanupDismissals( + ui.workspaceCleanup?.dismissals + ), + // Why the normalizer rather than a cast: this blob is hand-editable and + // may come from an older or newer build; it degrades field by field + // instead of bricking the cleanup dialog. + // Why: a sync broadcast can carry stale browse state while its writer is debounced. + workspaceCleanupBrowse: + source === 'startup' + ? normalizeWorkspaceCleanupBrowseState(ui.workspaceCleanup?.browse) + : s.workspaceCleanupBrowse, + // Why: restore only on startup; on 'sync' broadcasts it would clobber the window's current per-window view. + activeView: + source === 'startup' + ? sanitizeHydratedActiveView(ui.activeView, s.settings?.experimentalActivity === true) + : s.activeView, + persistedUIReady: true + } + // The incoming payload is authoritative for the writer-owned fields, so it becomes the + // writer's new diff baseline — but fields with an unflushed local edit (mirror diverged + // from the previous baseline) keep the local value so a broadcast arriving inside the + // writer's debounce window can't silently revert what the user just toggled (STA-5781). + // Order matters: capture the baseline BEFORE overlaying pending edits, or the baseline + // would equal the pending value, the diff would go empty, and the toggle would be dropped. + // Note the width sanitizers above fall back to the CURRENT store value only for + // non-numeric input (numbers are clamped in place), so a captured width can differ + // from what main holds only for garbage payloads; at worst main keeps an + // out-of-range width until the next drag re-writes it. + const nextWriteBaseline = capturePersistedUIWriteBaseline(hydrated) + const previousBaseline = s.persistedUIWriteBaseline + if (previousBaseline) { + const pendingLocalEdits = diffPersistedUIWriteFields( + capturePersistedUIWriteBaseline(s), + previousBaseline + ) + Object.assign(hydrated, pendingLocalEdits) + // In-flight fields too: a flip-back to the baseline value diffs empty, + // yet the in-flight write's echo must not revert it (PR#17057 review). + for (const field of Object.keys( + s.persistedUIWriteInFlightCounts + ) as (keyof PersistedUIWriteBaseline)[]) { + ;(hydrated as Record)[field] = s[field] + } + } + // Why: return the same ref on identical hydration so App's debounced writer doesn't echo it back to main. + // The baseline must still advance when it moved (a remote same-field write during an in-flight + // ack pins the only visibly differing field, and our own echo precedes every ack) — but only + // the two baseline keys, or every ordinary write's echo would churn the store's collection + // identities and re-render identity-compared selectors once per write. + // Why the generation bumps only on baseline movement: an unrelated-field + // broadcast during an in-flight write would otherwise void that write's + // fold and cost a redundant trailing re-send of identical values. + const writeBaselineMoved = + !previousBaseline || + Object.keys(diffPersistedUIWriteFields(nextWriteBaseline, previousBaseline)).length > 0 + const nextWriteBaselineGeneration = writeBaselineMoved + ? s.persistedUIWriteBaselineGeneration + 1 + : s.persistedUIWriteBaselineGeneration + if (hydratedUIPartialMatchesState(s, hydrated as Partial)) { + if (!writeBaselineMoved) { + return s + } + return { + persistedUIWriteBaseline: nextWriteBaseline, + persistedUIWriteBaselineGeneration: nextWriteBaselineGeneration + } + } + return { + ...hydrated, + persistedUIWriteBaseline: nextWriteBaseline, + persistedUIWriteBaselineGeneration: nextWriteBaselineGeneration + } + }) + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts new file mode 100644 index 00000000000..c557a75f246 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts @@ -0,0 +1,221 @@ +import type { AppState } from '../../types' +import type { PersistedTrustedOrcaHooks } from '../../../../../shared/orca-yaml-hook-types' +import type { PersistedUIState } from '../../../../../shared/persisted-ui-state-types' +import type { + TaskViewPresetId, + TopLevelView, + VisibleWorkspaceHostIds +} from '../../../../../shared/ui-chrome-types' +import type { WorkspaceCleanupDismissal } from '../../../../../shared/workspace-cleanup' +import { WORKSPACE_CLEANUP_CLASSIFIER_VERSION } from '../../../../../shared/workspace-cleanup' +import { isTopLevelView } from '../../../../../shared/top-level-view' +import { + normalizeVisibleExecutionHostIds, + normalizeExecutionHostScope +} from '../../../../../shared/execution-host' +import { persistedUIValuesEqual } from '../../../../../shared/persisted-ui-equality' +import { DEFAULT_STATUS_BAR_ITEMS } from '../../../../../shared/constants' +import type { StatusBarItem } from '../../../../../shared/ui-chrome-types' +import type { UISlice } from './ui-slice-contract' + +const MIN_SIDEBAR_WIDTH = 220 +const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 +export function isPlainPersistedRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +export function isSafePersistedRecordKey(key: string): boolean { + return key !== '__proto__' && key !== 'constructor' && key !== 'prototype' +} + +export function sanitizePersistedRepoIds(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + return value.filter((repoId): repoId is string => typeof repoId === 'string') +} + +export function sanitizeTrustedOrcaHooks(trust: unknown): PersistedTrustedOrcaHooks { + if (!isPlainPersistedRecord(trust)) { + return {} + } + const next: PersistedTrustedOrcaHooks = {} + for (const [repoId, entry] of Object.entries(trust)) { + if (!isSafePersistedRecordKey(repoId) || !isPlainPersistedRecord(entry)) { + continue + } + next[repoId] = entry as PersistedTrustedOrcaHooks[string] + } + return next +} + +export function hydrateTrustedOrcaHooks( + trust: unknown, + validRepoIds: Set +): PersistedTrustedOrcaHooks { + const sanitized = sanitizeTrustedOrcaHooks(trust) + if (validRepoIds.size === 0) { + return sanitized + } + const next: PersistedTrustedOrcaHooks = {} + for (const [repoId, entry] of Object.entries(sanitized)) { + if (validRepoIds.has(repoId)) { + next[repoId] = entry + } + } + return next +} + +export function sanitizeShowDotfilesByWorktree(value: unknown): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + const out: Record = {} + for (const [worktreeId, showDotfiles] of Object.entries(value as Record)) { + if (!worktreeId || !isSafePersistedRecordKey(worktreeId) || typeof showDotfiles !== 'boolean') { + continue + } + out[worktreeId] = showDotfiles + } + return out +} + +export function sanitizePersistedSidebarWidth( + width: unknown, + fallback: number, + maxWidth: number +): number { + if (typeof width !== 'number' || !Number.isFinite(width)) { + return fallback + } + return Math.min(maxWidth, Math.max(MIN_SIDEBAR_WIDTH, width)) +} + +export function sanitizeAcknowledgedAgentsByPaneKey(value: unknown): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + const cutoff = Date.now() - HYDRATE_MAX_AGE_MS + const out: Record = {} + for (const [key, ackAt] of Object.entries(value as Record)) { + if (!isSafePersistedRecordKey(key)) { + continue + } + if (typeof ackAt !== 'number' || !Number.isFinite(ackAt) || ackAt <= 0 || ackAt < cutoff) { + continue + } + out[key] = ackAt + } + return out +} + +export function sanitizeWorkspaceCleanupDismissals( + value: unknown +): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + const out: Record = {} + for (const [key, raw] of Object.entries(value as Record)) { + if ( + !isSafePersistedRecordKey(key) || + raw === null || + typeof raw !== 'object' || + Array.isArray(raw) + ) { + continue + } + const input = raw as Record + if ( + typeof input.worktreeId !== 'string' || + typeof input.dismissedAt !== 'number' || + !Number.isFinite(input.dismissedAt) || + typeof input.fingerprint !== 'string' || + input.classifierVersion !== WORKSPACE_CLEANUP_CLASSIFIER_VERSION + ) { + continue + } + out[key] = { + worktreeId: input.worktreeId, + dismissedAt: input.dismissedAt, + fingerprint: input.fingerprint, + classifierVersion: input.classifierVersion + } + } + return out +} + +export function sanitizeHydratedActiveView( + value: PersistedUIState['activeView'], + experimentalActivityEnabled: boolean +): TopLevelView { + // Why: older data (pre-activeView) or a view a different build doesn't have falls back to terminal rather than rendering nothing. + if (!isTopLevelView(value)) { + return 'terminal' + } + // Why: activity is hidden when its setting is off, so gate only it (mobile/automations stay functional when hidden). + if (value === 'activity' && !experimentalActivityEnabled) { + return 'terminal' + } + return value +} + +export function hydratedUIPartialMatchesState( + state: AppState, + hydrated: Partial +): boolean { + return Object.entries(hydrated).every(([key, value]) => + persistedUIValuesEqual(state[key as keyof AppState], value) + ) +} + +export function normalizeHydratedVisibleWorkspaceHostIds( + ui: PersistedUIState +): VisibleWorkspaceHostIds { + const visibleHostIds = normalizeVisibleExecutionHostIds(ui.visibleWorkspaceHostIds) + if (visibleHostIds) { + return visibleHostIds + } + const legacyScope = normalizeExecutionHostScope(ui.workspaceHostScope) + return legacyScope === 'all' ? null : [legacyScope] +} + +export function clampPetSize( + size: number, + defaults: { min: number; max: number; fallback: number } +): number { + if (!Number.isFinite(size)) { + return defaults.fallback + } + return Math.max(defaults.min, Math.min(defaults.max, Math.round(size))) +} + +export function presetToQuery(presetId: TaskViewPresetId | null): string { + switch (presetId) { + case 'all': + case 'issues': + return 'is:issue is:open' + case 'my-issues': + return 'assignee:@me is:issue is:open' + case 'prs': + return 'is:pr is:open' + case 'review': + return 'review-requested:@me is:pr is:open' + case 'my-prs': + return 'author:@me is:pr is:open' + case null: + return 'is:issue is:open' + } +} + +export function migrateStatusBarItems(items: readonly string[] | undefined): StatusBarItem[] { + const source = items ?? DEFAULT_STATUS_BAR_ITEMS + const out: string[] = [] + for (const id of source) { + const mapped = id === 'memory' || id === 'sessions' ? 'resource-usage' : id + if (!out.includes(mapped)) { + out.push(mapped) + } + } + return out as StatusBarItem[] +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-values.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-values.ts new file mode 100644 index 00000000000..7bb8376fb27 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-values.ts @@ -0,0 +1,134 @@ +import type { PersistedUIState } from '../../../../../shared/persisted-ui-state-types' +import type { TaskResumeState, TaskViewPresetId } from '../../../../../shared/ui-chrome-types' +import type { FeatureInteractionState } from '../../../../../shared/feature-interactions' +import type { ContextualTourId } from '../../../../../shared/contextual-tours' +import { normalizeFeatureInteractions } from '../../../../../shared/feature-interactions' +import { normalizeContextualTourIds } from '../../../../../shared/contextual-tours' + +const VALID_TASK_PRESETS = new Set([ + 'all', + 'issues', + 'review', + 'my-issues', + 'my-prs', + 'prs' +]) +const VALID_LINEAR_PRESETS = new Set>([ + 'assigned', + 'created', + 'all', + 'completed' +]) +const VALID_LINEAR_MODES = new Set>([ + 'issues', + 'projects', + 'views', + 'in-orca' +]) +const VALID_JIRA_PRESETS = new Set>([ + 'assigned', + 'reported', + 'all', + 'done' +]) + +export function sanitizeTaskResumeState(value: unknown): TaskResumeState | undefined { + if (!value || typeof value !== 'object') { + return undefined + } + const input = value as Record + const next: TaskResumeState = {} + if (input.githubMode === 'items' || input.githubMode === 'project') { + next.githubMode = input.githubMode + } + if (input.githubItemsPreset === null) { + next.githubItemsPreset = null + } else if ( + typeof input.githubItemsPreset === 'string' && + VALID_TASK_PRESETS.has(input.githubItemsPreset as TaskViewPresetId) + ) { + next.githubItemsPreset = input.githubItemsPreset as TaskViewPresetId + } + if (typeof input.githubItemsQuery === 'string') { + next.githubItemsQuery = input.githubItemsQuery + } + if ( + typeof input.linearPreset === 'string' && + VALID_LINEAR_PRESETS.has(input.linearPreset as NonNullable) + ) { + next.linearPreset = input.linearPreset as NonNullable + } + if ( + typeof input.linearMode === 'string' && + VALID_LINEAR_MODES.has(input.linearMode as NonNullable) + ) { + next.linearMode = input.linearMode as NonNullable + } + if (typeof input.linearQuery === 'string') { + next.linearQuery = input.linearQuery + } + if (input.linearContext && typeof input.linearContext === 'object') { + const context = input.linearContext as Record + if ( + (context.kind === 'project' || context.kind === 'view') && + typeof context.id === 'string' && + context.id.trim() && + typeof context.workspaceId === 'string' && + context.workspaceId.trim() && + context.workspaceId !== 'all' + ) { + next.linearContext = { + kind: context.kind, + id: context.id, + workspaceId: context.workspaceId, + model: context.model === 'issue' || context.model === 'project' ? context.model : undefined + } + } + } + if ( + typeof input.jiraPreset === 'string' && + VALID_JIRA_PRESETS.has(input.jiraPreset as NonNullable) + ) { + next.jiraPreset = input.jiraPreset as NonNullable + } + if (typeof input.jiraQuery === 'string') { + next.jiraQuery = input.jiraQuery + } + return Object.keys(next).length > 0 ? next : undefined +} + +export function mergeFeatureInteractionState( + current: FeatureInteractionState, + incoming: PersistedUIState['featureInteractions'] +): FeatureInteractionState { + const currentNormalized = normalizeFeatureInteractions(current) + const incomingNormalized = normalizeFeatureInteractions(incoming) + const merged: FeatureInteractionState = { ...currentNormalized } + for (const [id, incomingRecord] of Object.entries(incomingNormalized)) { + const currentRecord = currentNormalized[id as keyof FeatureInteractionState] + merged[id as keyof FeatureInteractionState] = currentRecord + ? { + firstInteractedAt: Math.min( + currentRecord.firstInteractedAt, + incomingRecord.firstInteractedAt + ), + interactionCount: Math.max( + currentRecord.interactionCount, + incomingRecord.interactionCount + ) + } + : incomingRecord + } + return merged +} + +export function mergeContextualTourSeenIds( + current: readonly ContextualTourId[], + incoming: PersistedUIState['contextualToursSeenIds'] +): ContextualTourId[] { + const merged = new Set(normalizeContextualTourIds(current)) + for (const id of normalizeContextualTourIds(incoming)) { + merged.add(id) + } + return [...merged] +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-modal-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-modal-actions.ts new file mode 100644 index 00000000000..278aefd5803 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-modal-actions.ts @@ -0,0 +1,25 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' +import { settleEvictedModalData } from '../modal-slot-dismissal' + +export function createUiModalActions(set: UISliceSet, get: UISliceGet): Partial { + return { + activeModal: 'none', + modalData: {}, + openModal: (modal, data = {}) => { + if (modal === 'add-repo' || modal === 'create-worktree') { + get().recordFeatureInteraction?.('workspace-creation') + } + const evicted = get().modalData + set({ + activeModal: modal, + modalData: data + }) + settleEvictedModalData(evicted) + }, + closeModal: () => { + const evicted = get().modalData + set({ activeModal: 'none', modalData: {} }) + settleEvictedModalData(evicted) + } + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-persistence-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-persistence-actions.ts new file mode 100644 index 00000000000..1029d16a395 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-persistence-actions.ts @@ -0,0 +1,52 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' + +export function createUiPersistenceActions(set: UISliceSet, _get: UISliceGet): Partial { + return { + persistedUIReady: false, + persistedUIWriteBaseline: null, + persistedUIWriteInFlightCounts: {}, + notePersistedUIWriteStarted: (fields) => + set((s) => { + const counts = { ...s.persistedUIWriteInFlightCounts } + for (const field of fields) { + counts[field] = (counts[field] ?? 0) + 1 + } + return { persistedUIWriteInFlightCounts: counts } + }), + persistedUIWriteBaselineGeneration: 0, + notePersistedUIWriteSettled: (fields, flushed, options) => + set((s) => { + const counts = { ...s.persistedUIWriteInFlightCounts } + for (const field of fields) { + const next = (counts[field] ?? 0) - 1 + if (next > 0) { + counts[field] = next + } else { + delete counts[field] + } + } + // Why the generation guard: a hydration during the round trip made the + // baseline authoritative for state NEWER than this write; folding the + // sent values over it would blank the mirror-vs-baseline diff and leave + // mirror and authority divergent with nothing left to reconcile them. + // Skipping the fold keeps the diff alive so the trailing flush re-sends. + // Why options is required for folding: an unguarded fold from a future + // caller could silently erase a remote write that landed mid-round-trip. + const foldable = + flushed && + s.persistedUIWriteBaseline && + options !== undefined && + options.sentAtGeneration === s.persistedUIWriteBaselineGeneration + return { + persistedUIWriteInFlightCounts: counts, + ...(foldable + ? { persistedUIWriteBaseline: { ...s.persistedUIWriteBaseline!, ...flushed } } + : {}) + } + }), + uiZoomLevel: 0, + setUIZoomLevel: (level) => set({ uiZoomLevel: level }), + editorFontZoomLevel: 0, + setEditorFontZoomLevel: (level) => set({ editorFontZoomLevel: level }) + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-preference-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-preference-actions.ts new file mode 100644 index 00000000000..3859ebfc75a --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-preference-actions.ts @@ -0,0 +1,261 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' +import { + DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE, + DEFAULT_SHOW_SLEEPING_WORKSPACES, + DEFAULT_STATUS_BAR_ITEMS, + DEFAULT_WORKTREE_CARD_PROPERTIES, + getWorktreeCardModeUpdates, + normalizeAgentActivityDisplayMode, + normalizeWorktreeCardProperties +} from '../../../../../shared/constants' +import { + DEFAULT_USAGE_PERCENTAGE_DISPLAY, + normalizeUsagePercentageDisplay +} from '../../../../../shared/usage-percentage-display' +import { + DEFAULT_STATUS_BAR_USAGE_MODE, + normalizeStatusBarUsageMode +} from '../../../../../shared/status-bar-usage-mode' +import type { WorkspaceHostScope } from '../../../../../shared/ui-chrome-types' +import { + normalizeExecutionHostOrder, + normalizeExecutionHostScope, + normalizeVisibleExecutionHostIds +} from '../../../../../shared/execution-host' +import { + ALL_AUTOMATION_HOSTS_FILTER, + toPersistedAutomationHostFilter +} from '../../../../../shared/automation-host-filter' +import { + clampWorkspaceBoardColumnWidth, + clampWorkspaceBoardOpacity, + cloneDefaultWorkspaceStatuses, + normalizeWorkspaceStatuses, + WORKSPACE_BOARD_COLUMN_WIDTH_DEFAULT +} from '../../../../../shared/workspace-statuses' + +export function createUiPreferenceActions(set: UISliceSet, get: UISliceGet): Partial { + return { + groupBy: 'repo', + // Why: group keys are mode-specific, so clear collapsed state on mode switch — stale keys are meaningless and accumulate. + setGroupBy: (g) => { + window.api.ui.set({ groupBy: g, collapsedGroups: [] }).catch(console.error) + set({ groupBy: g, collapsedGroups: new Set() }) + }, + + sortBy: 'recent', + setSortBy: (s) => set({ sortBy: s }), + + // Why: bare set — persists only via the debounced window.api.ui.set writer in App.tsx, not on its own. + projectOrderBy: 'manual', + setProjectOrderBy: (p) => set({ projectOrderBy: p }), + + showActiveOnly: false, + setShowActiveOnly: (v) => set({ showActiveOnly: v }), + + showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES, + setShowSleepingWorkspaces: (v) => set({ showSleepingWorkspaces: v }), + + workspaceHostScope: 'all', + // Why: host scope is presentation/filtering only — must never trigger resource teardown (terminals, browser pages). + setWorkspaceHostScope: (scope) => { + const normalized = normalizeExecutionHostScope(scope) + const visibleWorkspaceHostIds = normalized === 'all' ? null : [normalized] + set({ workspaceHostScope: normalized, visibleWorkspaceHostIds }) + window.api.ui + .set({ workspaceHostScope: normalized, visibleWorkspaceHostIds }) + .catch(console.error) + }, + visibleWorkspaceHostIds: null, + setVisibleWorkspaceHostIds: (ids) => { + const normalized = normalizeVisibleExecutionHostIds(ids) + // Why: workspaceHostScope stays the compat/default-host signal for creation flows; visibility can now be multi-select. + let workspaceHostScope: WorkspaceHostScope = get().workspaceHostScope + if (normalized === null) { + workspaceHostScope = 'all' + } else if (normalized.length === 1) { + workspaceHostScope = normalized[0] + } + set({ visibleWorkspaceHostIds: normalized, workspaceHostScope }) + window.api.ui + .set({ visibleWorkspaceHostIds: normalized, workspaceHostScope }) + .catch(console.error) + }, + workspaceHostOrder: [], + setWorkspaceHostOrder: (ids) => { + const workspaceHostOrder = normalizeExecutionHostOrder(ids) + set({ workspaceHostOrder }) + window.api.ui.set({ workspaceHostOrder }).catch(console.error) + }, + automationHostFilter: ALL_AUTOMATION_HOSTS_FILTER, + setAutomationHostFilter: (filter) => { + window.api.ui + .set({ automationHostFilter: toPersistedAutomationHostFilter(filter) }) + .catch(console.error) + set({ automationHostFilter: filter }) + }, + manualRepoOrder: [], + + hideDefaultBranchWorkspace: false, + setHideDefaultBranchWorkspace: (v) => set({ hideDefaultBranchWorkspace: v }), + hideAutomationGeneratedWorkspaces: false, + setHideAutomationGeneratedWorkspaces: (v) => set({ hideAutomationGeneratedWorkspaces: v }), + hideCliCreatedWorkspaces: false, + setHideCliCreatedWorkspaces: (v) => set({ hideCliCreatedWorkspaces: v }), + hideDetachedHeadWorkspaces: false, + setHideDetachedHeadWorkspaces: (v) => set({ hideDetachedHeadWorkspaces: v }), + hideWorkspacesFromOtherDevices: false, + setHideWorkspacesFromOtherDevices: (v) => set({ hideWorkspacesFromOtherDevices: v }), + alwaysShowDefaultBranchWorkspace: true, + setAlwaysShowDefaultBranchWorkspace: (v) => set({ alwaysShowDefaultBranchWorkspace: v }), + + showDotfilesByWorktree: {}, + setShowDotfilesForWorktree: (worktreeId, showDotfiles) => + set((s) => { + if (!worktreeId) { + return s + } + const current = s.showDotfilesByWorktree[worktreeId] ?? true + if (current === showDotfiles) { + return s + } + const next = { ...s.showDotfilesByWorktree } + // Why: showing dotfiles is the default; only persist worktree-level opt-outs. + if (showDotfiles) { + delete next[worktreeId] + } else { + next[worktreeId] = false + } + return { showDotfilesByWorktree: next } + }), + toggleShowDotfilesForWorktree: (worktreeId) => + set((s) => { + if (!worktreeId) { + return s + } + const nextShowDotfiles = !(s.showDotfilesByWorktree[worktreeId] ?? true) + const next = { ...s.showDotfilesByWorktree } + if (nextShowDotfiles) { + delete next[worktreeId] + } else { + next[worktreeId] = false + } + return { showDotfilesByWorktree: next } + }), + + filterRepoIds: [], + setFilterRepoIds: (ids) => set({ filterRepoIds: ids }), + + collapsedGroups: new Set(), + toggleCollapsedGroup: (key) => + set((s) => { + const next = new Set(s.collapsedGroups) + if (next.has(key)) { + next.delete(key) + } else { + next.add(key) + } + window.api.ui.set({ collapsedGroups: [...next] }).catch(console.error) + return { collapsedGroups: next } + }), + + worktreeCardProperties: [...DEFAULT_WORKTREE_CARD_PROPERTIES], + _worktreeCardModeDefaulted: true, + setWorktreeCardMode: (mode) => { + const updates = getWorktreeCardModeUpdates(mode) + set((s) => ({ + settings: s.settings ? { ...s.settings, ...updates.settings } : s.settings, + worktreeCardProperties: updates.ui.worktreeCardProperties, + _worktreeCardModeDefaulted: true + })) + void Promise.all([ + window.api.settings.set(updates.settings).then((nextSettings) => { + if (nextSettings) { + set({ settings: nextSettings }) + } + }), + window.api.ui.set(updates.ui) + ]).catch(console.error) + }, + setWorktreeCardProperties: (properties) => { + const normalized = normalizeWorktreeCardProperties(properties) + set({ worktreeCardProperties: normalized, _worktreeCardModeDefaulted: false }) + window.api.ui + .set({ worktreeCardProperties: normalized, _worktreeCardModeDefaulted: false }) + .catch(console.error) + }, + agentActivityDisplayMode: DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE, + setAgentActivityDisplayMode: (mode) => { + const normalized = normalizeAgentActivityDisplayMode(mode) + window.api.ui.set({ agentActivityDisplayMode: normalized }).catch(console.error) + set({ agentActivityDisplayMode: normalized }) + }, + + workspaceStatuses: cloneDefaultWorkspaceStatuses(), + setWorkspaceStatuses: (statuses) => { + const normalized = normalizeWorkspaceStatuses(statuses) + window.api.ui.set({ workspaceStatuses: normalized }).catch(console.error) + set({ workspaceStatuses: normalized }) + }, + + workspaceBoardOpacity: 1, + setWorkspaceBoardOpacity: (opacity) => { + const clamped = clampWorkspaceBoardOpacity(opacity) + window.api.ui.set({ workspaceBoardOpacity: clamped }).catch(console.error) + set({ workspaceBoardOpacity: clamped }) + }, + + workspaceBoardColumnWidth: WORKSPACE_BOARD_COLUMN_WIDTH_DEFAULT, + setWorkspaceBoardColumnWidth: (width) => { + const clamped = clampWorkspaceBoardColumnWidth(width) + window.api.ui.set({ workspaceBoardColumnWidth: clamped }).catch(console.error) + set({ workspaceBoardColumnWidth: clamped }) + }, + + syncTaskStatusFromWorkspaceBoard: false, + setSyncTaskStatusFromWorkspaceBoard: (enabled) => { + window.api.ui.set({ syncTaskStatusFromWorkspaceBoard: enabled }).catch(console.error) + set({ syncTaskStatusFromWorkspaceBoard: enabled }) + }, + + statusBarItems: [...DEFAULT_STATUS_BAR_ITEMS], + toggleStatusBarItem: (item) => + set((s) => { + const current = s.statusBarItems || DEFAULT_STATUS_BAR_ITEMS + const updated = current.includes(item) + ? current.filter((i) => i !== item) + : [...current, item] + window.api.ui.set({ statusBarItems: updated }).catch(console.error) + return { statusBarItems: updated } + }), + + agentDashboardDrawerOpen: false, + setAgentDashboardDrawerOpen: (open) => set({ agentDashboardDrawerOpen: open }), + statusBarVisible: true, + setStatusBarVisible: (v) => { + window.api.ui.set({ statusBarVisible: v }).catch(console.error) + set({ statusBarVisible: v }) + }, + usagePercentageDisplay: DEFAULT_USAGE_PERCENTAGE_DISPLAY, + setUsagePercentageDisplay: (display) => { + const normalized = normalizeUsagePercentageDisplay(display) + // Why: changing the control is the discovery path, so permanently dismiss the one-time change notice. + window.api.ui + .set({ + usagePercentageDisplay: normalized, + usagePercentageDisplayChangeNoticeDismissed: true + }) + .catch(console.error) + set({ + usagePercentageDisplay: normalized, + usagePercentageDisplayChangeNoticeDismissed: true + }) + }, + statusBarUsageMode: DEFAULT_STATUS_BAR_USAGE_MODE, + setStatusBarUsageMode: (mode) => { + const normalized = normalizeStatusBarUsageMode(mode) + window.api.ui.set({ statusBarUsageMode: normalized }).catch(console.error) + set({ statusBarUsageMode: normalized }) + } + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-settings-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-settings-actions.ts new file mode 100644 index 00000000000..93a4d0069fb --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-settings-actions.ts @@ -0,0 +1,65 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' +import { isSettingsNavigationTarget } from '../../../lib/settings-navigation-types' + +export function createUiSettingsActions(set: UISliceSet, get: UISliceGet): Partial { + return { + openSettingsPage: () => { + // Why: settings search is a transient filter; opening Settings shouldn't inherit hidden sections from last visit. + get().setSettingsSearchQuery('') + set((state) => ({ + activeView: 'settings', + // Why: preserve the originating view so Settings back returns there (e.g. in-progress draft), not always terminal. + previousViewBeforeSettings: + state.activeView === 'settings' ? state.previousViewBeforeSettings : state.activeView + })) + }, + closeSettingsPage: () => + set((state) => { + const previousView = + state.previousViewBeforeSettings === 'activity' && + state.settings?.experimentalActivity !== true + ? 'terminal' + : state.previousViewBeforeSettings + return { activeView: previousView } + }), + settingsNavigationTarget: null, + openSettingsTarget: (target) => { + if (!isSettingsNavigationTarget(target)) { + if (import.meta.env.DEV) { + throw new TypeError('openSettingsTarget received an invalid navigation target') + } + return + } + set({ settingsNavigationTarget: target }) + }, + clearSettingsTarget: () => set({ settingsNavigationTarget: null }), + settingsProjectHostSelection: {}, + settingsProjectSetupSelection: {}, + // Why: renderer-only, never persisted — no window.api.ui.set, and absent from the debounced UI writer in App.tsx. + setSettingsProjectHostSelection: (projectId, hostId, setupId) => + set((s) => { + const nextSetupSelections = { ...s.settingsProjectSetupSelection } + if (setupId) { + nextSetupSelections[projectId] = setupId + } else { + delete nextSetupSelections[projectId] + } + if ( + s.settingsProjectHostSelection[projectId] === hostId && + s.settingsProjectSetupSelection[projectId] === setupId + ) { + return s + } + return { + settingsProjectHostSelection: { + ...s.settingsProjectHostSelection, + [projectId]: hostId + }, + settingsProjectSetupSelection: nextSetupSelections + } + }), + appearanceAccordionDeepLink: null, + setAppearanceAccordionDeepLink: (section) => set({ appearanceAccordionDeepLink: section }), + clearAppearanceAccordionDeepLink: () => set({ appearanceAccordionDeepLink: null }) + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts new file mode 100644 index 00000000000..fb83ff6fc9b --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-surface-actions.ts @@ -0,0 +1,169 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' +import { revokeCustomPetBlobUrl } from '../../../components/pet/pet-blob-cache' +import { DEFAULT_PET_ID } from '../../../components/pet/pet-models' +import { + PET_SIZE_DEFAULT, + PET_SIZE_MAX, + PET_SIZE_MIN, + type CustomPet +} from '../../../../../shared/pet-types' +import { clampPetSize } from './ui-slice-hydration-sanitizers' + +export function createUiSurfaceActions(set: UISliceSet, _get: UISliceGet): Partial { + return { + workspacePortScan: null, + workspacePortScansByKey: {}, + workspacePortScanRefreshing: false, + setWorkspacePortScan: (scan) => + set((state) => { + if (!scan) { + if (!state.workspacePortScan && Object.keys(state.workspacePortScansByKey).length === 0) { + return state + } + return { workspacePortScan: null, workspacePortScansByKey: {} } + } + if ( + state.workspacePortScan?.key === scan.key && + state.workspacePortScan.result === scan.result && + state.workspacePortScansByKey[scan.key] === scan.result + ) { + return state + } + return { + workspacePortScan: scan, + workspacePortScansByKey: { ...state.workspacePortScansByKey, [scan.key]: scan.result } + } + }), + // Why: target changes rebuild the aggregate without republishing or clearing per-host scans. + setWorkspacePortScanProjection: (scan) => + set((state) => { + if ( + state.workspacePortScan?.key === scan?.key && + state.workspacePortScan?.result === scan?.result + ) { + return state + } + return { workspacePortScan: scan } + }), + // Why: drop stale per-host scans in one store update so a large host set can't fan out notifications to every subscriber. + replaceWorkspacePortScans: (scansByKey, projection) => + set((state) => { + if ( + state.workspacePortScansByKey === scansByKey && + state.workspacePortScan?.key === projection?.key && + state.workspacePortScan?.result === projection?.result + ) { + return state + } + return { workspacePortScansByKey: scansByKey, workspacePortScan: projection } + }), + setWorkspacePortScanForKey: (key, result) => + set((state) => { + const currentResult = state.workspacePortScansByKey[key] + if (currentResult === result || (!result && !currentResult)) { + return state + } + const nextScansByKey = { ...state.workspacePortScansByKey } + if (result) { + nextScansByKey[key] = result + } else { + delete nextScansByKey[key] + } + return { + workspacePortScansByKey: nextScansByKey, + workspacePortScan: + state.workspacePortScan?.key === key + ? result + ? { key, result } + : null + : state.workspacePortScan + } + }), + setWorkspacePortScanRefreshing: (refreshing) => + set({ workspacePortScanRefreshing: refreshing }), + + // Why: default true so enabling experimentalPet shows the pet immediately (persisted; "Hide pet" flips it false). + petVisible: true, + setPetVisible: (v) => { + window.api.ui.set({ petVisible: v }).catch(console.error) + set({ petVisible: v }) + }, + + petId: DEFAULT_PET_ID, + setPetId: (id) => { + window.api.ui.set({ petId: id }).catch(console.error) + set({ petId: id }) + }, + + petSize: PET_SIZE_DEFAULT, + setPetSize: (size) => { + const clamped = clampPetSize(size, { + min: PET_SIZE_MIN, + max: PET_SIZE_MAX, + fallback: PET_SIZE_DEFAULT + }) + window.api.ui.set({ petSize: clamped }).catch(console.error) + set({ petSize: clamped }) + }, + + customPets: [], + addCustomPet: (model) => + set((s) => { + const next = [...s.customPets.filter((m) => m.id !== model.id), model] + window.api.ui.set({ customPets: next }).catch(console.error) + return { customPets: next } + }), + removeCustomPet: (id) => + set((s) => { + const target = s.customPets.find((m) => m.id === id) + if (!target) { + return s + } + const next = s.customPets.filter((m) => m.id !== id) + // Why: removing the active custom pet falls back to bundled default so the overlay isn't empty. + const fallback = s.petId === id ? DEFAULT_PET_ID : s.petId + // Why: single combined IPC update so customPets and petId persist atomically. + const ipcPayload: { customPets: CustomPet[]; petId?: string } = { + customPets: next + } + if (fallback !== s.petId) { + ipcPayload.petId = fallback + } + window.api.ui.set(ipcPayload).catch(console.error) + // Why: revoke the cached blob: URL so the Blob is released, not leaked for the session. + revokeCustomPetBlobUrl(id) + // Why: best-effort delete — bytes owned by main; fresh-UUID imports mean an orphaned file is never re-referenced. + window.api.pet.delete(id, target.fileName, target.kind).catch(console.error) + const partial: Partial = { customPets: next } + if (fallback !== s.petId) { + partial.petId = fallback + } + return partial + }), + + pendingRevealWorktree: null, + pendingRevealSidebarRow: null, + revealWorktreeInSidebar: (worktreeId, options) => + set({ + pendingRevealWorktree: { + worktreeId, + ...(options?.executionHostId ? { executionHostId: options.executionHostId } : {}), + behavior: options?.behavior ?? 'smooth', + ...(options?.highlight ? { highlight: true } : {}), + ...(options?.beginRename ? { beginRename: true } : {}) + } + }), + revealSidebarRow: (rowKey, options) => + set({ + pendingRevealSidebarRow: { + rowKey, + behavior: options?.behavior ?? 'smooth', + ...(options?.highlight === false ? {} : { highlight: true }) + } + }), + clearPendingRevealWorktreeId: () => set({ pendingRevealWorktree: null }), + clearPendingRevealSidebarRow: () => set({ pendingRevealSidebarRow: null }), + scrollToDiffCommentId: null, + setScrollToDiffCommentId: (id) => set({ scrollToDiffCommentId: id }) + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-task-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-task-actions.ts new file mode 100644 index 00000000000..efee4b93ce9 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-task-actions.ts @@ -0,0 +1,215 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' +import { findPrevLiveNonTaskStackHistoryIndex } from '../worktree-nav-history' +import { hasFeatureInteraction } from '../../../../../shared/feature-interactions' +import { + normalizeVisibleTaskProviders, + restoreAvailableDefaultTaskProvider, + resolveVisibleTaskProvider +} from '../../../../../shared/task-providers' +import { PER_REPO_FETCH_LIMIT } from '../../../../../shared/work-items' +import { isGitRepoKind } from '../../../../../shared/repo-kind' +import { presetToQuery } from './ui-slice-hydration-sanitizers' + +const LINEAR_TASK_PREFETCH_LIMIT = 36 + +export function createUiTaskActions(set: UISliceSet, get: UISliceGet): Partial { + return { + activeView: 'terminal', + previousViewBeforeTasks: 'terminal', + previousViewBeforeSettings: 'terminal', + previousViewBeforeActivity: 'terminal', + previousViewBeforeAutomations: 'terminal', + previousViewBeforeSpace: 'terminal', + previousViewBeforeSkills: 'terminal', + pendingSkillShareId: null, + pendingSkillsSharedView: false, + previousViewBeforeMobile: 'terminal', + previousViewBeforeArtifacts: 'terminal', + setActiveView: (view) => set({ activeView: view }), + taskPageData: {}, + taskResumeState: undefined, + taskListPosition: null, + githubTaskDrawerWorkItem: null, + newWorkspaceDraft: null, + openTaskPage: (data = {}, options = {}) => { + if (options.recordTasksInteraction !== false) { + const wasTasksPreviouslyInteracted = hasFeatureInteraction( + get().featureInteractions, + 'tasks' + ) + set((state) => ({ + contextualTourNavigationInteractionSnapshot: { + ...state.contextualTourNavigationInteractionSnapshot, + tasks: wasTasksPreviouslyInteracted + } + })) + get().recordFeatureInteraction?.('tasks') + } + if (data.openGitHubWorkItem) { + get().recordFeatureInteraction?.('github-tasks') + } + if (data.openGitLabWorkItem) { + get().recordFeatureInteraction?.('gitlab-tasks') + } + if (data.openLinearIssue) { + get().recordFeatureInteraction?.('linear-tasks') + } + if (data.openJiraIssue) { + get().recordFeatureInteraction?.('jira-tasks') + } + // Why: record a Tasks visit in shared back/forward history; all task-source variants collapse to one deduped 'tasks' entry. + const detailEntry = data.openGitHubWorkItem + ? ({ + kind: 'task-detail', + source: 'github', + workItem: data.openGitHubWorkItem, + sourceContext: data.openGitHubSourceContext, + initialTab: data.openGitHubInitialTab + } as const) + : data.openGitLabWorkItem + ? ({ + kind: 'task-detail', + source: 'gitlab', + workItem: data.openGitLabWorkItem, + sourceContext: data.openGitLabSourceContext + } as const) + : data.openLinearIssue + ? ({ + kind: 'task-detail', + source: 'linear', + issue: data.openLinearIssue, + sourceContext: data.openLinearSourceContext + } as const) + : data.openJiraIssue + ? ({ + kind: 'task-detail', + source: 'jira', + issue: data.openJiraIssue, + sourceContext: data.openJiraSourceContext + } as const) + : null + const currentEntry = get().worktreeNavHistory[get().worktreeNavHistoryIndex] + const currentIsTaskStack = + currentEntry === 'tasks' || + (typeof currentEntry === 'object' && currentEntry.kind === 'task-detail') + if (!detailEntry || !currentIsTaskStack) { + get().recordViewVisit('tasks') + } + if (detailEntry) { + get().recordViewVisit(detailEntry) + } + set((state) => ({ + activeView: 'tasks', + previousViewBeforeTasks: + state.activeView === 'tasks' ? state.previousViewBeforeTasks : state.activeView, + taskPageData: data + })) + // Why: prefetch the work-item list during first render so the page's effect hits a warm/in-flight SWR cache (~300–800ms win). + const state = get() + const preferredVisibleTaskProviders = normalizeVisibleTaskProviders( + state.settings?.visibleTaskProviders + ) + const visibleTaskProviders = restoreAvailableDefaultTaskProvider( + preferredVisibleTaskProviders, + { + gitlabInstalled: state.preflightStatus?.glab?.installed === true, + linearConnected: state.linearStatus?.connected === true + }, + state.settings?.defaultTaskSource + ) + const resolvedSource = resolveVisibleTaskProvider( + data.taskSource ?? state.settings?.defaultTaskSource, + visibleTaskProviders + ) + const resolvedMode = state.taskResumeState?.githubMode ?? 'items' + if (resolvedSource === 'github' && resolvedMode === 'items') { + const eligibleRepos = state.repos.filter((repo) => isGitRepoKind(repo) && repo.path) + const selectedRepos = (() => { + const preferred = data.preselectedRepoId + if (preferred) { + const repo = eligibleRepos.find((r) => r.id === preferred) + return repo ? [repo] : [] + } + const persisted = state.settings?.defaultRepoSelection + if (Array.isArray(persisted)) { + const selected = eligibleRepos.filter((repo) => persisted.includes(repo.id)) + if (selected.length > 0) { + return selected + } + } + return eligibleRepos + })() + + const resume = state.taskResumeState + const defaultPreset = state.settings?.defaultTaskViewPreset ?? 'all' + // Why: must match the query TaskPage's resume effect mounts with, else the warm cache key misses and prefetch is wasted. + const query = + resume?.githubItemsPreset === null + ? (resume.githubItemsQuery ?? '').trim() + : presetToQuery(resume?.githubItemsPreset ?? defaultPreset) + for (const repo of selectedRepos) { + state.prefetchWorkItems(repo.id, repo.path, PER_REPO_FETCH_LIMIT, query, { + sourceContext: + data.openGitHubSourceContext?.provider === 'github' && + data.openGitHubSourceContext.repoId === repo.id + ? data.openGitHubSourceContext + : null + }) + } + } + if (resolvedSource === 'linear' && typeof state.prefetchLinearIssues === 'function') { + const resume = state.taskResumeState + const query = (resume?.linearQuery ?? '').trim() + const sourceContext = + data.openLinearSourceContext?.provider === 'linear' ? data.openLinearSourceContext : null + if (query) { + state.prefetchLinearIssues( + { kind: 'search', query, limit: LINEAR_TASK_PREFETCH_LIMIT }, + { sourceContext } + ) + } else { + // Why: TaskPage no longer exposes Linear preset filters; keep prefetch aligned with the default unsearched issue list. + state.prefetchLinearIssues( + { + kind: 'list', + filter: 'all', + limit: LINEAR_TASK_PREFETCH_LIMIT + }, + { sourceContext } + ) + } + } + }, + setTaskResumeState: (updates) => + set((s) => { + const next = { ...s.taskResumeState, ...updates } + window.api.ui.set({ taskResumeState: next }).catch(console.error) + return { taskResumeState: next } + }), + setTaskListPosition: (taskListPosition) => set({ taskListPosition }), + setGithubTaskDrawerWorkItem: (item) => set({ githubTaskDrawerWorkItem: item }), + closeTaskPage: () => + set((state) => { + // Why: if parked on a 'tasks' entry, rewind the history index so Back/Forward aren't no-ops; keep 0 if it's the only entry. + const currentEntry = state.worktreeNavHistory[state.worktreeNavHistoryIndex] + let nextHistoryIndex = state.worktreeNavHistoryIndex + if ( + currentEntry === 'tasks' || + (typeof currentEntry === 'object' && currentEntry.kind === 'task-detail') + ) { + const prev = findPrevLiveNonTaskStackHistoryIndex(state) + if (prev !== null) { + nextHistoryIndex = prev + } else if (typeof currentEntry === 'object' && state.worktreeNavHistory[0] === 'tasks') { + nextHistoryIndex = 0 + } + } + return { + activeView: state.previousViewBeforeTasks, + taskPageData: {}, + githubTaskDrawerWorkItem: null, + worktreeNavHistoryIndex: nextHistoryIndex + } + }) + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-tour-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-tour-actions.ts new file mode 100644 index 00000000000..9f26a409260 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-tour-actions.ts @@ -0,0 +1,239 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' +import { getContextualTour } from '../../../../../shared/contextual-tours' +import { + getContextualTourRequestDecision, + hasContextualTourTarget, + getNextVisibleContextualTourStepIndex, + getPreviousVisibleContextualTourStepIndex +} from '../../../components/contextual-tours/contextual-tour-gate' +import { hasFeatureInteraction } from '../../../../../shared/feature-interactions' + +export function createUiTourActions(set: UISliceSet, get: UISliceGet): Partial { + return { + contextualToursSeenIds: [], + contextualToursAutoEligible: null, + activeContextualTourId: null, + activeContextualTourStepIndex: 0, + activeContextualTourSource: null, + activeContextualTourSourceDetached: false, + activeContextualTourWasFeaturePreviouslyInteracted: false, + contextualTourNavigationInteractionSnapshot: {}, + activeContextualTourSuppressed: false, + contextualTourShownThisSession: false, + contextualToursOnboardingVisible: false, + contextualToursBlockingSurfaceVisible: false, + lastCompletedContextualTourId: null, + setContextualToursAutoEligible: (eligible) => + set((s) => { + if (s.contextualToursAutoEligible === eligible) { + return s + } + if (typeof window !== 'undefined') { + window.api.ui.set({ contextualToursAutoEligible: eligible }).catch(console.error) + } + return { contextualToursAutoEligible: eligible } + }), + setContextualToursOnboardingVisible: (visible) => + set((s) => + s.contextualToursOnboardingVisible === visible + ? s + : { contextualToursOnboardingVisible: visible } + ), + setContextualToursBlockingSurfaceVisible: (visible) => + set((s) => + s.contextualToursBlockingSurfaceVisible === visible + ? s + : { contextualToursBlockingSurfaceVisible: visible } + ), + requestContextualTour: (id, source, wasFeaturePreviouslyInteracted, options) => + set((s) => { + const tour = getContextualTour(id) + const decision = getContextualTourRequestDecision({ + tour, + persistedUIReady: s.persistedUIReady, + autoEligible: options?.force === true || s.contextualToursAutoEligible === true, + onboardingVisible: s.contextualToursOnboardingVisible, + seenIds: options?.force === true ? [] : s.contextualToursSeenIds, + sessionConsumed: options?.force === true ? false : s.contextualTourShownThisSession, + activeTourId: s.activeContextualTourId, + activeModal: s.activeModal, + blockingSurfaceVisible: s.contextualToursBlockingSurfaceVisible, + targetExists: hasContextualTourTarget + }) + if (decision.kind !== 'start') { + if (s.contextualTourNavigationInteractionSnapshot[id] === undefined) { + return s + } + const { [id]: _consumed, ...remainingNavigationSnapshot } = + s.contextualTourNavigationInteractionSnapshot + void _consumed + return { contextualTourNavigationInteractionSnapshot: remainingNavigationSnapshot } + } + const navigationSnapshot = s.contextualTourNavigationInteractionSnapshot[id] + const { [id]: _consumed, ...remainingNavigationSnapshot } = + s.contextualTourNavigationInteractionSnapshot + void _consumed + return { + activeContextualTourId: id, + activeContextualTourStepIndex: decision.stepIndex, + activeContextualTourSource: source, + activeContextualTourSourceDetached: false, + activeContextualTourWasFeaturePreviouslyInteracted: + wasFeaturePreviouslyInteracted ?? + navigationSnapshot ?? + hasFeatureInteraction(s.featureInteractions, id), + contextualTourNavigationInteractionSnapshot: remainingNavigationSnapshot, + activeContextualTourSuppressed: false, + contextualTourShownThisSession: true, + lastCompletedContextualTourId: null + } + }), + suppressContextualTour: (id, source) => + set((s) => { + if ( + s.activeContextualTourId !== id || + s.activeContextualTourSource !== source || + s.activeContextualTourSourceDetached + ) { + return s + } + return s.activeContextualTourSuppressed ? s : { activeContextualTourSuppressed: true } + }), + detachContextualTourSource: (id, source) => + set((s) => { + if (s.activeContextualTourId !== id || s.activeContextualTourSource !== source) { + return s + } + return s.activeContextualTourSourceDetached + ? s + : { activeContextualTourSourceDetached: true } + }), + advanceContextualTour: () => + set((s) => { + if (!s.activeContextualTourId) { + return s + } + const tour = getContextualTour(s.activeContextualTourId) + const nextStepIndex = getNextVisibleContextualTourStepIndex({ + tour, + currentStepIndex: s.activeContextualTourStepIndex, + targetExists: hasContextualTourTarget + }) + if (nextStepIndex !== null) { + return { activeContextualTourStepIndex: nextStepIndex } + } + // Why: browser step 3's target lives in a closed menu until that step is active. + if ( + s.activeContextualTourId === 'browser' && + s.activeContextualTourStepIndex + 1 < tour.steps.length + ) { + return { activeContextualTourStepIndex: s.activeContextualTourStepIndex + 1 } + } + return s + }), + regressContextualTour: () => + set((s) => { + if (!s.activeContextualTourId) { + return s + } + const previousStepIndex = getPreviousVisibleContextualTourStepIndex({ + tour: getContextualTour(s.activeContextualTourId), + currentStepIndex: s.activeContextualTourStepIndex, + targetExists: hasContextualTourTarget + }) + if (previousStepIndex === null) { + return s + } + return { activeContextualTourStepIndex: previousStepIndex } + }), + dismissContextualTour: (id) => { + const activeTourId = get().activeContextualTourId + if (id && activeTourId !== id) { + return + } + const tourId = id ?? activeTourId + if (tourId) { + get().markContextualToursSeen([tourId]) + } + set((s) => { + if (id && s.activeContextualTourId !== id) { + return s + } + return { + activeContextualTourId: null, + activeContextualTourStepIndex: 0, + activeContextualTourSource: null, + activeContextualTourSourceDetached: false, + activeContextualTourWasFeaturePreviouslyInteracted: false, + activeContextualTourSuppressed: false, + lastCompletedContextualTourId: null + } + }) + }, + completeContextualTour: (id) => { + const activeTourId = get().activeContextualTourId + if (id && activeTourId !== id) { + return + } + const tourId = id ?? activeTourId + if (tourId) { + get().markContextualToursSeen([tourId]) + } + set((s) => { + if (id && s.activeContextualTourId !== id) { + return s + } + return { + activeContextualTourId: null, + activeContextualTourStepIndex: 0, + activeContextualTourSource: null, + activeContextualTourSourceDetached: false, + activeContextualTourWasFeaturePreviouslyInteracted: false, + activeContextualTourSuppressed: false, + lastCompletedContextualTourId: tourId ?? null + } + }) + }, + cancelContextualTour: (id) => + set((s) => { + const activeTourId = s.activeContextualTourId + const tourId = id ?? activeTourId + if (!tourId || (id && activeTourId !== id)) { + return s + } + const alreadyShown = s.contextualToursSeenIds.includes(tourId) + return { + activeContextualTourId: null, + activeContextualTourStepIndex: 0, + activeContextualTourSource: null, + activeContextualTourSourceDetached: false, + activeContextualTourWasFeaturePreviouslyInteracted: false, + activeContextualTourSuppressed: false, + lastCompletedContextualTourId: null, + contextualTourShownThisSession: alreadyShown ? s.contextualTourShownThisSession : false + } + }), + markContextualToursSeen: (ids) => + set((s) => { + if (ids.length === 0) { + return s + } + const current = new Set(s.contextualToursSeenIds) + let changed = false + for (const id of ids) { + if (!current.has(id)) { + current.add(id) + changed = true + } + } + if (!changed) { + return s + } + const next = [...current] + if (typeof window !== 'undefined') { + window.api.ui.set({ contextualToursSeenIds: next }).catch(console.error) + } + return { contextualToursSeenIds: next } + }) + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-tour-helpers.ts b/src/renderer/src/store/slices/ui/ui-slice-tour-helpers.ts new file mode 100644 index 00000000000..7d99992b9a4 --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-tour-helpers.ts @@ -0,0 +1,38 @@ +import type { AppState } from '../../types' +import type { FeatureInteractionId } from '../../../../../shared/feature-interactions' +import { getContextualTour } from '../../../../../shared/contextual-tours' +import { + getNextVisibleContextualTourStepIndex, + hasContextualTourTarget +} from '../../../components/contextual-tours/contextual-tour-gate' + +export function getContextualTourProgressionForFeatureInteraction( + state: AppState, + id: FeatureInteractionId +): 'advance' | 'complete' | 'reveal-sidebar-and-advance' | null { + if (!state.activeContextualTourId) { + return null + } + const tour = getContextualTour(state.activeContextualTourId) + const step = tour.steps[state.activeContextualTourStepIndex] + if (step?.advanceOnFeatureInteraction !== id) { + return null + } + const nextStepIndex = getNextVisibleContextualTourStepIndex({ + tour, + currentStepIndex: state.activeContextualTourStepIndex, + targetExists: hasContextualTourTarget + }) + if (nextStepIndex !== null) { + return 'advance' + } + if ( + state.activeContextualTourId === 'workspace-agent-sessions' && + state.activeContextualTourStepIndex === 0 && + id === 'terminal-pane-split' && + !state.sidebarOpen + ) { + return 'reveal-sidebar-and-advance' + } + return 'complete' +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts new file mode 100644 index 00000000000..8111c8f8f1f --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-trust-actions.ts @@ -0,0 +1,143 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' +import { getSetupScriptPromptDismissalKey } from '../../../lib/setup-script-prompt' + +export function createUiTrustActions(set: UISliceSet, _get: UISliceGet): Partial { + return { + trustedOrcaHooks: {}, + markOrcaHookScriptConfirmed: (repoId, kind, contentHash) => + set((s) => { + const existing = s.trustedOrcaHooks[repoId] + const currentEntry = existing?.[kind] + if (currentEntry?.contentHash === contentHash) { + return s + } + const nextRepo = { + ...existing, + [kind]: { contentHash, approvedAt: Date.now() } + } + const next = { ...s.trustedOrcaHooks, [repoId]: nextRepo } + window.api.ui.set({ trustedOrcaHooks: next }).catch(console.error) + return { trustedOrcaHooks: next } + }), + markOrcaHookRepoAlwaysTrusted: (repoId) => + set((s) => { + const existing = s.trustedOrcaHooks[repoId] + if (existing?.all) { + return s + } + const next = { + ...s.trustedOrcaHooks, + [repoId]: { + ...existing, + all: { approvedAt: Date.now() } + } + } + window.api.ui.set({ trustedOrcaHooks: next }).catch(console.error) + return { trustedOrcaHooks: next } + }), + clearOrcaHookTrustForRepo: (repoId) => + set((s) => { + if (!(repoId in s.trustedOrcaHooks)) { + return s + } + const next = { ...s.trustedOrcaHooks } + delete next[repoId] + window.api.ui.set({ trustedOrcaHooks: next }).catch(console.error) + return { trustedOrcaHooks: next } + }), + setupScriptPromptDismissedRepoIds: [], + dismissSetupScriptPrompt: (repoHostIdentity) => + set((s) => { + const dismissalKey = getSetupScriptPromptDismissalKey(repoHostIdentity) + if (!repoHostIdentity || s.setupScriptPromptDismissedRepoIds.includes(dismissalKey)) { + return s + } + const next = [...s.setupScriptPromptDismissedRepoIds, dismissalKey] + window.api.ui.set({ setupScriptPromptDismissedRepoIds: next }).catch(console.error) + return { setupScriptPromptDismissedRepoIds: next } + }), + setupGuideSidebarDismissed: false, + setSetupGuideSidebarDismissed: (dismissed) => + set((s) => { + if (s.setupGuideSidebarDismissed === dismissed) { + return s + } + window.api.ui.set({ setupGuideSidebarDismissed: dismissed }).catch(console.error) + return { setupGuideSidebarDismissed: dismissed } + }), + setupGuideBrowserMilestoneMigrated: true, + setupGuideBrowserMilestoneLegacyComplete: false, + markSetupGuideBrowserMilestoneMigrated: (legacyComplete) => + set((s) => { + if ( + s.setupGuideBrowserMilestoneMigrated && + s.setupGuideBrowserMilestoneLegacyComplete === legacyComplete + ) { + return s + } + const updates = { + setupGuideBrowserMilestoneMigrated: true, + setupGuideBrowserMilestoneLegacyComplete: legacyComplete + } + window.api.ui.set(updates).catch(console.error) + return updates + }), + browserImportHintHidden: false, + setBrowserImportHintHidden: (hidden) => + set((s) => { + if (s.browserImportHintHidden === hidden) { + return s + } + window.api.ui.set({ browserImportHintHidden: hidden }).catch(console.error) + return { browserImportHintHidden: hidden } + }), + mobileEmulatorTabIntroDismissed: false, + dismissMobileEmulatorTabIntro: () => + set((s) => { + if (s.mobileEmulatorTabIntroDismissed) { + return s + } + window.api.ui.set({ mobileEmulatorTabIntroDismissed: true }).catch(console.error) + return { mobileEmulatorTabIntroDismissed: true } + }), + mobileEmulatorAgentSetupDismissed: false, + dismissMobileEmulatorAgentSetup: () => + set((s) => { + if (s.mobileEmulatorAgentSetupDismissed) { + return s + } + window.api.ui.set({ mobileEmulatorAgentSetupDismissed: true }).catch(console.error) + return { mobileEmulatorAgentSetupDismissed: true } + }), + projectOrderManualDefaultNoticeDismissed: true, + dismissProjectOrderManualDefaultNotice: () => + set((s) => { + if (s.projectOrderManualDefaultNoticeDismissed) { + return s + } + window.api.ui.set({ projectOrderManualDefaultNoticeDismissed: true }).catch(console.error) + return { projectOrderManualDefaultNoticeDismissed: true } + }), + // Why: default true so pre-hydration / new sessions never flash the change notice before persistence resolves. + usagePercentageDisplayChangeNoticeDismissed: true, + dismissUsagePercentageDisplayChangeNotice: () => + set((s) => { + if (s.usagePercentageDisplayChangeNoticeDismissed) { + return s + } + window.api.ui + .set({ usagePercentageDisplayChangeNoticeDismissed: true }) + .catch(console.error) + return { usagePercentageDisplayChangeNoticeDismissed: true } + }), + usageEmptyStateDismissed: false, + dismissUsageEmptyState: () => + set((s) => { + if (s.usageEmptyStateDismissed) { + return s + } + window.api.ui.set({ usageEmptyStateDismissed: true }).catch(console.error) + return { usageEmptyStateDismissed: true } + }) + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts new file mode 100644 index 00000000000..e4beadf4c6c --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts @@ -0,0 +1,110 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' +import { + DEFAULT_BROWSER_PAGE_ZOOM_LEVEL, + normalizeBrowserPageZoomLevel +} from '../../../../../shared/browser-page-zoom' +import { normalizeKagiSessionLink } from '../../../../../shared/browser-url' +export function createUiUpdateActions(set: UISliceSet, get: UISliceGet): Partial { + return { + updateStatus: { state: 'idle' }, + setUpdateStatus: (status) => { + const prevState = get().updateStatus.state + const update: Partial< + Pick< + UISlice, + 'updateStatus' | 'updateChangelog' | 'updateCardCollapsed' | 'updateUserInitiatedCycle' + > + > = { + updateStatus: status + } + if (status.state === 'checking') { + update.updateUserInitiatedCycle = status.userInitiated === true + } else if (status.state === 'idle') { + update.updateUserInitiatedCycle = false + } + if (status.state === 'available') { + // Why: always overwrite (even with null) so a prior version's changelog can't leak into a later simple-mode update. + update.updateChangelog = status.changelog ?? null + } else if ( + status.state === 'idle' || + status.state === 'checking' || + status.state === 'not-available' + ) { + // Why: reset on cycle-boundary states so stale rich content from a previous cycle can't resurface. + update.updateChangelog = null + } + // 'downloading'/'downloaded'/'error': leave updateChangelog untouched to keep the original 'available' content. + if (status.state !== prevState) { + // Why: re-surface the card on each phase transition so a collapsed `downloading` doesn't bury `downloaded`/`error`. + update.updateCardCollapsed = false + } + set(update) + }, + updateChangelog: null, + updateUserInitiatedCycle: false, + dismissedUpdateVersion: null, + clearDismissedUpdateVersion: () => { + set({ dismissedUpdateVersion: null }) + }, + releaseChannelOverride: null, + setReleaseChannelOverride: (channel) => { + void window.api.ui.set({ releaseChannelOverride: channel }).catch(console.error) + set({ releaseChannelOverride: channel }) + }, + dismissUpdate: (versionOverride?: string) => + set((s) => { + // Why: the 'error' variant has no version field, so the card passes it via versionOverride. + const dismissedUpdateVersion = + versionOverride ?? ('version' in s.updateStatus ? (s.updateStatus.version ?? null) : null) + const activeNudgeId = + 'activeNudgeId' in s.updateStatus ? (s.updateStatus.activeNudgeId ?? null) : null + // Why: persist dismissal so relaunch doesn't immediately re-show the same card until a newer release. + void window.api.ui.set({ dismissedUpdateVersion }).catch(console.error) + // Why: main can't otherwise tell an offered update was abandoned, which keeps a local-build session pinned and stalls background checks. + void window.api.updater.dismissAvailableUpdate().catch(console.error) + // Why: only consume the nudge campaign for cards from a nudge cycle, not ordinary dismissals. + if (activeNudgeId) { + void window.api.updater.dismissNudge().catch(console.error) + } + return { dismissedUpdateVersion, updateUserInitiatedCycle: false } + }), + updateCardCollapsed: false, + setUpdateCardCollapsed: (collapsed) => set({ updateCardCollapsed: collapsed }), + updateReassuranceSeen: false, + markUpdateReassuranceSeen: () => { + void window.api.ui.set({ updateReassuranceSeen: true }).catch(console.error) + set({ updateReassuranceSeen: true }) + }, + osc52ClipboardDefaultOnNoticePending: false, + clearOsc52ClipboardDefaultOnNotice: () => { + // Why clear locally first: a failed persist must not re-toast this session. It will + // re-arm on the next launch, which is the safe direction for a one-shot notice. + set({ osc52ClipboardDefaultOnNoticePending: false }) + void window.api.ui.set({ osc52ClipboardDefaultOnNoticePending: false }).catch(console.error) + }, + isFullScreen: false, + setIsFullScreen: (v) => set({ isFullScreen: v }), + browserDefaultUrl: null, + setBrowserDefaultUrl: (url) => { + void window.api.ui.set({ browserDefaultUrl: url }).catch(console.error) + set({ browserDefaultUrl: url }) + }, + browserDefaultSearchEngine: null, + setBrowserDefaultSearchEngine: (engine) => { + void window.api.ui.set({ browserDefaultSearchEngine: engine }).catch(console.error) + set({ browserDefaultSearchEngine: engine }) + }, + browserDefaultZoomLevel: DEFAULT_BROWSER_PAGE_ZOOM_LEVEL, + setBrowserDefaultZoomLevel: (level) => { + const normalized = normalizeBrowserPageZoomLevel(level) + void window.api.ui.set({ browserDefaultZoomLevel: normalized }).catch(console.error) + set({ browserDefaultZoomLevel: normalized }) + }, + browserKagiSessionLink: null, + setBrowserKagiSessionLink: (link) => { + const normalized = link ? normalizeKagiSessionLink(link) : null + void window.api.ui.set({ browserKagiSessionLink: normalized }).catch(console.error) + set({ browserKagiSessionLink: normalized }) + } + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-view-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-view-actions.ts new file mode 100644 index 00000000000..432572b799f --- /dev/null +++ b/src/renderer/src/store/slices/ui/ui-slice-view-actions.ts @@ -0,0 +1,111 @@ +import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract' +import { rewindHistoryIndexPastView } from '../worktree-nav-history' + +export function createUiViewActions(set: UISliceSet, get: UISliceGet): Partial { + return { + openActivityPage: () => { + if (get().settings?.experimentalActivity !== true) { + return + } + set((state) => ({ + activeView: 'activity', + previousViewBeforeActivity: + state.activeView === 'activity' ? state.previousViewBeforeActivity : state.activeView + })) + }, + closeActivityPage: () => + set((state) => ({ + activeView: state.previousViewBeforeActivity + })), + selectedAutomationId: null, + setSelectedAutomationId: (id) => set({ selectedAutomationId: id }), + pendingAutomationRunNavigation: null, + setPendingAutomationRunNavigation: (navigation) => + set({ pendingAutomationRunNavigation: navigation }), + openAutomationsPage: () => { + get().recordViewVisit('automations') + set((state) => ({ + activeView: 'automations', + previousViewBeforeAutomations: + state.activeView === 'automations' + ? state.previousViewBeforeAutomations + : state.activeView + })) + }, + closeAutomationsPage: () => + set((state) => ({ + activeView: state.previousViewBeforeAutomations, + worktreeNavHistoryIndex: rewindHistoryIndexPastView(state, 'automations') + })), + openSpacePage: () => { + get().recordFeatureInteraction?.('workspace-cleanup') + set((state) => ({ + activeView: 'space', + previousViewBeforeSpace: + state.activeView === 'space' ? state.previousViewBeforeSpace : state.activeView + })) + }, + closeSpacePage: () => + set((state) => ({ + activeView: state.previousViewBeforeSpace + })), + openSkillsPage: () => { + get().recordViewVisit('skills') + set((state) => ({ + activeView: 'skills', + previousViewBeforeSkills: + state.activeView === 'skills' ? state.previousViewBeforeSkills : state.activeView + })) + }, + closeSkillsPage: () => + set((state) => ({ + activeView: state.previousViewBeforeSkills, + worktreeNavHistoryIndex: rewindHistoryIndexPastView(state, 'skills') + })), + openSkillShare: (shareId) => { + get().recordViewVisit('skills') + set((state) => ({ + activeView: 'skills', + previousViewBeforeSkills: + state.activeView === 'skills' ? state.previousViewBeforeSkills : state.activeView, + pendingSkillShareId: shareId + })) + }, + clearPendingSkillShare: () => set({ pendingSkillShareId: null }), + openSkillsSharedLinks: () => { + get().recordViewVisit('skills') + set((state) => ({ + activeView: 'skills', + previousViewBeforeSkills: + state.activeView === 'skills' ? state.previousViewBeforeSkills : state.activeView, + pendingSkillsSharedView: true + })) + }, + clearPendingSkillsSharedView: () => set({ pendingSkillsSharedView: false }), + openArtifactsPage: () => { + get().recordViewVisit('artifacts') + set((state) => ({ + activeView: 'artifacts', + previousViewBeforeArtifacts: + state.activeView === 'artifacts' ? state.previousViewBeforeArtifacts : state.activeView + })) + }, + closeArtifactsPage: () => + set((state) => ({ + activeView: state.previousViewBeforeArtifacts, + worktreeNavHistoryIndex: rewindHistoryIndexPastView(state, 'artifacts') + })), + openMobilePage: () => + set((state) => ({ + activeView: 'mobile', + previousViewBeforeMobile: + state.activeView === 'mobile' ? state.previousViewBeforeMobile : state.activeView + })), + closeMobilePage: () => + set((state) => ({ + activeView: state.previousViewBeforeMobile + })), + setNewWorkspaceDraft: (draft) => set({ newWorkspaceDraft: draft }), + clearNewWorkspaceDraft: () => set({ newWorkspaceDraft: null }) + } +} diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index 17f48be0159..edd43350aca 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -1,2432 +1,11 @@ -/* eslint-disable max-lines -- Why: keep the shortcut registry, parser, formatter, and conflict detector in one shared module so main/renderer/browser/Settings can't drift. */ -import type { TuiAgent } from './tui-agent' -import { ALL_TUI_AGENTS, TUI_AGENT_DISPLAY_NAMES } from './tui-agent-display-names' - -export type KeybindingScope = - | 'global' - | 'tabs' - | 'terminal' - | 'browser' - | 'editor' - | 'fileExplorer' - | 'composer' - | 'settings' - -export type KeybindingContext = 'app' | 'terminal' | 'browser' - -export type KeybindingPlatform = 'darwin' | 'linux' | 'win32' - -export type TerminalShortcutPolicy = 'orca-first' | 'terminal-first' - -export type KeybindingMatchOptions = { - context?: KeybindingContext - terminalShortcutPolicy?: TerminalShortcutPolicy -} - -export type AgentTabActionId = `tab.newAgent.${TuiAgent}` -export type PluginKeybindingActionId = `plugin:${string}` - -export type KeybindingActionId = - | 'worktree.quickOpen' - | 'worktree.palette' - | 'worktree.navigateUp' - | 'worktree.navigateDown' - | 'app.settings' - | 'app.forceReload' - | 'workspace.create' - | 'workspace.rename' - | 'workspace.delete' - | 'workspace.openBoard' - | 'workspace.selectByIndex' - | 'voice.dictation' - | 'view.tasks' - | 'dashboard.toggle' - | 'sidebar.left.toggle' - | 'sidebar.right.toggle' - | 'sidebar.explorer.toggle' - | 'sidebar.search.toggle' - | 'sidebar.sourceControl.toggle' - | 'sidebar.checks.toggle' - | 'sidebar.ports.toggle' - | 'sidebar.sleepingWorkspaces.toggle' - | 'sidebar.focusWorktreeList' - | 'floatingTerminal.toggle' - | 'floatingWorkspace.maximize' - | 'floatingWorkspace.minimize' - | 'zoom.in' - | 'zoom.out' - | 'zoom.reset' - | 'worktree.history.back' - | 'worktree.history.forward' - | 'tab.newTerminal' - | 'tab.newAgent' - | AgentTabActionId - | 'tab.newBrowser' - | 'tab.newSimulator' - | 'tab.newMarkdown' - | 'tab.openMarkdown' - | 'tab.close' - | 'tab.closeAll' - | 'tab.rename' - | 'tab.reopenClosed' - | 'tab.nextSameType' - | 'tab.previousSameType' - | 'tab.nextAllTypes' - | 'tab.previousAllTypes' - | 'tab.previousRecent' - | 'tab.nextTerminal' - | 'tab.previousTerminal' - | 'tab.selectByIndex' - | 'tab.openQuickCommandsMenu' - | 'browser.find' - | 'browser.back' - | 'browser.forward' - | 'browser.reload' - | 'browser.hardReload' - | 'browser.focusAddressBar' - | 'browser.grabElement' - | 'editor.find' - | 'editor.replace' - | 'editor.save' - | 'editor.markdownPreview' - | 'editor.toggleWordWrap' - | 'editor.copyContext' - | 'editor.previousChange' - | 'editor.nextChange' - | 'editor.addReviewNote' - | 'sourceControl.sendReviewNotes' - | 'fileExplorer.undo' - | 'fileExplorer.redo' - | 'fileExplorer.copyPath' - | 'fileExplorer.copyRelativePath' - | 'fileExplorer.delete' - | 'settings.search' - | 'terminal.copySelection' - | 'terminal.selectAll' - | 'terminal.paste' - | 'terminal.search' - | 'terminal.clear' - | 'terminal.focusNextPane' - | 'terminal.focusPreviousPane' - | 'terminal.equalizePaneSizes' - | 'terminal.expandPane' - | 'terminal.setTitle' - | 'terminal.clearPaneTitle' - | 'terminal.closePane' - | 'terminal.splitRight' - | 'terminal.splitDown' - | 'terminal.switchInputSource' - | PluginKeybindingActionId - -export type KeybindingOverrides = Partial> - -export type KeybindingFileDiagnostic = { - severity: 'warning' | 'error' - message: string - actionId?: string - section?: string -} - -export type KeybindingFileSnapshot = { - path: string - platform: KeybindingPlatform - exists: boolean - overrides: KeybindingOverrides - commonOverrides: KeybindingOverrides - platformOverrides: Partial> - diagnostics: KeybindingFileDiagnostic[] -} - -type PlatformBindings = { - darwin: readonly string[] - linux: readonly string[] - win32: readonly string[] -} - -export type KeybindingDefinition = { - id: KeybindingActionId - title: string - group: string - scope: KeybindingScope - searchKeywords: readonly string[] - defaultBindings: PlatformBindings - allowInTerminal?: boolean - allowBareKeybindings?: boolean - allowShiftOnlyKeybindings?: boolean - conflictGroup?: string -} - -export type ModifierToken = 'Mod' | 'Cmd' | 'Ctrl' | 'Alt' | 'Shift' -export type PhysicalModifierToken = Exclude - -export type KeybindingInput = { - key?: string - code?: string - alt?: boolean - meta?: boolean - control?: boolean - shift?: boolean - altKey?: boolean - metaKey?: boolean - ctrlKey?: boolean - shiftKey?: boolean - // Set only by the double-tap detector; always a physical token (never 'Mod'). - doubleTapModifier?: PhysicalModifierToken -} - -type ParsedKeybinding = { - mod: boolean - meta: boolean - control: boolean - alt: boolean - shift: boolean - key: string - doubleTapModifier?: ModifierToken -} - -type NormalizeKeybindingOptions = { - allowBareKeybindings?: boolean - allowShiftOnlyKeybindings?: boolean -} - -export type KeybindingValidationResult = { ok: true; value: string } | { ok: false; error: string } - -export type KeybindingConflict = { - binding: string - actionIds: KeybindingActionId[] -} - -export type FindKeybindingConflictOptions = { - ignoredActionIds?: Iterable - relevantActionIds?: Iterable -} - -export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ - { - id: 'worktree.quickOpen', - title: 'Go to File', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'global', 'file', 'quick open'], - defaultBindings: platformBindings(['Mod+P']) - }, - { - id: 'app.settings', - title: 'Open Settings', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'settings', 'preferences'], - defaultBindings: platformBindings(['Mod+Comma']), - conflictGroup: 'menu' - }, - { - id: 'app.forceReload', - title: 'Force Reload', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'reload', 'refresh', 'force'], - defaultBindings: platformBindings(['Mod+Shift+R']), - conflictGroup: 'menu' - }, - { - id: 'worktree.palette', - title: 'Switch worktree', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'global', 'worktree', 'switch', 'jump'], - defaultBindings: { - darwin: ['Mod+J'], - linux: ['Mod+Shift+J'], - win32: ['Mod+Shift+J'] - } - }, - { - id: 'worktree.navigateUp', - title: 'Previous worktree', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'global', 'worktree', 'previous', 'up'], - defaultBindings: platformBindings(['Mod+Shift+ArrowUp']) - }, - { - id: 'worktree.navigateDown', - title: 'Next worktree', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'global', 'worktree', 'next', 'down'], - defaultBindings: platformBindings(['Mod+Shift+ArrowDown']) - }, - { - id: 'workspace.create', - title: 'Create worktree', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'global', 'worktree', 'create', 'new workspace'], - defaultBindings: platformBindings(['Mod+N', 'Mod+Shift+N']) - }, - { - id: 'workspace.rename', - title: 'Rename worktree', - group: 'Global', - scope: 'global', - conflictGroup: 'workspace-shell', - searchKeywords: ['shortcut', 'global', 'worktree', 'rename', 'workspace', 'title'], - // Why: macOS only — Windows/Linux Ctrl+Alt+R has no safe default (Ctrl+R reverse-search, Ctrl+Shift+R reload are taken). - defaultBindings: { - darwin: ['Mod+Alt+R'], - linux: [], - win32: [] - } - }, - { - id: 'workspace.delete', - title: 'Delete Workspace', - group: 'Global', - scope: 'global', - searchKeywords: [ - 'shortcut', - 'global', - 'workspace', - 'current workspace', - 'worktree', - 'delete', - 'remove', - 'trash' - ], - // Why: Backspace avoids the terminal pane's D-based split shortcuts on every platform. - defaultBindings: platformBindings(['Mod+Shift+Backspace']), - allowInTerminal: true - }, - { - id: 'workspace.openBoard', - title: 'Toggle Workspace Board', - group: 'Global', - scope: 'global', - searchKeywords: [ - 'shortcut', - 'global', - 'workspace', - 'board', - 'kanban', - 'worktree', - 'toggle', - 'open', - 'close' - ], - // Why: configurable but unbound by default, to not take a global chord from terminal/browser/editor users. - defaultBindings: platformBindings([]), - allowInTerminal: true - }, - { - id: 'dashboard.toggle', - title: 'Toggle Agent Dashboard', - group: 'Global', - scope: 'global', - searchKeywords: [ - 'shortcut', - 'global', - 'agent', - 'agents', - 'dashboard', - 'kanban', - 'board', - 'toggle', - 'open', - 'close' - ], - // Why: configurable but unbound by default, matching workspace.openBoard — an - // experimental surface must not claim a global chord from terminal users. - defaultBindings: platformBindings([]), - allowInTerminal: true - }, - { - id: 'workspace.selectByIndex', - title: 'Select Workspace 1–9', - group: 'Global', - scope: 'global', - searchKeywords: [ - 'shortcut', - 'global', - 'workspace', - 'worktree', - 'select', - 'switch', - 'number', - 'digit', - '1-9', - 'index' - ], - // Why: one remappable row covers the whole 1-9 range (stored chord is a representative; any of 1-9 fires it). - defaultBindings: platformBindings(['Mod+1']) - }, - { - id: 'voice.dictation', - title: 'Dictation', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'dictation', 'voice', 'speech', 'microphone'], - defaultBindings: platformBindings(['Mod+E']) - }, - { - id: 'view.tasks', - title: 'Open Tasks', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'tasks', 'github issues', 'linear'], - defaultBindings: platformBindings([]) - }, - { - id: 'sidebar.left.toggle', - title: 'Toggle Sidebar', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'sidebar', 'left'], - defaultBindings: platformBindings(['Mod+B']) - }, - { - id: 'sidebar.right.toggle', - title: 'Toggle Right Sidebar', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'sidebar', 'right'], - defaultBindings: platformBindings(['Mod+L']) - }, - { - id: 'sidebar.explorer.toggle', - title: 'Show Explorer', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'sidebar', 'explorer', 'files'], - defaultBindings: platformBindings(['Mod+Shift+E']) - }, - { - id: 'sidebar.search.toggle', - title: 'Show Search', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'sidebar', 'search'], - defaultBindings: platformBindings(['Mod+Shift+F']) - }, - { - id: 'sidebar.sourceControl.toggle', - title: 'Show Source Control', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'sidebar', 'source control', 'git'], - defaultBindings: platformBindings(['Mod+Shift+G']) - }, - { - id: 'sidebar.checks.toggle', - title: 'Show Checks', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'sidebar', 'checks', 'ci'], - defaultBindings: platformBindings([]) - }, - { - id: 'sidebar.ports.toggle', - title: 'Show Ports', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'sidebar', 'ports'], - defaultBindings: { - darwin: ['Mod+Shift+I'], - linux: [], - win32: [] - } - }, - { - id: 'sidebar.sleepingWorkspaces.toggle', - title: 'Toggle Sleeping Workspaces', - group: 'Global', - scope: 'global', - searchKeywords: [ - 'shortcut', - 'sidebar', - 'sleeping', - 'asleep', - 'workspaces', - 'worktree', - 'filter', - 'show', - 'hide' - ], - // Why: ship unbound (issue #5209 asks users to assign it), avoiding a claimed cross-platform chord. - defaultBindings: platformBindings([]) - }, - { - id: 'sidebar.focusWorktreeList', - title: 'Focus worktree list', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'sidebar', 'worktree', 'focus'], - // Why: keep zoom.reset on the browser-standard Mod+0; this chord was unreachable while it shared that default (#8584). - defaultBindings: platformBindings(['Mod+Shift+0']) - }, - { - id: 'floatingTerminal.toggle', - title: 'Toggle Floating Terminal', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'floating terminal', 'terminal'], - defaultBindings: platformBindings(['Mod+Alt+A']), - allowInTerminal: true - }, - { - id: 'floatingWorkspace.maximize', - title: 'Maximize Floating Workspace Panel', - group: 'Global', - scope: 'global', - searchKeywords: [ - 'shortcut', - 'floating', - 'workspace', - 'panel', - 'floating workspace', - 'workspace panel', - 'maximize', - 'expand' - ], - // Why: pairs with floatingTerminal.toggle (Cmd+Opt+A) so maximize stays one-handed; macOS-only, Linux/Windows unbound. - defaultBindings: { - darwin: ['Mod+Alt+Shift+A'], - linux: [], - win32: [] - }, - allowInTerminal: true - }, - { - id: 'floatingWorkspace.minimize', - title: 'Minimize Floating Workspace Panel', - group: 'Global', - scope: 'global', - searchKeywords: [ - 'shortcut', - 'floating', - 'workspace', - 'panel', - 'floating workspace', - 'workspace panel', - 'minimize', - 'hide' - ], - // Why: unbound everywhere since floatingTerminal.toggle owns show/hide; this exists only for an explicit user-bound "hide panel" shortcut. - defaultBindings: { - darwin: [], - linux: [], - win32: [] - }, - allowInTerminal: true - }, - { - id: 'zoom.in', - title: 'Zoom In', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'zoom', 'in', 'scale'], - defaultBindings: platformBindings(['Mod+Equal', 'Mod+Shift+Plus', 'Mod+NumpadAdd']) - }, - { - id: 'zoom.out', - title: 'Zoom Out', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'zoom', 'out', 'scale'], - defaultBindings: platformBindings(['Mod+Minus', 'Mod+NumpadSubtract']) - }, - { - id: 'zoom.reset', - title: 'Reset Size', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'zoom', 'reset', 'size', 'actual'], - defaultBindings: platformBindings(['Mod+0']) - }, - { - id: 'worktree.history.back', - title: 'Worktree History Back', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'worktree', 'history', 'back'], - defaultBindings: platformBindings(['Mod+Alt+ArrowLeft']), - allowInTerminal: true - }, - { - id: 'worktree.history.forward', - title: 'Worktree History Forward', - group: 'Global', - scope: 'global', - searchKeywords: ['shortcut', 'worktree', 'history', 'forward'], - defaultBindings: platformBindings(['Mod+Alt+ArrowRight']), - allowInTerminal: true - }, - { - id: 'tab.newTerminal', - title: 'New terminal tab', - group: 'Tabs', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'terminal', 'new'], - defaultBindings: platformBindings(['Mod+T']) - }, - { - id: 'tab.newAgent', - title: 'New agent tab (default agent)', - group: 'Tabs', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'agent', 'new', 'default', 'launch'], - // Why: macOS only — Windows Ctrl+Alt is AltGr and Linux Ctrl+Alt+T is the desktop "open terminal", so no safe default there. - defaultBindings: { - darwin: ['Mod+Alt+T'], - linux: [], - win32: [] - } - }, - { - id: 'tab.newBrowser', - title: 'New browser tab', - group: 'Tabs', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'browser', 'new'], - defaultBindings: platformBindings(['Mod+Shift+B']) - }, - { - id: 'tab.newSimulator', - title: 'New mobile emulator tab', - group: 'Tabs', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'simulator', 'emulator', 'mobile', 'ios', 'new'], - // Why: keep explorer on Mod+Shift+E (VS Code muscle memory); emulator is macOS-only and less common, so it yields to a free chord (#8533). - defaultBindings: { - darwin: ['Mod+Alt+Shift+E'], - linux: [], - win32: [] - } - }, - { - id: 'tab.newMarkdown', - title: 'New markdown tab', - group: 'Tabs', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'markdown', 'file', 'new'], - defaultBindings: platformBindings(['Mod+Shift+M']) - }, - { - id: 'tab.openMarkdown', - title: 'Open markdown tab', - group: 'Tabs', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'markdown', 'file', 'open'], - defaultBindings: platformBindings(['Mod+Shift+O']) - }, - { - id: 'tab.close', - title: 'Close active tab', - group: 'Tabs', - scope: 'tabs', - searchKeywords: ['shortcut', 'close', 'tab', 'pane'], - defaultBindings: platformBindings(['Mod+W']) - }, - { - id: 'tab.closeAll', - title: 'Close all editor tabs', - group: 'Tabs', - scope: 'tabs', - searchKeywords: ['shortcut', 'close', 'all', 'tabs', 'files', 'editors'], - defaultBindings: platformBindings(['Mod+Alt+W']) - }, - { - id: 'tab.rename', - title: 'Rename active tab', - group: 'Tabs', - scope: 'tabs', - conflictGroup: 'workspace-shell', - searchKeywords: ['shortcut', 'tab', 'rename', 'title', 'label'], - // Why: macOS only — Cmd+R is free in app/terminal focus; on Windows/Linux Ctrl+R is shell reverse-search, so left unbound. - defaultBindings: { - darwin: ['Mod+R'], - linux: [], - win32: [] - } - }, - { - id: 'tab.reopenClosed', - title: 'Reopen closed tab', - group: 'Tabs', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'reopen', 'restore', 'closed'], - defaultBindings: platformBindings(['Mod+Shift+T']) - }, - { - id: 'tab.nextSameType', - title: 'Next tab (same type)', - group: 'Tab Navigation', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'next', 'switch', 'cycle'], - // Why: the widespread "switch tab" chord (Mod+Shift+Bracket) now drives all-types cycling; same-type moved to Mod+Alt for new installs. - defaultBindings: platformBindings(['Mod+Alt+BracketRight']) - }, - { - id: 'tab.previousSameType', - title: 'Previous tab (same type)', - group: 'Tab Navigation', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'previous', 'switch', 'cycle'], - defaultBindings: platformBindings(['Mod+Alt+BracketLeft']) - }, - { - id: 'tab.nextAllTypes', - title: 'Next tab (all types)', - group: 'Tab Navigation', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'next', 'switch', 'cycle', 'all', 'any'], - defaultBindings: platformBindings(['Mod+Shift+BracketRight']) - }, - { - id: 'tab.previousAllTypes', - title: 'Previous tab (all types)', - group: 'Tab Navigation', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'previous', 'switch', 'cycle', 'all', 'any'], - defaultBindings: platformBindings(['Mod+Shift+BracketLeft']) - }, - { - id: 'tab.previousRecent', - title: 'Previous recent tab', - group: 'Tab Navigation', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'recent', 'mru', 'switch', 'last used'], - defaultBindings: platformBindings(['Ctrl+Tab']), - allowInTerminal: true - }, - { - id: 'tab.nextTerminal', - title: 'Next terminal tab', - group: 'Tab Navigation', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'terminal', 'next', 'switch'], - defaultBindings: platformBindings(['Ctrl+PageDown']), - allowInTerminal: true - }, - { - id: 'tab.previousTerminal', - title: 'Previous terminal tab', - group: 'Tab Navigation', - scope: 'tabs', - searchKeywords: ['shortcut', 'tab', 'terminal', 'previous', 'switch'], - defaultBindings: platformBindings(['Ctrl+PageUp']), - allowInTerminal: true - }, - { - id: 'tab.selectByIndex', - title: 'Select Tab 1–9', - group: 'Tab Navigation', - scope: 'tabs', - // Why: no shared conflictGroup with workspace.selectByIndex so swapping their modifiers isn't a false conflict; safe because resolveWindowShortcutAction checks the workspace range first. - searchKeywords: ['shortcut', 'tab', 'select', 'switch', 'number', 'digit', '1-9', 'index'], - // Why: representative chord for the 1-9 range (see workspace.selectByIndex); each platform avoids the workspace-jump chord (Mod+1-9). - defaultBindings: { - darwin: ['Ctrl+1'], - linux: ['Alt+1'], - win32: ['Alt+1'] - } - }, - { - id: 'tab.openQuickCommandsMenu', - title: 'Toggle Quick Commands menu', - group: 'Quick Commands', - scope: 'tabs', - // Why: this tab-scoped action is also routed through the main-window allowlist, so Settings must warn when it shadows global chords. - conflictGroup: 'global', - searchKeywords: ['shortcut', 'quick', 'command', 'menu', 'tab', 'group', 'toggle'], - defaultBindings: platformBindings([]) - }, - { - id: 'browser.find', - title: 'Find in Browser', - group: 'Browser', - scope: 'browser', - searchKeywords: ['shortcut', 'browser', 'find', 'search'], - defaultBindings: platformBindings(['Mod+F']) - }, - { - id: 'browser.back', - title: 'Go Back in Browser', - group: 'Browser', - scope: 'browser', - searchKeywords: ['shortcut', 'browser', 'history', 'back', 'previous'], - defaultBindings: { - darwin: ['Mod+BracketLeft'], - linux: ['Alt+ArrowLeft'], - win32: ['Alt+ArrowLeft'] - } - }, - { - id: 'browser.forward', - title: 'Go Forward in Browser', - group: 'Browser', - scope: 'browser', - searchKeywords: ['shortcut', 'browser', 'history', 'forward', 'next'], - defaultBindings: { - darwin: ['Mod+BracketRight'], - linux: ['Alt+ArrowRight'], - win32: ['Alt+ArrowRight'] - } - }, - { - id: 'browser.reload', - title: 'Reload Browser Page', - group: 'Browser', - scope: 'browser', - searchKeywords: ['shortcut', 'browser', 'reload', 'refresh'], - defaultBindings: platformBindings(['Mod+R']) - }, - { - id: 'browser.hardReload', - title: 'Hard Reload Browser Page', - group: 'Browser', - scope: 'browser', - searchKeywords: ['shortcut', 'browser', 'reload', 'refresh', 'cache'], - defaultBindings: platformBindings(['Mod+Shift+R']) - }, - { - id: 'browser.focusAddressBar', - title: 'Focus Browser Address Bar', - group: 'Browser', - scope: 'browser', - searchKeywords: ['shortcut', 'browser', 'address', 'url', 'location'], - defaultBindings: platformBindings(['Mod+L']) - }, - { - id: 'browser.grabElement', - title: 'Grab Page Element', - group: 'Browser', - scope: 'browser', - searchKeywords: ['shortcut', 'browser', 'grab', 'copy', 'element'], - defaultBindings: platformBindings(['Mod+C']) - }, - { - id: 'editor.find', - title: 'Find in editor', - group: 'Editors', - scope: 'editor', - searchKeywords: ['shortcut', 'editor', 'find', 'search'], - defaultBindings: platformBindings(['Mod+F']) - }, - { - id: 'editor.replace', - title: 'Replace in editor', - group: 'Editors', - scope: 'editor', - searchKeywords: ['shortcut', 'editor', 'replace', 'find', 'search'], - // Why: match the source editor's native replace shortcut per platform. - defaultBindings: { - darwin: ['Mod+Alt+F'], - linux: ['Mod+H'], - win32: ['Mod+H'] - } - }, - { - id: 'editor.save', - title: 'Save File', - group: 'Editors', - scope: 'editor', - searchKeywords: ['shortcut', 'editor', 'save'], - defaultBindings: platformBindings(['Mod+S']) - }, - { - id: 'editor.markdownPreview', - title: 'Show Markdown Preview', - group: 'Editors', - scope: 'editor', - searchKeywords: ['shortcut', 'editor', 'markdown', 'preview'], - defaultBindings: platformBindings(['Mod+Shift+V']) - }, - { - id: 'editor.toggleWordWrap', - title: 'Toggle Word Wrap', - group: 'Editors', - scope: 'editor', - searchKeywords: ['shortcut', 'editor', 'word wrap', 'wrap', 'long lines', 'soft wrap'], - // Why: Alt+Z matches VS Code; bare Alt+letter is not AltGr, so it stays cross-platform (#9974). - defaultBindings: platformBindings(['Alt+Z']) - }, - { - id: 'editor.copyContext', - title: 'Copy Context', - group: 'Editors', - scope: 'editor', - searchKeywords: ['shortcut', 'editor', 'copy', 'context'], - defaultBindings: platformBindings(['Mod+Alt+C']) - }, - // Why: F7 / Shift+F7 mirror VS Code / JetBrains diff-change nav; function keys are safe bare/Shift, so both opt into allowBareKeybindings. - { - id: 'editor.previousChange', - title: 'Go to Previous Change', - group: 'Editors', - scope: 'editor', - searchKeywords: ['shortcut', 'editor', 'diff', 'change', 'hunk', 'previous'], - defaultBindings: platformBindings(['Shift+F7']), - allowBareKeybindings: true - }, - { - id: 'editor.nextChange', - title: 'Go to Next Change', - group: 'Editors', - scope: 'editor', - searchKeywords: ['shortcut', 'editor', 'diff', 'change', 'hunk', 'next'], - defaultBindings: platformBindings(['F7']), - allowBareKeybindings: true - }, - { - id: 'editor.addReviewNote', - title: 'Add Review Note', - group: 'Editors', - scope: 'editor', - searchKeywords: ['shortcut', 'editor', 'markdown', 'note', 'comment', 'annotation', 'review'], - // Why: Ctrl+Alt+letter is AltGr text input on Windows/Linux, so an editor default must not reserve chars like Polish `ń`. - defaultBindings: platformBindings(['Mod+Shift+A']) - }, - { - id: 'sourceControl.sendReviewNotes', - title: 'Send Review Notes to Agent', - group: 'Global', - scope: 'global', - // Why: fires from the global capture handler even while the editor is focused, so Settings must warn on collisions with editor chords (e.g. Add Review Note) too, not just global ones. - conflictGroup: 'editor', - searchKeywords: [ - 'shortcut', - 'source control', - 'diff', - 'notes', - 'send', - 'agent', - 'review', - 'annotate' - ], - // Why: unbound by default so it never collides with existing chords; users opt in via Settings. - defaultBindings: platformBindings([]) - }, - { - id: 'fileExplorer.undo', - title: 'Undo file operation', - group: 'File Explorer', - scope: 'fileExplorer', - searchKeywords: ['shortcut', 'file explorer', 'undo'], - defaultBindings: platformBindings(['Mod+Z']) - }, - { - id: 'fileExplorer.redo', - title: 'Redo file operation', - group: 'File Explorer', - scope: 'fileExplorer', - searchKeywords: ['shortcut', 'file explorer', 'redo'], - defaultBindings: { - darwin: ['Mod+Shift+Z'], - linux: ['Mod+Shift+Z', 'Ctrl+Y'], - win32: ['Mod+Shift+Z', 'Ctrl+Y'] - } - }, - { - id: 'fileExplorer.copyPath', - title: 'Copy file path', - group: 'File Explorer', - scope: 'fileExplorer', - searchKeywords: ['shortcut', 'file explorer', 'copy', 'path'], - defaultBindings: { - darwin: ['Mod+Alt+C'], - linux: ['Alt+Shift+C'], - win32: ['Alt+Shift+C'] - } - }, - { - id: 'fileExplorer.copyRelativePath', - title: 'Copy relative file path', - group: 'File Explorer', - scope: 'fileExplorer', - searchKeywords: ['shortcut', 'file explorer', 'copy', 'relative', 'path'], - defaultBindings: platformBindings(['Mod+Alt+Shift+C']) - }, - { - id: 'fileExplorer.delete', - title: 'Delete file', - group: 'File Explorer', - scope: 'fileExplorer', - searchKeywords: ['shortcut', 'file explorer', 'delete', 'remove', 'trash'], - defaultBindings: { - darwin: ['Mod+Backspace', 'Delete'], - linux: ['Delete'], - win32: ['Delete'] - }, - allowBareKeybindings: true - }, - { - id: 'settings.search', - title: 'Search Settings', - group: 'Settings', - scope: 'settings', - searchKeywords: ['shortcut', 'settings', 'search', 'find'], - defaultBindings: platformBindings(['Mod+F']) - }, - { - id: 'terminal.copySelection', - title: 'Copy terminal selection', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'terminal', 'copy', 'selection'], - defaultBindings: { - darwin: ['Mod+C'], - linux: ['Ctrl+Shift+C', 'Ctrl+C'], - win32: ['Ctrl+Shift+C', 'Ctrl+C'] - } - }, - { - id: 'terminal.selectAll', - title: 'Select all terminal text', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'terminal', 'select', 'all'], - defaultBindings: { - darwin: ['Mod+A'], - linux: ['Ctrl+Shift+A'], - win32: ['Ctrl+Shift+A'] - } - }, - { - id: 'terminal.paste', - title: 'Paste into terminal', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'terminal', 'paste', 'clipboard'], - defaultBindings: { - darwin: ['Mod+V'], - linux: ['Ctrl+V', 'Ctrl+Shift+V', 'Shift+Insert'], - win32: ['Ctrl+V', 'Ctrl+Shift+V', 'Shift+Insert'] - } - }, - { - id: 'terminal.search', - title: 'Search active pane', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'terminal', 'search', 'find'], - defaultBindings: platformBindings(['Mod+F']) - }, - { - id: 'terminal.clear', - title: 'Clear active pane', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'pane', 'clear'], - defaultBindings: platformBindings(['Mod+K']) - }, - { - id: 'terminal.focusNextPane', - title: 'Focus next pane', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'pane', 'focus', 'next'], - defaultBindings: platformBindings(['Mod+BracketRight']) - }, - { - id: 'terminal.focusPreviousPane', - title: 'Focus previous pane', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'pane', 'focus', 'previous'], - defaultBindings: platformBindings(['Mod+BracketLeft']) - }, - { - id: 'terminal.equalizePaneSizes', - title: 'Equalize pane sizes', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'pane', 'split', 'equalize', 'resize', 'balance', 'size'], - defaultBindings: platformBindings([]) - }, - { - id: 'terminal.expandPane', - title: 'Expand / collapse pane', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'pane', 'expand', 'collapse'], - defaultBindings: platformBindings(['Mod+Shift+Enter']) - }, - { - id: 'terminal.setTitle', - title: 'Set Title…', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'terminal', 'pane', 'set title', 'title', 'rename'], - defaultBindings: platformBindings([]) - }, - { - id: 'terminal.clearPaneTitle', - title: 'Clear Pane Title', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'terminal', 'pane', 'clear title', 'remove title', 'title'], - defaultBindings: platformBindings([]) - }, - { - id: 'terminal.closePane', - title: 'Close active pane', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'pane', 'close'], - defaultBindings: platformBindings(['Mod+W']) - }, - { - id: 'terminal.splitRight', - title: 'Split terminal right', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'pane', 'split', 'right'], - defaultBindings: { - darwin: ['Mod+D'], - linux: ['Mod+Shift+D'], - win32: ['Mod+Shift+D'] - } - }, - { - id: 'terminal.splitDown', - title: 'Split terminal down', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: ['shortcut', 'pane', 'split', 'down'], - defaultBindings: { - darwin: ['Mod+Shift+D'], - linux: ['Alt+Shift+D'], - win32: ['Alt+Shift+D'] - } - }, - { - id: 'terminal.switchInputSource', - title: 'Switch input source / language (native)', - group: 'Terminal Panes', - scope: 'terminal', - searchKeywords: [ - 'shortcut', - 'input', - 'source', - 'language', - 'korean', - 'english', - 'ime', - 'switch', - 'hangul', - 'layout' - ], - defaultBindings: { - darwin: [], - linux: [], - win32: [] - }, - // Why: macOS uses Shift+Space as an input-source shortcut; Orca otherwise rejects Shift-only bindings to avoid stealing typed text. - allowShiftOnlyKeybindings: true - }, - ...buildAgentTabKeybindingDefinitions() -] - -/** Pre-swap tab-switch bindings; a one-time migration pins these for pre-release installs so upgrading users keep the shortcuts they learned. */ -export const LEGACY_TAB_SWITCH_BINDINGS: Readonly>> = { - 'tab.nextSameType': ['Mod+Shift+BracketRight'], - 'tab.previousSameType': ['Mod+Shift+BracketLeft'], - 'tab.nextAllTypes': ['Mod+Alt+BracketRight'], - 'tab.previousAllTypes': ['Mod+Alt+BracketLeft'] -} - -export function agentTabActionId(agent: TuiAgent): AgentTabActionId { - return `tab.newAgent.${agent}` -} - -// Why: one bindable action per agent; all ship unassigned since tab.newAgent covers the default, and Settings hides disabled agents. -function buildAgentTabKeybindingDefinitions(): KeybindingDefinition[] { - return ALL_TUI_AGENTS.map((agent) => ({ - id: agentTabActionId(agent), - title: `New ${TUI_AGENT_DISPLAY_NAMES[agent]} tab`, - group: 'Agents', - scope: 'tabs', - searchKeywords: [ - 'shortcut', - 'tab', - 'agent', - 'new', - 'launch', - agent, - TUI_AGENT_DISPLAY_NAMES[agent].toLowerCase() - ], - defaultBindings: platformBindings([]) - })) -} - -const DEFINITIONS_BY_ID = new Map( - KEYBINDING_DEFINITIONS.map((definition) => [definition.id, definition]) -) - -const DEFINITION_IDS = new Set( - KEYBINDING_DEFINITIONS.map((definition) => definition.id) -) - -// Why: these ids are single remappable rows whose chord is a representative — the digit canonicalizes to 1 but the binding fires for any 1-9. -export const DIGIT_INDEX_ACTION_IDS: readonly KeybindingActionId[] = [ - 'tab.selectByIndex', - 'workspace.selectByIndex' -] - -const DIGIT_INDEX_ACTION_ID_SET = new Set(DIGIT_INDEX_ACTION_IDS) - -// The representative key for a digit-index chord is a single 1-9 number key. -const DIGIT_INDEX_KEY_PATTERN = /^[1-9]$/ - -export function isDigitIndexActionId(actionId: KeybindingActionId): boolean { - return DIGIT_INDEX_ACTION_ID_SET.has(actionId) -} - -function platformBindings(bindings: readonly string[]): PlatformBindings { - return { - darwin: bindings, - linux: bindings, - win32: bindings - } -} - -export function getKeybindingPlatform(platform: NodeJS.Platform): KeybindingPlatform { - return platform === 'darwin' ? 'darwin' : platform === 'win32' ? 'win32' : 'linux' -} - -export function isKeybindingActionId(value: string): value is KeybindingActionId { - return DEFINITION_IDS.has(value as KeybindingActionId) || isPluginKeybindingActionId(value) -} - -export function isPluginKeybindingActionId(value: string): value is PluginKeybindingActionId { - return ( - value.length <= 400 && - /^plugin:[a-z0-9]+(?:-[a-z0-9]+)*\.[a-z0-9]+(?:-[a-z0-9]+)*\/[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*$/.test( - value - ) - ) -} - -function hasModifier( - input: KeybindingInput, - modifier: 'alt' | 'meta' | 'control' | 'shift' -): boolean { - if (modifier === 'alt') { - return Boolean(input.alt ?? input.altKey) - } - if (modifier === 'meta') { - return Boolean(input.meta ?? input.metaKey) - } - if (modifier === 'control') { - return Boolean(input.control ?? input.ctrlKey) - } - return Boolean(input.shift ?? input.shiftKey) -} - -function isFunctionKeyToken(key: string): boolean { - return /^F([1-9]|1[0-9]|2[0-4])$/.test(key) -} - -function normalizeKeyToken(token: string): string | null { - if (token === ' ') { - return 'Space' - } - const trimmed = token.trim() - if (!trimmed) { - return null - } - const upper = trimmed.toUpperCase() - if (upper.length === 1 && upper >= 'A' && upper <= 'Z') { - return upper - } - if (upper.length === 1 && upper >= '0' && upper <= '9') { - return upper - } - // Function keys F1–F24 (event.key/event.code report them verbatim, e.g. F7). - if (isFunctionKeyToken(upper)) { - return upper - } - - const simple: Record = { - '[': 'BracketLeft', - ']': 'BracketRight', - '{': 'BracketLeft', - '}': 'BracketRight', - '-': 'Minus', - _: 'Underscore', - '=': 'Equal', - '+': 'Plus', - ',': 'Comma', - '.': 'Period', - '/': 'Slash', - '\\': 'Backslash', - ';': 'Semicolon', - "'": 'Quote', - '`': 'Backquote', - RETURN: 'Enter', - ESC: 'Escape', - SPACEBAR: 'Space', - PGUP: 'PageUp', - PGDN: 'PageDown', - PLUS: 'Plus', - MINUS: 'Minus', - EQUAL: 'Equal', - UNDERSCORE: 'Underscore', - ARROWLEFT: 'ArrowLeft', - LEFT: 'ArrowLeft', - ARROWRIGHT: 'ArrowRight', - RIGHT: 'ArrowRight', - ARROWUP: 'ArrowUp', - UP: 'ArrowUp', - ARROWDOWN: 'ArrowDown', - DOWN: 'ArrowDown', - PAGEUP: 'PageUp', - PAGEDOWN: 'PageDown', - BACKSPACE: 'Backspace', - DELETE: 'Delete', - DEL: 'Delete', - INSERT: 'Insert', - INS: 'Insert', - ENTER: 'Enter', - TAB: 'Tab', - ESCAPE: 'Escape', - SPACE: 'Space', - BRACKETLEFT: 'BracketLeft', - BRACKETRIGHT: 'BracketRight', - NUMPADADD: 'NumpadAdd', - NUMPADSUBTRACT: 'NumpadSubtract', - ADD: 'NumpadAdd', - SUBTRACT: 'NumpadSubtract', - COMMA: 'Comma', - PERIOD: 'Period', - SLASH: 'Slash', - BACKSLASH: 'Backslash', - SEMICOLON: 'Semicolon', - QUOTE: 'Quote', - BACKQUOTE: 'Backquote' - } - - return simple[upper] ?? null -} - -function parseModifierToken(rawPart: string): ModifierToken | null { - const part = rawPart.toLowerCase() - if (part === 'mod' || part === 'cmdorctrl' || part === 'commandorcontrol') { - return 'Mod' - } - if (part === 'cmd' || part === 'command' || part === 'meta' || rawPart === '⌘') { - return 'Cmd' - } - if (part === 'ctrl' || part === 'control' || rawPart === '⌃') { - return 'Ctrl' - } - if (part === 'alt' || part === 'option' || part === 'opt' || rawPart === '⌥') { - return 'Alt' - } - if (part === 'shift' || rawPart === '⇧') { - return 'Shift' - } - return null -} - -function applyModifierToken(parsed: ParsedKeybinding, modifier: ModifierToken): void { - if (modifier === 'Mod') { - parsed.mod = true - } else if (modifier === 'Cmd') { - parsed.meta = true - } else if (modifier === 'Ctrl') { - parsed.control = true - } else if (modifier === 'Alt') { - parsed.alt = true - } else { - parsed.shift = true - } -} - -function emptyParsedKeybinding(): ParsedKeybinding { - return { mod: false, meta: false, control: false, alt: false, shift: false, key: '' } -} - -// Why: a double-tap is a bare modifier with no key, so it can't use the normal parse path; modifier validation is deferred to normalize. -function parseDoubleTapKeybinding(rawParts: string[]): ParsedKeybinding | null { - const modifiers: ModifierToken[] = [] - let sawDoubleTap = false - for (const rawPart of rawParts) { - if (rawPart.toLowerCase() === 'doubletap') { - if (sawDoubleTap) { - return null - } - sawDoubleTap = true - continue - } - const modifier = parseModifierToken(rawPart) - if (!modifier) { - return null - } - modifiers.push(modifier) - } - if (modifiers.length === 0) { - return null - } - const parsed = emptyParsedKeybinding() - for (const modifier of modifiers) { - applyModifierToken(parsed, modifier) - } - // Keep both flags when Mod is combined with a platform modifier, so normalize emits the shared "Mod or platform-specific, not both" error. - if (parsed.mod && (parsed.meta || parsed.control)) { - parsed.doubleTapModifier = 'Mod' - return parsed - } - if (modifiers.length > 1) { - return null - } - parsed.doubleTapModifier = modifiers[0] - return parsed -} - -function parseKeybinding(binding: string): ParsedKeybinding | null { - const rawParts = binding - .split('+') - .map((part) => part.trim()) - .filter(Boolean) - if (rawParts.length === 0) { - return null - } - - if (rawParts.some((part) => part.toLowerCase() === 'doubletap')) { - return parseDoubleTapKeybinding(rawParts) - } - - const parsed = emptyParsedKeybinding() - for (const rawPart of rawParts) { - const modifier = parseModifierToken(rawPart) - if (modifier) { - applyModifierToken(parsed, modifier) - continue - } - if (parsed.key) { - return null - } - const key = normalizeKeyToken(rawPart) - if (!key) { - return null - } - parsed.key = key - } - - return parsed.key ? parsed : null -} - -function canonicalizeParsedKeybinding(parsed: ParsedKeybinding): string { - if (parsed.doubleTapModifier) { - return `DoubleTap+${parsed.doubleTapModifier}` - } - const parts: string[] = [] - if (parsed.mod) { - parts.push('Mod') - } - if (parsed.meta) { - parts.push('Cmd') - } - if (parsed.control) { - parts.push('Ctrl') - } - if (parsed.alt) { - parts.push('Alt') - } - if (parsed.shift) { - parts.push('Shift') - } - parts.push(parsed.key) - return parts.join('+') -} - -function isSafeBareKey(parsed: ParsedKeybinding): boolean { - if (parsed.mod || parsed.meta || parsed.control || parsed.alt) { - return false - } - // Function keys produce no text, so they're safe bare or with Shift (Shift+letter stays unsafe). - if (parsed.shift) { - return isFunctionKeyToken(parsed.key) - } - return ( - isFunctionKeyToken(parsed.key) || - [ - 'Backspace', - 'Delete', - 'Enter', - 'Escape', - 'Tab', - 'ArrowLeft', - 'ArrowRight', - 'ArrowUp', - 'ArrowDown', - 'PageUp', - 'PageDown' - ].includes(parsed.key) - ) -} - -function normalizeKeybindingWithOptions( - binding: string, - options: NormalizeKeybindingOptions = {} -): KeybindingValidationResult { - const parsed = parseKeybinding(binding) - if (!parsed) { - return { ok: false, error: 'Use a shortcut like Ctrl+Shift+P or Cmd+K.' } - } - if (parsed.mod && (parsed.meta || parsed.control)) { - return { ok: false, error: 'Use either Mod or a platform-specific modifier, not both.' } - } - if (parsed.doubleTapModifier) { - return { ok: true, value: canonicalizeParsedKeybinding(parsed) } - } - const isShiftInsert = parsed.shift && parsed.key === 'Insert' - const isBareAllowed = options.allowBareKeybindings === true && isSafeBareKey(parsed) - const isShiftOnlyAllowed = - options.allowShiftOnlyKeybindings === true && - parsed.shift && - !parsed.mod && - !parsed.meta && - !parsed.control && - !parsed.alt - if ( - !parsed.mod && - !parsed.meta && - !parsed.control && - !parsed.alt && - !isShiftInsert && - !isBareAllowed && - !isShiftOnlyAllowed - ) { - return { ok: false, error: 'Include at least one modifier key.' } - } - return { ok: true, value: canonicalizeParsedKeybinding(parsed) } -} - -export function normalizeKeybinding(binding: string): KeybindingValidationResult { - return normalizeKeybindingWithOptions(binding) -} - -export function isDoubleTapBinding(binding: string): boolean { - return Boolean(parseKeybinding(binding)?.doubleTapModifier) -} - -function normalizeKeybindingListWithOptions( - input: string, - options: NormalizeKeybindingOptions = {} -): KeybindingValidationResult | string[] { - const trimmed = input.trim() - if (!trimmed) { - return [] - } - const normalized: string[] = [] - for (const piece of trimmed.split(',')) { - const result = normalizeKeybindingWithOptions(piece, options) - if (!result.ok) { - return result - } - if (!normalized.includes(result.value)) { - normalized.push(result.value) - } - } - return normalized -} - -export function normalizeKeybindingList(input: string): KeybindingValidationResult | string[] { - return normalizeKeybindingListWithOptions(input) -} - -function normalizeKeybindingArrayWithOptions( - input: readonly string[], - options: NormalizeKeybindingOptions = {} -): KeybindingValidationResult | string[] { - const normalized: string[] = [] - for (const binding of input) { - const piece = normalizeKeybindingListWithOptions(binding, options) - if (!Array.isArray(piece)) { - return piece - } - for (const normalizedBinding of piece) { - if (!normalized.includes(normalizedBinding)) { - normalized.push(normalizedBinding) - } - } - } - return normalized -} - -function normalizeOptionsForAction(actionId: KeybindingActionId): NormalizeKeybindingOptions { - const definition = DEFINITIONS_BY_ID.get(actionId) - return { - allowBareKeybindings: definition?.allowBareKeybindings === true, - allowShiftOnlyKeybindings: definition?.allowShiftOnlyKeybindings === true - } -} - -// Why: rewrite a digit-index chord's key to 1 so display and conflict detection stay stable across the 1-9 range; reject any non 1-9 key. -function canonicalizeDigitIndexBinding(binding: string): KeybindingValidationResult { - const parsed = parseKeybinding(binding) - if (!parsed || parsed.doubleTapModifier || !DIGIT_INDEX_KEY_PATTERN.test(parsed.key)) { - return { - ok: false, - error: 'Pick a number key 1–9 with a modifier, like Cmd+1 or Ctrl+1.' - } - } - return { ok: true, value: canonicalizeParsedKeybinding({ ...parsed, key: '1' }) } -} - -function finalizeDigitIndexBindings( - actionId: KeybindingActionId, - result: KeybindingValidationResult | string[] -): KeybindingValidationResult | string[] { - if (!isDigitIndexActionId(actionId) || !Array.isArray(result)) { - return result - } - const canonical: string[] = [] - for (const binding of result) { - const normalized = canonicalizeDigitIndexBinding(binding) - if (!normalized.ok) { - return normalized - } - if (!canonical.includes(normalized.value)) { - canonical.push(normalized.value) - } - } - return canonical -} - -export function normalizeKeybindingListForAction( - actionId: KeybindingActionId, - input: string -): KeybindingValidationResult | string[] { - return finalizeDigitIndexBindings( - actionId, - normalizeKeybindingListWithOptions(input, normalizeOptionsForAction(actionId)) - ) -} - -export function normalizeKeybindingArrayForAction( - actionId: KeybindingActionId, - input: readonly string[] -): KeybindingValidationResult | string[] { - return finalizeDigitIndexBindings( - actionId, - normalizeKeybindingArrayWithOptions(input, normalizeOptionsForAction(actionId)) - ) -} - -const MODIFIER_KEYS = new Set([ - 'Alt', - 'AltGraph', - 'Control', - 'Meta', - 'Shift', - 'OS', - 'Fn', - 'FnLock', - 'Hyper', - 'Super', - 'Symbol', - 'SymbolLock' -]) - -const PUNCTUATION_KEY_TOKENS = new Set([ - 'BracketLeft', - 'BracketRight', - 'Minus', - 'Underscore', - 'Equal', - 'Plus', - 'Comma', - 'Period', - 'Slash', - 'Backslash', - 'Semicolon', - 'Quote', - 'Backquote' -]) - -const PHYSICAL_CODE_FALLBACK_KEYS = new Set(['', 'Dead', 'Unidentified']) - -const SHIFTED_PUNCTUATION_KEY_TOKENS: Record = { - '<': 'Comma', - '>': 'Period', - '?': 'Slash', - '|': 'Backslash', - ':': 'Semicolon', - '"': 'Quote', - '~': 'Backquote' -} - -function logicalKeyTokenFromInput(input: KeybindingInput): string | null { - const key = input.key ?? '' - if (MODIFIER_KEYS.has(key)) { - return null - } - const normalizedKey = normalizeKeyToken(key) - if (normalizedKey) { - return normalizedKey - } - if (hasModifier(input, 'shift')) { - return SHIFTED_PUNCTUATION_KEY_TOKENS[key] ?? null - } - return null -} - -function canUsePhysicalCodeFallback(input: KeybindingInput): boolean { - // Why: layout-aware shortcuts trust real logical keys; physical code is only a fallback when the platform can't report the produced key. - return PHYSICAL_CODE_FALLBACK_KEYS.has(input.key ?? '') -} - -function isLatinShortcutKey(key: string): boolean { - // Why: A-Z / 0-9 are the only chars a Latin shortcut names; a non-Latin char (Cyrillic с, Greek π) is never a Latin remap, so physical-code fallback is safe. - if (key.length !== 1) { - return false - } - const upper = key.toUpperCase() - return (upper >= 'A' && upper <= 'Z') || (key >= '0' && key <= '9') -} - -function shouldUseNonLatinShortcutPhysicalFallback( - input: KeybindingInput, - platform: NodeJS.Platform -): boolean { - // Why: non-Latin layouts report non-Latin logical keys for physical letters (#6274), breaking Ctrl/Meta shortcuts; fall back to the physical code. - if (getKeybindingPlatform(platform) === 'darwin') { - return false - } - const hasPrimaryModifier = hasModifier(input, 'control') || hasModifier(input, 'meta') - if (!hasPrimaryModifier) { - return false - } - // AltGr surfaces as Ctrl+Alt on Windows/Linux; treat it as text, not a chord. - if (hasModifier(input, 'control') && hasModifier(input, 'alt')) { - return false - } - if (logicalKeyTokenFromInput(input) !== null) { - return false - } - const key = input.key ?? '' - return key !== '' && !MODIFIER_KEYS.has(key) && !isLatinShortcutKey(key) -} - -function canFallBackToPhysicalCode(input: KeybindingInput, platform: NodeJS.Platform): boolean { - return ( - canUsePhysicalCodeFallback(input) || shouldUseNonLatinShortcutPhysicalFallback(input, platform) - ) -} - -function physicalCodeKeyTokenFromInput(input: KeybindingInput): string | null { - const code = input.code ?? '' - if (code.startsWith('Key') && code.length === 4) { - return code.slice(3).toUpperCase() - } - if (code.startsWith('Digit') && code.length === 6) { - return code.slice(5) - } - - return normalizeKeyToken(code) -} - -function numpadCodeKeyTokenFromInput(input: KeybindingInput): string | null { - const code = input.code ?? '' - return code === 'NumpadAdd' || code === 'NumpadSubtract' ? normalizeKeyToken(code) : null -} - -function shouldUseMacOptionComposedCaptureFallback( - input: KeybindingInput, - platform: NodeJS.Platform -): boolean { - // Why: macOS Option+key reports composed characters (Option+C -> ç), so capturing Alt shortcuts needs the physical-code fallback. - if ( - getKeybindingPlatform(platform) !== 'darwin' || - !hasModifier(input, 'alt') || - MODIFIER_KEYS.has(input.key ?? '') - ) { - return false - } - const physicalToken = physicalCodeKeyTokenFromInput(input) - if (!physicalToken) { - return false - } - return ( - (physicalToken.length === 1 && physicalToken >= 'A' && physicalToken <= 'Z') || - isPunctuationKeyToken(physicalToken) - ) -} - -function keyTokenFromInput(input: KeybindingInput, platform: NodeJS.Platform): string | null { - const numpadKey = numpadCodeKeyTokenFromInput(input) - if (numpadKey) { - return numpadKey - } - const logicalKey = logicalKeyTokenFromInput(input) - if (logicalKey) { - return logicalKey - } - if ( - !canUsePhysicalCodeFallback(input) && - !shouldUseMacOptionComposedCaptureFallback(input, platform) && - !shouldUseNonLatinShortcutPhysicalFallback(input, platform) - ) { - return null - } - return physicalCodeKeyTokenFromInput(input) -} - -// Why: the platform primary modifier canonicalizes to Mod (Cmd on macOS / Ctrl elsewhere), mirroring normal capture. -function canonicalDoubleTapToken( - modifier: PhysicalModifierToken, - platform: NodeJS.Platform -): ModifierToken { - const isMac = platform === 'darwin' - if (modifier === 'Cmd' && isMac) { - return 'Mod' - } - if (modifier === 'Ctrl' && !isMac) { - return 'Mod' - } - return modifier -} - -function keybindingFromInputWithOptions( - input: KeybindingInput, - platform: NodeJS.Platform, - options: NormalizeKeybindingOptions = {} -): KeybindingValidationResult { - if (input.doubleTapModifier) { - return normalizeKeybindingWithOptions( - `DoubleTap+${canonicalDoubleTapToken(input.doubleTapModifier, platform)}`, - options - ) - } - const key = keyTokenFromInput(input, platform) - if (!key) { - return { ok: false, error: 'Press a key, not only a modifier.' } - } - - const isMac = getKeybindingPlatform(platform) === 'darwin' - const parts: string[] = [] - const primaryModifierPressed = isMac ? hasModifier(input, 'meta') : hasModifier(input, 'control') - if (primaryModifierPressed) { - parts.push('Mod') - } - if (isMac && hasModifier(input, 'control')) { - parts.push('Ctrl') - } - if (!isMac && hasModifier(input, 'meta')) { - parts.push('Cmd') - } - if (hasModifier(input, 'alt')) { - parts.push('Alt') - } - if (hasModifier(input, 'shift')) { - parts.push('Shift') - } - parts.push(key) - - return normalizeKeybindingWithOptions(parts.join('+'), options) -} - -export function keybindingFromInput( - input: KeybindingInput, - platform: NodeJS.Platform -): KeybindingValidationResult { - return keybindingFromInputWithOptions(input, platform) -} - -export function keybindingFromInputForAction( - actionId: KeybindingActionId, - input: KeybindingInput, - platform: NodeJS.Platform -): KeybindingValidationResult { - const result = keybindingFromInputWithOptions( - input, - platform, - normalizeOptionsForAction(actionId) - ) - if (!result.ok || !isDigitIndexActionId(actionId)) { - return result - } - return canonicalizeDigitIndexBinding(result.value) -} - -function getDefaultBindings(definition: KeybindingDefinition, platform: NodeJS.Platform): string[] { - return definition.defaultBindings[getKeybindingPlatform(platform)].map((binding) => { - const normalized = normalizeKeybindingWithOptions(binding, { - allowBareKeybindings: definition.allowBareKeybindings === true, - allowShiftOnlyKeybindings: definition.allowShiftOnlyKeybindings === true - }) - return normalized.ok ? normalized.value : binding - }) -} - -export function getEffectiveKeybindingsForAction( - actionId: KeybindingActionId, - platform: NodeJS.Platform, - overrides?: KeybindingOverrides -): string[] { - const definition = DEFINITIONS_BY_ID.get(actionId) - const override = overrides?.[actionId] - if (Array.isArray(override)) { - // Why: canonicalize digit-index overrides to +1 so display/conflict stay consistent even if a hand-edited file stored a different digit. - if (isDigitIndexActionId(actionId)) { - const canonical: string[] = [] - for (const binding of override) { - const normalized = canonicalizeDigitIndexBinding(binding) - if (normalized.ok && !canonical.includes(normalized.value)) { - canonical.push(normalized.value) - } - } - return canonical - } - return override.flatMap((binding) => { - const normalized = normalizeKeybindingWithOptions( - binding, - normalizeOptionsForAction(actionId) - ) - return normalized.ok ? [normalized.value] : [] - }) - } - return definition ? getDefaultBindings(definition, platform) : [] -} - -export function getEffectiveKeybindingsForDefinition( - definition: KeybindingDefinition, - platform: NodeJS.Platform, - overrides?: KeybindingOverrides -): string[] { - const override = overrides?.[definition.id] - if (Array.isArray(override)) { - return getEffectiveKeybindingsForAction(definition.id, platform, overrides) - } - return getDefaultBindings(definition, platform) -} - -export function getKeybindingDefinition(actionId: KeybindingActionId): KeybindingDefinition | null { - return DEFINITIONS_BY_ID.get(actionId) ?? null -} - -export function normalizeTerminalShortcutPolicy( - policy: TerminalShortcutPolicy | null | undefined -): TerminalShortcutPolicy { - return policy === 'terminal-first' ? 'terminal-first' : 'orca-first' -} - -export function isKeybindingAllowedInTerminal(definition: KeybindingDefinition): boolean { - return definition.scope === 'terminal' || definition.allowInTerminal === true -} - -export function isKeybindingPotentialTerminalConflict(definition: KeybindingDefinition): boolean { - return definition.scope !== 'terminal' && definition.allowInTerminal !== true -} - -export function keybindingIsActiveInContext( - definition: KeybindingDefinition, - options: KeybindingMatchOptions = {} -): boolean { - if (options.context !== 'terminal') { - return true - } - // Why: Orca-first keeps app shortcuts inside terminals; terminal-first is the escape hatch for shells and TUIs. - if (normalizeTerminalShortcutPolicy(options.terminalShortcutPolicy) === 'orca-first') { - return true - } - return isKeybindingAllowedInTerminal(definition) -} - -function platformModifiers( - parsed: ParsedKeybinding, - platform: NodeJS.Platform -): { meta: boolean; control: boolean; alt: boolean; shift: boolean } { - const isMac = platform === 'darwin' - return { - meta: parsed.meta || (parsed.mod && isMac), - control: parsed.control || (parsed.mod && !isMac), - alt: parsed.alt, - shift: parsed.shift - } -} - -function modifierStateMatches( - parsed: ParsedKeybinding, - input: KeybindingInput, - platform: NodeJS.Platform -): boolean { - const expected = platformModifiers(parsed, platform) - return ( - hasModifier(input, 'meta') === expected.meta && - hasModifier(input, 'control') === expected.control && - hasModifier(input, 'alt') === expected.alt && - hasModifier(input, 'shift') === expected.shift - ) -} - -function shouldUseMacOptionLetterPhysicalFallback( - parsed: ParsedKeybinding, - input: KeybindingInput, - platform: NodeJS.Platform -): boolean { - // Why: macOS Option+letter reports composed characters (Option+A -> å), leaving no logical Latin key for Alt shortcuts. - return ( - getKeybindingPlatform(platform) === 'darwin' && - parsed.alt && - hasModifier(input, 'alt') && - logicalKeyTokenFromInput(input) === null - ) -} - -function shouldUseMacOptionPunctuationPhysicalFallback( - parsed: ParsedKeybinding, - input: KeybindingInput, - platform: NodeJS.Platform -): boolean { - // Why: macOS Option+punctuation reports composed dead-key values, leaving no logical bracket token for Alt shortcuts. - return ( - getKeybindingPlatform(platform) === 'darwin' && - parsed.alt && - hasModifier(input, 'alt') && - logicalKeyTokenFromInput(input) === null - ) -} - -function letterKeyMatches( - input: KeybindingInput, - letter: string, - parsed: ParsedKeybinding, - platform: NodeJS.Platform -): boolean { - const logicalKey = logicalKeyTokenFromInput(input) - if (logicalKey && logicalKey.length === 1 && logicalKey >= 'A' && logicalKey <= 'Z') { - return logicalKey === letter.toUpperCase() - } - return ( - (canFallBackToPhysicalCode(input, platform) || - shouldUseMacOptionLetterPhysicalFallback(parsed, input, platform)) && - input.code === `Key${letter.toUpperCase()}` - ) -} - -function digitKeyMatches( - input: KeybindingInput, - digit: string, - platform: NodeJS.Platform -): boolean { - const logicalKey = logicalKeyTokenFromInput(input) - if (logicalKey && logicalKey.length === 1 && logicalKey >= '0' && logicalKey <= '9') { - return logicalKey === digit - } - return canFallBackToPhysicalCode(input, platform) && input.code === `Digit${digit}` -} - -function isPunctuationKeyToken(token: string | null): token is string { - return token !== null && PUNCTUATION_KEY_TOKENS.has(token) -} - -function semanticPunctuationKey(input: KeybindingInput): string | null { - const logicalKey = logicalKeyTokenFromInput(input) - return isPunctuationKeyToken(logicalKey) ? logicalKey : null -} - -function physicalPunctuationKey(input: KeybindingInput): string | null { - const physicalKey = physicalCodeKeyTokenFromInput(input) - return isPunctuationKeyToken(physicalKey) ? physicalKey : null -} - -function shouldUseSemanticPunctuation( - parsed: ParsedKeybinding, - input: KeybindingInput, - platform: NodeJS.Platform -): boolean { - // Why: Windows/Linux expose AltGr as Ctrl+Alt; don't turn international text input into Mod+Alt app shortcuts. - if ( - getKeybindingPlatform(platform) !== 'darwin' && - parsed.mod && - parsed.alt && - hasModifier(input, 'control') && - hasModifier(input, 'alt') && - !hasModifier(input, 'meta') && - physicalPunctuationKey(input) === null - ) { - return false - } - return true -} - -function keyMatches( - parsedKey: string, - input: KeybindingInput, - parsed: ParsedKeybinding, - platform: NodeJS.Platform -): boolean { - if (parsedKey.length === 1 && parsedKey >= 'A' && parsedKey <= 'Z') { - return letterKeyMatches(input, parsedKey, parsed, platform) - } - if (parsedKey.length === 1 && parsedKey >= '0' && parsedKey <= '9') { - return digitKeyMatches(input, parsedKey, platform) - } - - if (parsedKey === 'NumpadAdd' || parsedKey === 'NumpadSubtract') { - return ( - numpadCodeKeyTokenFromInput(input) === parsedKey || - logicalKeyTokenFromInput(input) === parsedKey - ) - } - - if (isPunctuationKeyToken(parsedKey)) { - // Why: shortcut labels name logical punctuation, but international layouts can report it from different physical codes. - const semanticKey = semanticPunctuationKey(input) - if (semanticKey !== null) { - if (!shouldUseSemanticPunctuation(parsed, input, platform)) { - return false - } - return semanticKey === parsedKey - } - return ( - (canFallBackToPhysicalCode(input, platform) || - shouldUseMacOptionPunctuationPhysicalFallback(parsed, input, platform)) && - physicalPunctuationKey(input) === parsedKey - ) - } - - const logicalKey = logicalKeyTokenFromInput(input) - if (logicalKey !== null) { - return logicalKey === parsedKey - } - return ( - canFallBackToPhysicalCode(input, platform) && physicalCodeKeyTokenFromInput(input) === parsedKey - ) -} - -function resolveModifierToken( - modifier: ModifierToken, - platform: NodeJS.Platform -): 'meta' | 'control' | 'alt' | 'shift' { - switch (modifier) { - case 'Mod': - return platform === 'darwin' ? 'meta' : 'control' - case 'Cmd': - return 'meta' - case 'Ctrl': - return 'control' - case 'Alt': - return 'alt' - case 'Shift': - return 'shift' - } -} - -export function keybindingMatchesInput( - binding: string, - input: KeybindingInput, - platform: NodeJS.Platform -): boolean { - const parsed = parseKeybinding(binding) - if (!parsed) { - return false - } - // A double-tap binding matches only a synthetic double-tap input (and vice-versa), resolved per platform. - if (parsed.doubleTapModifier) { - return ( - input.doubleTapModifier !== undefined && - resolveModifierToken(parsed.doubleTapModifier, platform) === - resolveModifierToken(input.doubleTapModifier, platform) - ) - } - if (input.doubleTapModifier !== undefined) { - return false - } - return ( - modifierStateMatches(parsed, input, platform) && keyMatches(parsed.key, input, parsed, platform) - ) -} - -function keybindingConflictIdentityForParsed( - parsed: ParsedKeybinding, - platform: NodeJS.Platform -): string { - if (parsed.doubleTapModifier) { - return `DoubleTap:${resolveModifierToken(parsed.doubleTapModifier, platform)}` - } - const modifiers = platformModifiers(parsed, platform) - return [ - modifiers.meta ? 'Meta' : '', - modifiers.control ? 'Control' : '', - modifiers.alt ? 'Alt' : '', - modifiers.shift ? 'Shift' : '', - parsed.key - ].join('+') -} - -export function getKeybindingConflictIdentity(binding: string, platform: NodeJS.Platform): string { - const parsed = parseKeybinding(binding) - return parsed ? keybindingConflictIdentityForParsed(parsed, platform) : binding -} - -function keybindingConflictIdentities( - actionId: KeybindingActionId, - binding: string, - platform: NodeJS.Platform -): readonly string[] { - const exact = getKeybindingConflictIdentity(binding, platform) - if (!isDigitIndexActionId(actionId)) { - return [exact] - } - const parsed = parseKeybinding(binding) - if (!parsed || parsed.doubleTapModifier || !DIGIT_INDEX_KEY_PATTERN.test(parsed.key)) { - return [exact] - } - return Array.from({ length: 9 }, (_, index) => - keybindingConflictIdentityForParsed({ ...parsed, key: String(index + 1) }, platform) - ) -} - -export function keybindingMatchesAction( - actionId: KeybindingActionId, - input: KeybindingInput, - platform: NodeJS.Platform, - overrides?: KeybindingOverrides, - options: KeybindingMatchOptions = {} -): boolean { - const definition = DEFINITIONS_BY_ID.get(actionId) - if (!definition) { - return false - } - if (!keybindingIsActiveInContext(definition, options)) { - return false - } - return getEffectiveKeybindingsForAction(actionId, platform, overrides).some((binding) => - keybindingMatchesInput(binding, input, platform) - ) -} - -function digitFromInput(input: KeybindingInput, platform: NodeJS.Platform): string | null { - for (let value = 1; value <= 9; value++) { - const digit = String(value) - if (digitKeyMatches(input, digit, platform)) { - return digit - } - } - return null -} - -// Why: a digit-index row's representative chord fires for any 1-9 — reuse its modifiers with the pressed digit via the normal matcher. -export function matchKeybindingDigitIndex( - actionId: KeybindingActionId, - input: KeybindingInput, - platform: NodeJS.Platform, - overrides?: KeybindingOverrides, - options: KeybindingMatchOptions = {} -): number | null { - const definition = DEFINITIONS_BY_ID.get(actionId) - if (!definition || !keybindingIsActiveInContext(definition, options)) { - return null - } - const digit = digitFromInput(input, platform) - if (!digit) { - return null - } - for (const binding of getEffectiveKeybindingsForAction(actionId, platform, overrides)) { - const parsed = parseKeybinding(binding) - if (!parsed || parsed.doubleTapModifier || !DIGIT_INDEX_KEY_PATTERN.test(parsed.key)) { - continue - } - const candidate = canonicalizeParsedKeybinding({ ...parsed, key: digit }) - if (keybindingMatchesInput(candidate, input, platform)) { - return Number(digit) - 1 - } - } - return null -} - -function formatModifierGlyph(modifier: ModifierToken, isMac: boolean): string { - switch (modifier) { - case 'Mod': - return isMac ? '⌘' : 'Ctrl' - case 'Cmd': - return isMac ? '⌘' : 'Cmd' - case 'Ctrl': - return isMac ? '⌃' : 'Ctrl' - case 'Alt': - return isMac ? '⌥' : 'Alt' - case 'Shift': - return isMac ? '⇧' : 'Shift' - } -} - -export function formatKeybinding(binding: string, platform: NodeJS.Platform): string[] { - const parsed = parseKeybinding(binding) - if (!parsed) { - return [binding] - } - const isMac = platform === 'darwin' - if (parsed.doubleTapModifier) { - const glyph = formatModifierGlyph(parsed.doubleTapModifier, isMac) - return [glyph, glyph] - } - const parts: string[] = [] - if (parsed.mod) { - parts.push(isMac ? '⌘' : 'Ctrl') - } - if (parsed.meta) { - parts.push(isMac ? '⌘' : 'Cmd') - } - if (parsed.control) { - parts.push(isMac ? '⌃' : 'Ctrl') - } - if (parsed.alt) { - parts.push(isMac ? '⌥' : 'Alt') - } - if (parsed.shift) { - parts.push(isMac ? '⇧' : 'Shift') - } - parts.push(formatKeyToken(parsed.key, isMac)) - return parts -} - -export function formatKeybindingList( - bindings: readonly string[], - platform: NodeJS.Platform -): string { - if (bindings.length === 0) { - return 'Unassigned' - } - return bindings - .map((binding) => { - const separator = isDoubleTapBinding(binding) ? ' ' : platform === 'darwin' ? '' : '+' - return formatKeybinding(binding, platform).join(separator) - }) - .join(', ') -} - -export function findKeybindingActionsForBinding( - binding: string, - platform: NodeJS.Platform, - overrides?: KeybindingOverrides, - scopes: readonly KeybindingScope[] = ['global', 'tabs'] -): KeybindingActionId[] { - const identity = getKeybindingConflictIdentity(binding, platform) - const allowedScopes = new Set(scopes) - return KEYBINDING_DEFINITIONS.filter( - (definition) => - allowedScopes.has(definition.scope) && - getEffectiveKeybindingsForAction(definition.id, platform, overrides).some((candidate) => - keybindingConflictIdentities(definition.id, candidate, platform).includes(identity) - ) - ).map((definition) => definition.id) -} - -function formatKeyToken(token: string, isMac: boolean): string { - const labels: Record = { - BracketLeft: '[', - BracketRight: ']', - Minus: '-', - Underscore: '_', - Equal: '=', - Plus: '+', - ArrowLeft: '←', - ArrowRight: '→', - ArrowUp: '↑', - ArrowDown: '↓', - PageUp: 'PageUp', - PageDown: 'PageDown', - NumpadAdd: 'Numpad +', - NumpadSubtract: 'Numpad -', - Comma: ',', - Period: '.', - Slash: '/', - Backslash: '\\', - Semicolon: ';', - Quote: "'", - Backquote: '`', - Enter: 'Enter', - Backspace: isMac ? '⌫' : 'Backspace', - Delete: 'Delete', - Insert: 'Insert', - Tab: 'Tab', - Escape: 'Esc', - Space: 'Space' - } - return labels[token] ?? token -} - -export function findKeybindingConflicts( - platform: NodeJS.Platform, - overrides?: KeybindingOverrides, - options: FindKeybindingConflictOptions = {} -): KeybindingConflict[] { - return findKeybindingConflictsForDefinitions(KEYBINDING_DEFINITIONS, platform, overrides, options) -} - -export function findKeybindingConflictsForDefinitions( - definitions: readonly KeybindingDefinition[], - platform: NodeJS.Platform, - overrides?: KeybindingOverrides, - options: FindKeybindingConflictOptions = {} -): KeybindingConflict[] { - const owners = new Map }>() - const ignoredActionIds = new Set(options.ignoredActionIds ?? []) - const customizedActions = new Set( - Object.keys(overrides ?? {}).filter( - (actionId): actionId is KeybindingActionId => - isKeybindingActionId(actionId) && !ignoredActionIds.has(actionId) - ) - ) - for (const actionId of options.relevantActionIds ?? []) { - if (!ignoredActionIds.has(actionId)) { - customizedActions.add(actionId) - } - } - for (const definition of definitions) { - if (ignoredActionIds.has(definition.id)) { - continue - } - for (const binding of getEffectiveKeybindingsForDefinition(definition, platform, overrides)) { - const groups = new Set([definition.conflictGroup ?? definition.scope]) - if (definition.conflictGroup) { - // Why: native menu accelerators can consume global chords, so check custom bindings against both the menu bucket and scope. - groups.add(definition.scope) - } - for (const group of groups) { - for (const identity of keybindingConflictIdentities(definition.id, binding, platform)) { - const conflictKey = `${group}\u0000${identity}` - const current = owners.get(conflictKey) ?? { binding, actionIds: new Set() } - if ( - !isDigitIndexActionId(definition.id) && - Array.from(current.actionIds).some((actionId) => isDigitIndexActionId(actionId)) - ) { - current.binding = binding - } - current.actionIds.add(definition.id) - owners.set(conflictKey, current) - } - } - } - } - - const seenConflictKeys = new Set() - return Array.from(owners.values()) - .filter(({ actionIds }) => actionIds.size > 1 && setIntersects(actionIds, customizedActions)) - .map(({ binding, actionIds }) => ({ - binding, - actionIds: Array.from(actionIds) - })) - .filter((conflict) => { - const key = `${conflict.binding}\u0000${conflict.actionIds.join('\u0000')}` - if (seenConflictKeys.has(key)) { - return false - } - seenConflictKeys.add(key) - return true - }) -} - -function setIntersects(left: ReadonlySet, right: ReadonlySet): boolean { - for (const value of left) { - if (right.has(value)) { - return true - } - } - return false -} +// Public compatibility surface for the keybinding domain. +// Implementations live beside this facade so consumers can keep the stable import path. +export * from './keybindings/types' +export * from './keybindings/definitions' +export * from './keybindings/parser' +export * from './keybindings/normalization' +export * from './keybindings/input' +export * from './keybindings/effective' +export * from './keybindings/matching-key' +export * from './keybindings/matching' +export * from './keybindings/formatting' diff --git a/src/shared/keybindings/definitions-core-1.ts b/src/shared/keybindings/definitions-core-1.ts new file mode 100644 index 00000000000..b757a4127e4 --- /dev/null +++ b/src/shared/keybindings/definitions-core-1.ts @@ -0,0 +1,298 @@ +import type { KeybindingDefinition } from './types' +import { platformBindings } from './definitions-support' + +export const KEYBINDING_DEFINITION_CORE_1: readonly KeybindingDefinition[] = [ + { + id: 'worktree.quickOpen', + title: 'Go to File', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'global', 'file', 'quick open'], + defaultBindings: platformBindings(['Mod+P']) + }, + { + id: 'app.settings', + title: 'Open Settings', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'settings', 'preferences'], + defaultBindings: platformBindings(['Mod+Comma']), + conflictGroup: 'menu' + }, + { + id: 'app.forceReload', + title: 'Force Reload', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'reload', 'refresh', 'force'], + defaultBindings: platformBindings(['Mod+Shift+R']), + conflictGroup: 'menu' + }, + { + id: 'worktree.palette', + title: 'Switch worktree', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'global', 'worktree', 'switch', 'jump'], + defaultBindings: { + darwin: ['Mod+J'], + linux: ['Mod+Shift+J'], + win32: ['Mod+Shift+J'] + } + }, + { + id: 'worktree.navigateUp', + title: 'Previous worktree', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'global', 'worktree', 'previous', 'up'], + defaultBindings: platformBindings(['Mod+Shift+ArrowUp']) + }, + { + id: 'worktree.navigateDown', + title: 'Next worktree', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'global', 'worktree', 'next', 'down'], + defaultBindings: platformBindings(['Mod+Shift+ArrowDown']) + }, + { + id: 'workspace.create', + title: 'Create worktree', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'global', 'worktree', 'create', 'new workspace'], + defaultBindings: platformBindings(['Mod+N', 'Mod+Shift+N']) + }, + { + id: 'workspace.rename', + title: 'Rename worktree', + group: 'Global', + scope: 'global', + conflictGroup: 'workspace-shell', + searchKeywords: ['shortcut', 'global', 'worktree', 'rename', 'workspace', 'title'], + // Why: macOS only — Windows/Linux Ctrl+Alt+R has no safe default (Ctrl+R reverse-search, Ctrl+Shift+R reload are taken). + defaultBindings: { + darwin: ['Mod+Alt+R'], + linux: [], + win32: [] + } + }, + { + id: 'workspace.delete', + title: 'Delete Workspace', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'global', + 'workspace', + 'current workspace', + 'worktree', + 'delete', + 'remove', + 'trash' + ], + // Why: Backspace avoids the terminal pane's D-based split shortcuts on every platform. + defaultBindings: platformBindings(['Mod+Shift+Backspace']), + allowInTerminal: true + }, + { + id: 'workspace.openBoard', + title: 'Toggle Workspace Board', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'global', + 'workspace', + 'board', + 'kanban', + 'worktree', + 'toggle', + 'open', + 'close' + ], + // Why: configurable but unbound by default, to not take a global chord from terminal/browser/editor users. + defaultBindings: platformBindings([]), + allowInTerminal: true + }, + { + id: 'dashboard.toggle', + title: 'Toggle Agent Dashboard', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'global', + 'agent', + 'agents', + 'dashboard', + 'kanban', + 'board', + 'toggle', + 'open', + 'close' + ], + // Why: configurable but unbound by default, matching workspace.openBoard — an + // experimental surface must not claim a global chord from terminal users. + defaultBindings: platformBindings([]), + allowInTerminal: true + }, + { + id: 'workspace.selectByIndex', + title: 'Select Workspace 1–9', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'global', + 'workspace', + 'worktree', + 'select', + 'switch', + 'number', + 'digit', + '1-9', + 'index' + ], + // Why: one remappable row covers the whole 1-9 range (stored chord is a representative; any of 1-9 fires it). + defaultBindings: platformBindings(['Mod+1']) + }, + { + id: 'voice.dictation', + title: 'Dictation', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'dictation', 'voice', 'speech', 'microphone'], + defaultBindings: platformBindings(['Mod+E']) + }, + { + id: 'view.tasks', + title: 'Open Tasks', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'tasks', 'github issues', 'linear'], + defaultBindings: platformBindings([]) + }, + { + id: 'sidebar.left.toggle', + title: 'Toggle Sidebar', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'sidebar', 'left'], + defaultBindings: platformBindings(['Mod+B']) + }, + { + id: 'sidebar.right.toggle', + title: 'Toggle Right Sidebar', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'sidebar', 'right'], + defaultBindings: platformBindings(['Mod+L']) + }, + { + id: 'sidebar.explorer.toggle', + title: 'Show Explorer', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'sidebar', 'explorer', 'files'], + defaultBindings: platformBindings(['Mod+Shift+E']) + }, + { + id: 'sidebar.search.toggle', + title: 'Show Search', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'sidebar', 'search'], + defaultBindings: platformBindings(['Mod+Shift+F']) + }, + { + id: 'sidebar.sourceControl.toggle', + title: 'Show Source Control', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'sidebar', 'source control', 'git'], + defaultBindings: platformBindings(['Mod+Shift+G']) + }, + { + id: 'sidebar.checks.toggle', + title: 'Show Checks', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'sidebar', 'checks', 'ci'], + defaultBindings: platformBindings([]) + }, + { + id: 'sidebar.ports.toggle', + title: 'Show Ports', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'sidebar', 'ports'], + defaultBindings: { + darwin: ['Mod+Shift+I'], + linux: [], + win32: [] + } + }, + { + id: 'sidebar.sleepingWorkspaces.toggle', + title: 'Toggle Sleeping Workspaces', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'sidebar', + 'sleeping', + 'asleep', + 'workspaces', + 'worktree', + 'filter', + 'show', + 'hide' + ], + // Why: ship unbound (issue #5209 asks users to assign it), avoiding a claimed cross-platform chord. + defaultBindings: platformBindings([]) + }, + { + id: 'sidebar.focusWorktreeList', + title: 'Focus worktree list', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'sidebar', 'worktree', 'focus'], + // Why: keep zoom.reset on the browser-standard Mod+0; this chord was unreachable while it shared that default (#8584). + defaultBindings: platformBindings(['Mod+Shift+0']) + }, + { + id: 'floatingTerminal.toggle', + title: 'Toggle Floating Terminal', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'floating terminal', 'terminal'], + defaultBindings: platformBindings(['Mod+Alt+A']), + allowInTerminal: true + }, + { + id: 'floatingWorkspace.maximize', + title: 'Maximize Floating Workspace Panel', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'floating', + 'workspace', + 'panel', + 'floating workspace', + 'workspace panel', + 'maximize', + 'expand' + ], + // Why: pairs with floatingTerminal.toggle (Cmd+Opt+A) so maximize stays one-handed; macOS-only, Linux/Windows unbound. + defaultBindings: { + darwin: ['Mod+Alt+Shift+A'], + linux: [], + win32: [] + }, + allowInTerminal: true + } +] diff --git a/src/shared/keybindings/definitions-core-2.ts b/src/shared/keybindings/definitions-core-2.ts new file mode 100644 index 00000000000..d8fd6ad3227 --- /dev/null +++ b/src/shared/keybindings/definitions-core-2.ts @@ -0,0 +1,282 @@ +import type { KeybindingDefinition } from './types' +import { platformBindings } from './definitions-support' + +export const KEYBINDING_DEFINITION_CORE_2: readonly KeybindingDefinition[] = [ + { + id: 'floatingWorkspace.minimize', + title: 'Minimize Floating Workspace Panel', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'floating', + 'workspace', + 'panel', + 'floating workspace', + 'workspace panel', + 'minimize', + 'hide' + ], + // Why: unbound everywhere since floatingTerminal.toggle owns show/hide; this exists only for an explicit user-bound "hide panel" shortcut. + defaultBindings: { + darwin: [], + linux: [], + win32: [] + }, + allowInTerminal: true + }, + { + id: 'zoom.in', + title: 'Zoom In', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'zoom', 'in', 'scale'], + defaultBindings: platformBindings(['Mod+Equal', 'Mod+Shift+Plus', 'Mod+NumpadAdd']) + }, + { + id: 'zoom.out', + title: 'Zoom Out', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'zoom', 'out', 'scale'], + defaultBindings: platformBindings(['Mod+Minus', 'Mod+NumpadSubtract']) + }, + { + id: 'zoom.reset', + title: 'Reset Size', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'zoom', 'reset', 'size', 'actual'], + defaultBindings: platformBindings(['Mod+0']) + }, + { + id: 'worktree.history.back', + title: 'Worktree History Back', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'worktree', 'history', 'back'], + defaultBindings: platformBindings(['Mod+Alt+ArrowLeft']), + allowInTerminal: true + }, + { + id: 'worktree.history.forward', + title: 'Worktree History Forward', + group: 'Global', + scope: 'global', + searchKeywords: ['shortcut', 'worktree', 'history', 'forward'], + defaultBindings: platformBindings(['Mod+Alt+ArrowRight']), + allowInTerminal: true + }, + { + id: 'tab.newTerminal', + title: 'New terminal tab', + group: 'Tabs', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'terminal', 'new'], + defaultBindings: platformBindings(['Mod+T']) + }, + { + id: 'tab.newAgent', + title: 'New agent tab (default agent)', + group: 'Tabs', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'agent', 'new', 'default', 'launch'], + // Why: macOS only — Windows Ctrl+Alt is AltGr and Linux Ctrl+Alt+T is the desktop "open terminal", so no safe default there. + defaultBindings: { + darwin: ['Mod+Alt+T'], + linux: [], + win32: [] + } + }, + { + id: 'tab.newBrowser', + title: 'New browser tab', + group: 'Tabs', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'browser', 'new'], + defaultBindings: platformBindings(['Mod+Shift+B']) + }, + { + id: 'tab.newSimulator', + title: 'New mobile emulator tab', + group: 'Tabs', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'simulator', 'emulator', 'mobile', 'ios', 'new'], + // Why: keep explorer on Mod+Shift+E (VS Code muscle memory); emulator is macOS-only and less common, so it yields to a free chord (#8533). + defaultBindings: { + darwin: ['Mod+Alt+Shift+E'], + linux: [], + win32: [] + } + }, + { + id: 'tab.newMarkdown', + title: 'New markdown tab', + group: 'Tabs', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'markdown', 'file', 'new'], + defaultBindings: platformBindings(['Mod+Shift+M']) + }, + { + id: 'tab.openMarkdown', + title: 'Open markdown tab', + group: 'Tabs', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'markdown', 'file', 'open'], + defaultBindings: platformBindings(['Mod+Shift+O']) + }, + { + id: 'tab.close', + title: 'Close active tab', + group: 'Tabs', + scope: 'tabs', + searchKeywords: ['shortcut', 'close', 'tab', 'pane'], + defaultBindings: platformBindings(['Mod+W']) + }, + { + id: 'tab.closeAll', + title: 'Close all editor tabs', + group: 'Tabs', + scope: 'tabs', + searchKeywords: ['shortcut', 'close', 'all', 'tabs', 'files', 'editors'], + defaultBindings: platformBindings(['Mod+Alt+W']) + }, + { + id: 'tab.rename', + title: 'Rename active tab', + group: 'Tabs', + scope: 'tabs', + conflictGroup: 'workspace-shell', + searchKeywords: ['shortcut', 'tab', 'rename', 'title', 'label'], + // Why: macOS only — Cmd+R is free in app/terminal focus; on Windows/Linux Ctrl+R is shell reverse-search, so left unbound. + defaultBindings: { + darwin: ['Mod+R'], + linux: [], + win32: [] + } + }, + { + id: 'tab.reopenClosed', + title: 'Reopen closed tab', + group: 'Tabs', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'reopen', 'restore', 'closed'], + defaultBindings: platformBindings(['Mod+Shift+T']) + }, + { + id: 'tab.nextSameType', + title: 'Next tab (same type)', + group: 'Tab Navigation', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'next', 'switch', 'cycle'], + // Why: the widespread "switch tab" chord (Mod+Shift+Bracket) now drives all-types cycling; same-type moved to Mod+Alt for new installs. + defaultBindings: platformBindings(['Mod+Alt+BracketRight']) + }, + { + id: 'tab.previousSameType', + title: 'Previous tab (same type)', + group: 'Tab Navigation', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'previous', 'switch', 'cycle'], + defaultBindings: platformBindings(['Mod+Alt+BracketLeft']) + }, + { + id: 'tab.nextAllTypes', + title: 'Next tab (all types)', + group: 'Tab Navigation', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'next', 'switch', 'cycle', 'all', 'any'], + defaultBindings: platformBindings(['Mod+Shift+BracketRight']) + }, + { + id: 'tab.previousAllTypes', + title: 'Previous tab (all types)', + group: 'Tab Navigation', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'previous', 'switch', 'cycle', 'all', 'any'], + defaultBindings: platformBindings(['Mod+Shift+BracketLeft']) + }, + { + id: 'tab.previousRecent', + title: 'Previous recent tab', + group: 'Tab Navigation', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'recent', 'mru', 'switch', 'last used'], + defaultBindings: platformBindings(['Ctrl+Tab']), + allowInTerminal: true + }, + { + id: 'tab.nextTerminal', + title: 'Next terminal tab', + group: 'Tab Navigation', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'terminal', 'next', 'switch'], + defaultBindings: platformBindings(['Ctrl+PageDown']), + allowInTerminal: true + }, + { + id: 'tab.previousTerminal', + title: 'Previous terminal tab', + group: 'Tab Navigation', + scope: 'tabs', + searchKeywords: ['shortcut', 'tab', 'terminal', 'previous', 'switch'], + defaultBindings: platformBindings(['Ctrl+PageUp']), + allowInTerminal: true + }, + { + id: 'tab.selectByIndex', + title: 'Select Tab 1–9', + group: 'Tab Navigation', + scope: 'tabs', + // Why: no shared conflictGroup with workspace.selectByIndex so swapping their modifiers isn't a false conflict; safe because resolveWindowShortcutAction checks the workspace range first. + searchKeywords: ['shortcut', 'tab', 'select', 'switch', 'number', 'digit', '1-9', 'index'], + // Why: representative chord for the 1-9 range (see workspace.selectByIndex); each platform avoids the workspace-jump chord (Mod+1-9). + defaultBindings: { + darwin: ['Ctrl+1'], + linux: ['Alt+1'], + win32: ['Alt+1'] + } + }, + { + id: 'tab.openQuickCommandsMenu', + title: 'Toggle Quick Commands menu', + group: 'Quick Commands', + scope: 'tabs', + // Why: this tab-scoped action is also routed through the main-window allowlist, so Settings must warn when it shadows global chords. + conflictGroup: 'global', + searchKeywords: ['shortcut', 'quick', 'command', 'menu', 'tab', 'group', 'toggle'], + defaultBindings: platformBindings([]) + }, + { + id: 'browser.find', + title: 'Find in Browser', + group: 'Browser', + scope: 'browser', + searchKeywords: ['shortcut', 'browser', 'find', 'search'], + defaultBindings: platformBindings(['Mod+F']) + }, + { + id: 'browser.back', + title: 'Go Back in Browser', + group: 'Browser', + scope: 'browser', + searchKeywords: ['shortcut', 'browser', 'history', 'back', 'previous'], + defaultBindings: { + darwin: ['Mod+BracketLeft'], + linux: ['Alt+ArrowLeft'], + win32: ['Alt+ArrowLeft'] + } + }, + { + id: 'browser.forward', + title: 'Go Forward in Browser', + group: 'Browser', + scope: 'browser', + searchKeywords: ['shortcut', 'browser', 'history', 'forward', 'next'], + defaultBindings: { + darwin: ['Mod+BracketRight'], + linux: ['Alt+ArrowRight'], + win32: ['Alt+ArrowRight'] + } + } +] diff --git a/src/shared/keybindings/definitions-core-3.ts b/src/shared/keybindings/definitions-core-3.ts new file mode 100644 index 00000000000..c6439949622 --- /dev/null +++ b/src/shared/keybindings/definitions-core-3.ts @@ -0,0 +1,292 @@ +import type { KeybindingDefinition } from './types' +import { platformBindings } from './definitions-support' + +export const KEYBINDING_DEFINITION_CORE_3: readonly KeybindingDefinition[] = [ + { + id: 'browser.reload', + title: 'Reload Browser Page', + group: 'Browser', + scope: 'browser', + searchKeywords: ['shortcut', 'browser', 'reload', 'refresh'], + defaultBindings: platformBindings(['Mod+R']) + }, + { + id: 'browser.hardReload', + title: 'Hard Reload Browser Page', + group: 'Browser', + scope: 'browser', + searchKeywords: ['shortcut', 'browser', 'reload', 'refresh', 'cache'], + defaultBindings: platformBindings(['Mod+Shift+R']) + }, + { + id: 'browser.focusAddressBar', + title: 'Focus Browser Address Bar', + group: 'Browser', + scope: 'browser', + searchKeywords: ['shortcut', 'browser', 'address', 'url', 'location'], + defaultBindings: platformBindings(['Mod+L']) + }, + { + id: 'browser.grabElement', + title: 'Grab Page Element', + group: 'Browser', + scope: 'browser', + searchKeywords: ['shortcut', 'browser', 'grab', 'copy', 'element'], + defaultBindings: platformBindings(['Mod+C']) + }, + { + id: 'editor.find', + title: 'Find in editor', + group: 'Editors', + scope: 'editor', + searchKeywords: ['shortcut', 'editor', 'find', 'search'], + defaultBindings: platformBindings(['Mod+F']) + }, + { + id: 'editor.replace', + title: 'Replace in editor', + group: 'Editors', + scope: 'editor', + searchKeywords: ['shortcut', 'editor', 'replace', 'find', 'search'], + // Why: match the source editor's native replace shortcut per platform. + defaultBindings: { + darwin: ['Mod+Alt+F'], + linux: ['Mod+H'], + win32: ['Mod+H'] + } + }, + { + id: 'editor.save', + title: 'Save File', + group: 'Editors', + scope: 'editor', + searchKeywords: ['shortcut', 'editor', 'save'], + defaultBindings: platformBindings(['Mod+S']) + }, + { + id: 'editor.markdownPreview', + title: 'Show Markdown Preview', + group: 'Editors', + scope: 'editor', + searchKeywords: ['shortcut', 'editor', 'markdown', 'preview'], + defaultBindings: platformBindings(['Mod+Shift+V']) + }, + { + id: 'editor.toggleWordWrap', + title: 'Toggle Word Wrap', + group: 'Editors', + scope: 'editor', + searchKeywords: ['shortcut', 'editor', 'word wrap', 'wrap', 'long lines', 'soft wrap'], + // Why: Alt+Z matches VS Code; bare Alt+letter is not AltGr, so it stays cross-platform (#9974). + defaultBindings: platformBindings(['Alt+Z']) + }, + { + id: 'editor.copyContext', + title: 'Copy Context', + group: 'Editors', + scope: 'editor', + searchKeywords: ['shortcut', 'editor', 'copy', 'context'], + defaultBindings: platformBindings(['Mod+Alt+C']) + }, + // Why: F7 / Shift+F7 mirror VS Code / JetBrains diff-change nav; function keys are safe bare/Shift, so both opt into allowBareKeybindings. + { + id: 'editor.previousChange', + title: 'Go to Previous Change', + group: 'Editors', + scope: 'editor', + searchKeywords: ['shortcut', 'editor', 'diff', 'change', 'hunk', 'previous'], + defaultBindings: platformBindings(['Shift+F7']), + allowBareKeybindings: true + }, + { + id: 'editor.nextChange', + title: 'Go to Next Change', + group: 'Editors', + scope: 'editor', + searchKeywords: ['shortcut', 'editor', 'diff', 'change', 'hunk', 'next'], + defaultBindings: platformBindings(['F7']), + allowBareKeybindings: true + }, + { + id: 'editor.addReviewNote', + title: 'Add Review Note', + group: 'Editors', + scope: 'editor', + searchKeywords: ['shortcut', 'editor', 'markdown', 'note', 'comment', 'annotation', 'review'], + // Why: Ctrl+Alt+letter is AltGr text input on Windows/Linux, so an editor default must not reserve chars like Polish `ń`. + defaultBindings: platformBindings(['Mod+Shift+A']) + }, + { + id: 'sourceControl.sendReviewNotes', + title: 'Send Review Notes to Agent', + group: 'Global', + scope: 'global', + // Why: fires from the global capture handler even while the editor is focused, so Settings must warn on collisions with editor chords (e.g. Add Review Note) too, not just global ones. + conflictGroup: 'editor', + searchKeywords: [ + 'shortcut', + 'source control', + 'diff', + 'notes', + 'send', + 'agent', + 'review', + 'annotate' + ], + // Why: unbound by default so it never collides with existing chords; users opt in via Settings. + defaultBindings: platformBindings([]) + }, + { + id: 'fileExplorer.undo', + title: 'Undo file operation', + group: 'File Explorer', + scope: 'fileExplorer', + searchKeywords: ['shortcut', 'file explorer', 'undo'], + defaultBindings: platformBindings(['Mod+Z']) + }, + { + id: 'fileExplorer.redo', + title: 'Redo file operation', + group: 'File Explorer', + scope: 'fileExplorer', + searchKeywords: ['shortcut', 'file explorer', 'redo'], + defaultBindings: { + darwin: ['Mod+Shift+Z'], + linux: ['Mod+Shift+Z', 'Ctrl+Y'], + win32: ['Mod+Shift+Z', 'Ctrl+Y'] + } + }, + { + id: 'fileExplorer.copyPath', + title: 'Copy file path', + group: 'File Explorer', + scope: 'fileExplorer', + searchKeywords: ['shortcut', 'file explorer', 'copy', 'path'], + defaultBindings: { + darwin: ['Mod+Alt+C'], + linux: ['Alt+Shift+C'], + win32: ['Alt+Shift+C'] + } + }, + { + id: 'fileExplorer.copyRelativePath', + title: 'Copy relative file path', + group: 'File Explorer', + scope: 'fileExplorer', + searchKeywords: ['shortcut', 'file explorer', 'copy', 'relative', 'path'], + defaultBindings: platformBindings(['Mod+Alt+Shift+C']) + }, + { + id: 'fileExplorer.delete', + title: 'Delete file', + group: 'File Explorer', + scope: 'fileExplorer', + searchKeywords: ['shortcut', 'file explorer', 'delete', 'remove', 'trash'], + defaultBindings: { + darwin: ['Mod+Backspace', 'Delete'], + linux: ['Delete'], + win32: ['Delete'] + }, + allowBareKeybindings: true + }, + { + id: 'settings.search', + title: 'Search Settings', + group: 'Settings', + scope: 'settings', + searchKeywords: ['shortcut', 'settings', 'search', 'find'], + defaultBindings: platformBindings(['Mod+F']) + }, + { + id: 'terminal.copySelection', + title: 'Copy terminal selection', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'terminal', 'copy', 'selection'], + defaultBindings: { + darwin: ['Mod+C'], + linux: ['Ctrl+Shift+C', 'Ctrl+C'], + win32: ['Ctrl+Shift+C', 'Ctrl+C'] + } + }, + { + id: 'terminal.selectAll', + title: 'Select all terminal text', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'terminal', 'select', 'all'], + defaultBindings: { + darwin: ['Mod+A'], + linux: ['Ctrl+Shift+A'], + win32: ['Ctrl+Shift+A'] + } + }, + { + id: 'terminal.paste', + title: 'Paste into terminal', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'terminal', 'paste', 'clipboard'], + defaultBindings: { + darwin: ['Mod+V'], + linux: ['Ctrl+V', 'Ctrl+Shift+V', 'Shift+Insert'], + win32: ['Ctrl+V', 'Ctrl+Shift+V', 'Shift+Insert'] + } + }, + { + id: 'terminal.search', + title: 'Search active pane', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'terminal', 'search', 'find'], + defaultBindings: platformBindings(['Mod+F']) + }, + { + id: 'terminal.clear', + title: 'Clear active pane', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'pane', 'clear'], + defaultBindings: platformBindings(['Mod+K']) + }, + { + id: 'terminal.focusNextPane', + title: 'Focus next pane', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'pane', 'focus', 'next'], + defaultBindings: platformBindings(['Mod+BracketRight']) + }, + { + id: 'terminal.focusPreviousPane', + title: 'Focus previous pane', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'pane', 'focus', 'previous'], + defaultBindings: platformBindings(['Mod+BracketLeft']) + }, + { + id: 'terminal.equalizePaneSizes', + title: 'Equalize pane sizes', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'pane', 'split', 'equalize', 'resize', 'balance', 'size'], + defaultBindings: platformBindings([]) + }, + { + id: 'terminal.expandPane', + title: 'Expand / collapse pane', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'pane', 'expand', 'collapse'], + defaultBindings: platformBindings(['Mod+Shift+Enter']) + }, + { + id: 'terminal.setTitle', + title: 'Set Title…', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'terminal', 'pane', 'set title', 'title', 'rename'], + defaultBindings: platformBindings([]) + } +] diff --git a/src/shared/keybindings/definitions-core-4.ts b/src/shared/keybindings/definitions-core-4.ts new file mode 100644 index 00000000000..9ed02c02d86 --- /dev/null +++ b/src/shared/keybindings/definitions-core-4.ts @@ -0,0 +1,70 @@ +import type { KeybindingDefinition } from './types' +import { platformBindings } from './definitions-support' + +export const KEYBINDING_DEFINITION_CORE_4: readonly KeybindingDefinition[] = [ + { + id: 'terminal.clearPaneTitle', + title: 'Clear Pane Title', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'terminal', 'pane', 'clear title', 'remove title', 'title'], + defaultBindings: platformBindings([]) + }, + { + id: 'terminal.closePane', + title: 'Close active pane', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'pane', 'close'], + defaultBindings: platformBindings(['Mod+W']) + }, + { + id: 'terminal.splitRight', + title: 'Split terminal right', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'pane', 'split', 'right'], + defaultBindings: { + darwin: ['Mod+D'], + linux: ['Mod+Shift+D'], + win32: ['Mod+Shift+D'] + } + }, + { + id: 'terminal.splitDown', + title: 'Split terminal down', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: ['shortcut', 'pane', 'split', 'down'], + defaultBindings: { + darwin: ['Mod+Shift+D'], + linux: ['Alt+Shift+D'], + win32: ['Alt+Shift+D'] + } + }, + { + id: 'terminal.switchInputSource', + title: 'Switch input source / language (native)', + group: 'Terminal Panes', + scope: 'terminal', + searchKeywords: [ + 'shortcut', + 'input', + 'source', + 'language', + 'korean', + 'english', + 'ime', + 'switch', + 'hangul', + 'layout' + ], + defaultBindings: { + darwin: [], + linux: [], + win32: [] + }, + // Why: macOS uses Shift+Space as an input-source shortcut; Orca otherwise rejects Shift-only bindings to avoid stealing typed text. + allowShiftOnlyKeybindings: true + } +] diff --git a/src/shared/keybindings/definitions-support.ts b/src/shared/keybindings/definitions-support.ts new file mode 100644 index 00000000000..1ae8c683dce --- /dev/null +++ b/src/shared/keybindings/definitions-support.ts @@ -0,0 +1,9 @@ +import type { PlatformBindings } from './types' + +export function platformBindings(bindings: readonly string[]): PlatformBindings { + return { + darwin: bindings, + linux: bindings, + win32: bindings + } +} diff --git a/src/shared/keybindings/definitions.ts b/src/shared/keybindings/definitions.ts new file mode 100644 index 00000000000..842f77a6389 --- /dev/null +++ b/src/shared/keybindings/definitions.ts @@ -0,0 +1,92 @@ +import { ALL_TUI_AGENTS, TUI_AGENT_DISPLAY_NAMES } from '../tui-agent-display-names' +import type { + AgentTabActionId, + KeybindingActionId, + KeybindingDefinition, + PluginKeybindingActionId +} from './types' +import type { TuiAgent } from '../tui-agent' +import { KEYBINDING_DEFINITION_CORE_1 } from './definitions-core-1' +import { KEYBINDING_DEFINITION_CORE_2 } from './definitions-core-2' +import { KEYBINDING_DEFINITION_CORE_3 } from './definitions-core-3' +import { KEYBINDING_DEFINITION_CORE_4 } from './definitions-core-4' + +export function agentTabActionId(agent: TuiAgent): AgentTabActionId { + return `tab.newAgent.${agent}` +} + +function buildAgentTabKeybindingDefinitions(): KeybindingDefinition[] { + return ALL_TUI_AGENTS.map((agent) => ({ + id: agentTabActionId(agent), + title: `New ${TUI_AGENT_DISPLAY_NAMES[agent]} tab`, + group: 'Agents', + scope: 'tabs', + searchKeywords: [ + 'shortcut', + 'tab', + 'agent', + 'new', + 'launch', + agent, + TUI_AGENT_DISPLAY_NAMES[agent].toLowerCase() + ], + defaultBindings: { darwin: [], linux: [], win32: [] } + })) +} + +export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ + ...KEYBINDING_DEFINITION_CORE_1, + ...KEYBINDING_DEFINITION_CORE_2, + ...KEYBINDING_DEFINITION_CORE_3, + ...KEYBINDING_DEFINITION_CORE_4, + ...buildAgentTabKeybindingDefinitions() +] + +/** Pre-swap tab-switch bindings; a one-time migration pins these for pre-release installs so upgrading users keep the shortcuts they learned. */ +export const LEGACY_TAB_SWITCH_BINDINGS: Readonly>> = { + 'tab.nextSameType': ['Mod+Shift+BracketRight'], + 'tab.previousSameType': ['Mod+Shift+BracketLeft'], + 'tab.nextAllTypes': ['Mod+Alt+BracketRight'], + 'tab.previousAllTypes': ['Mod+Alt+BracketLeft'] +} + +export const DEFINITIONS_BY_ID = new Map( + KEYBINDING_DEFINITIONS.map((definition) => [definition.id, definition]) +) + +const DEFINITION_IDS = new Set( + KEYBINDING_DEFINITIONS.map((definition) => definition.id) +) + +// Why: these ids are single remappable rows whose chord is a representative — the digit canonicalizes to 1 but the binding fires for any 1-9. +export const DIGIT_INDEX_ACTION_IDS: readonly KeybindingActionId[] = [ + 'tab.selectByIndex', + 'workspace.selectByIndex' +] + +export const DIGIT_INDEX_KEY_PATTERN = /^[1-9]$/ + +export function isDigitIndexActionId(actionId: KeybindingActionId): boolean { + return DIGIT_INDEX_ACTION_IDS.includes(actionId) +} + +export function isKeybindingActionId(value: string): value is KeybindingActionId { + return DEFINITION_IDS.has(value as KeybindingActionId) || isPluginKeybindingActionId(value) +} + +export function isPluginKeybindingActionId(value: string): value is PluginKeybindingActionId { + return ( + value.length <= 400 && + /^plugin:[a-z0-9]+(?:-[a-z0-9]+)*\.[a-z0-9]+(?:-[a-z0-9]+)*\/[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*$/.test( + value + ) + ) +} + +export function getKeybindingDefinition(actionId: KeybindingActionId): KeybindingDefinition | null { + return DEFINITIONS_BY_ID.get(actionId) ?? null +} + +export function getKeybindingPlatform(platform: NodeJS.Platform): 'darwin' | 'linux' | 'win32' { + return platform === 'darwin' ? 'darwin' : platform === 'win32' ? 'win32' : 'linux' +} diff --git a/src/shared/keybindings/effective.ts b/src/shared/keybindings/effective.ts new file mode 100644 index 00000000000..24835d672b7 --- /dev/null +++ b/src/shared/keybindings/effective.ts @@ -0,0 +1,97 @@ +import type { + KeybindingActionId, + KeybindingDefinition, + KeybindingMatchOptions, + KeybindingOverrides, + TerminalShortcutPolicy +} from './types' +import { DEFINITIONS_BY_ID, getKeybindingPlatform } from './definitions' +import { + normalizeKeybindingWithOptions, + normalizeOptionsForAction, + canonicalizeDigitIndexBinding +} from './normalization' +import { isDigitIndexActionId } from './definitions' + +export function getDefaultBindings( + definition: KeybindingDefinition, + platform: NodeJS.Platform +): string[] { + return definition.defaultBindings[getKeybindingPlatform(platform)].map((binding) => { + const normalized = normalizeKeybindingWithOptions(binding, { + allowBareKeybindings: definition.allowBareKeybindings === true, + allowShiftOnlyKeybindings: definition.allowShiftOnlyKeybindings === true + }) + return normalized.ok ? normalized.value : binding + }) +} + +export function getEffectiveKeybindingsForAction( + actionId: KeybindingActionId, + platform: NodeJS.Platform, + overrides?: KeybindingOverrides +): string[] { + const definition = DEFINITIONS_BY_ID.get(actionId) + const override = overrides?.[actionId] + if (Array.isArray(override)) { + // Why: canonicalize digit-index overrides to +1 so display/conflict stay consistent even if a hand-edited file stored a different digit. + if (isDigitIndexActionId(actionId)) { + const canonical: string[] = [] + for (const binding of override) { + const normalized = canonicalizeDigitIndexBinding(binding) + if (normalized.ok && !canonical.includes(normalized.value)) { + canonical.push(normalized.value) + } + } + return canonical + } + return override.flatMap((binding) => { + const normalized = normalizeKeybindingWithOptions( + binding, + normalizeOptionsForAction(actionId) + ) + return normalized.ok ? [normalized.value] : [] + }) + } + return definition ? getDefaultBindings(definition, platform) : [] +} + +export function getEffectiveKeybindingsForDefinition( + definition: KeybindingDefinition, + platform: NodeJS.Platform, + overrides?: KeybindingOverrides +): string[] { + const override = overrides?.[definition.id] + if (Array.isArray(override)) { + return getEffectiveKeybindingsForAction(definition.id, platform, overrides) + } + return getDefaultBindings(definition, platform) +} + +export function normalizeTerminalShortcutPolicy( + policy: TerminalShortcutPolicy | null | undefined +): TerminalShortcutPolicy { + return policy === 'terminal-first' ? 'terminal-first' : 'orca-first' +} + +export function isKeybindingAllowedInTerminal(definition: KeybindingDefinition): boolean { + return definition.scope === 'terminal' || definition.allowInTerminal === true +} + +export function isKeybindingPotentialTerminalConflict(definition: KeybindingDefinition): boolean { + return definition.scope !== 'terminal' && definition.allowInTerminal !== true +} + +export function keybindingIsActiveInContext( + definition: KeybindingDefinition, + options: KeybindingMatchOptions = {} +): boolean { + if (options.context !== 'terminal') { + return true + } + // Why: Orca-first keeps app shortcuts inside terminals; terminal-first is the escape hatch for shells and TUIs. + if (normalizeTerminalShortcutPolicy(options.terminalShortcutPolicy) === 'orca-first') { + return true + } + return isKeybindingAllowedInTerminal(definition) +} diff --git a/src/shared/keybindings/formatting.ts b/src/shared/keybindings/formatting.ts new file mode 100644 index 00000000000..68d1ef6e5a8 --- /dev/null +++ b/src/shared/keybindings/formatting.ts @@ -0,0 +1,206 @@ +import type { + FindKeybindingConflictOptions, + KeybindingActionId, + KeybindingConflict, + KeybindingDefinition, + KeybindingOverrides, + ModifierToken, + KeybindingScope +} from './types' +import { KEYBINDING_DEFINITIONS, isDigitIndexActionId } from './definitions' +import { parseKeybinding } from './parser' +import { getEffectiveKeybindingsForAction, getEffectiveKeybindingsForDefinition } from './effective' +import { isDoubleTapBinding } from './normalization' +import { isKeybindingActionId } from './definitions' +import { getKeybindingConflictIdentity, keybindingConflictIdentities } from './matching' + +function formatModifierGlyph(modifier: ModifierToken, isMac: boolean): string { + switch (modifier) { + case 'Mod': + return isMac ? '⌘' : 'Ctrl' + case 'Cmd': + return isMac ? '⌘' : 'Cmd' + case 'Ctrl': + return isMac ? '⌃' : 'Ctrl' + case 'Alt': + return isMac ? '⌥' : 'Alt' + case 'Shift': + return isMac ? '⇧' : 'Shift' + } +} + +export function formatKeybinding(binding: string, platform: NodeJS.Platform): string[] { + const parsed = parseKeybinding(binding) + if (!parsed) { + return [binding] + } + const isMac = platform === 'darwin' + if (parsed.doubleTapModifier) { + const glyph = formatModifierGlyph(parsed.doubleTapModifier, isMac) + return [glyph, glyph] + } + const parts: string[] = [] + if (parsed.mod) { + parts.push(isMac ? '⌘' : 'Ctrl') + } + if (parsed.meta) { + parts.push(isMac ? '⌘' : 'Cmd') + } + if (parsed.control) { + parts.push(isMac ? '⌃' : 'Ctrl') + } + if (parsed.alt) { + parts.push(isMac ? '⌥' : 'Alt') + } + if (parsed.shift) { + parts.push(isMac ? '⇧' : 'Shift') + } + parts.push(formatKeyToken(parsed.key, isMac)) + return parts +} + +export function formatKeybindingList( + bindings: readonly string[], + platform: NodeJS.Platform +): string { + if (bindings.length === 0) { + return 'Unassigned' + } + return bindings + .map((binding) => { + const separator = isDoubleTapBinding(binding) ? ' ' : platform === 'darwin' ? '' : '+' + return formatKeybinding(binding, platform).join(separator) + }) + .join(', ') +} + +export function findKeybindingActionsForBinding( + binding: string, + platform: NodeJS.Platform, + overrides?: KeybindingOverrides, + scopes: readonly KeybindingScope[] = ['global', 'tabs'] +): KeybindingActionId[] { + const identity = getKeybindingConflictIdentity(binding, platform) + const allowedScopes = new Set(scopes) + return KEYBINDING_DEFINITIONS.filter( + (definition) => + allowedScopes.has(definition.scope) && + getEffectiveKeybindingsForAction(definition.id, platform, overrides).some((candidate) => + keybindingConflictIdentities(definition.id, candidate, platform).includes(identity) + ) + ).map((definition) => definition.id) +} + +function formatKeyToken(token: string, isMac: boolean): string { + const labels: Record = { + BracketLeft: '[', + BracketRight: ']', + Minus: '-', + Underscore: '_', + Equal: '=', + Plus: '+', + ArrowLeft: '←', + ArrowRight: '→', + ArrowUp: '↑', + ArrowDown: '↓', + PageUp: 'PageUp', + PageDown: 'PageDown', + NumpadAdd: 'Numpad +', + NumpadSubtract: 'Numpad -', + Comma: ',', + Period: '.', + Slash: '/', + Backslash: '\\', + Semicolon: ';', + Quote: "'", + Backquote: '`', + Enter: 'Enter', + Backspace: isMac ? '⌫' : 'Backspace', + Delete: 'Delete', + Insert: 'Insert', + Tab: 'Tab', + Escape: 'Esc', + Space: 'Space' + } + return labels[token] ?? token +} + +export function findKeybindingConflicts( + platform: NodeJS.Platform, + overrides?: KeybindingOverrides, + options: FindKeybindingConflictOptions = {} +): KeybindingConflict[] { + return findKeybindingConflictsForDefinitions(KEYBINDING_DEFINITIONS, platform, overrides, options) +} + +export function findKeybindingConflictsForDefinitions( + definitions: readonly KeybindingDefinition[], + platform: NodeJS.Platform, + overrides?: KeybindingOverrides, + options: FindKeybindingConflictOptions = {} +): KeybindingConflict[] { + const owners = new Map }>() + const ignoredActionIds = new Set(options.ignoredActionIds ?? []) + const customizedActions = new Set( + Object.keys(overrides ?? {}).filter( + (actionId): actionId is KeybindingActionId => + isKeybindingActionId(actionId) && !ignoredActionIds.has(actionId) + ) + ) + for (const actionId of options.relevantActionIds ?? []) { + if (!ignoredActionIds.has(actionId)) { + customizedActions.add(actionId) + } + } + for (const definition of definitions) { + if (ignoredActionIds.has(definition.id)) { + continue + } + for (const binding of getEffectiveKeybindingsForDefinition(definition, platform, overrides)) { + const groups = new Set([definition.conflictGroup ?? definition.scope]) + if (definition.conflictGroup) { + // Why: native menu accelerators can consume global chords, so check custom bindings against both the menu bucket and scope. + groups.add(definition.scope) + } + for (const group of groups) { + for (const identity of keybindingConflictIdentities(definition.id, binding, platform)) { + const conflictKey = `${group}\u0000${identity}` + const current = owners.get(conflictKey) ?? { binding, actionIds: new Set() } + if ( + !isDigitIndexActionId(definition.id) && + Array.from(current.actionIds).some((actionId) => isDigitIndexActionId(actionId)) + ) { + current.binding = binding + } + current.actionIds.add(definition.id) + owners.set(conflictKey, current) + } + } + } + } + + const seenConflictKeys = new Set() + return Array.from(owners.values()) + .filter(({ actionIds }) => actionIds.size > 1 && setIntersects(actionIds, customizedActions)) + .map(({ binding, actionIds }) => ({ + binding, + actionIds: Array.from(actionIds) + })) + .filter((conflict) => { + const key = `${conflict.binding}\u0000${conflict.actionIds.join('\u0000')}` + if (seenConflictKeys.has(key)) { + return false + } + seenConflictKeys.add(key) + return true + }) +} + +function setIntersects(left: ReadonlySet, right: ReadonlySet): boolean { + for (const value of left) { + if (right.has(value)) { + return true + } + } + return false +} diff --git a/src/shared/keybindings/input.ts b/src/shared/keybindings/input.ts new file mode 100644 index 00000000000..5857cd80011 --- /dev/null +++ b/src/shared/keybindings/input.ts @@ -0,0 +1,261 @@ +import type { + KeybindingActionId, + KeybindingInput, + KeybindingValidationResult, + ModifierToken, + NormalizeKeybindingOptions, + PhysicalModifierToken +} from './types' +import { getKeybindingPlatform, isDigitIndexActionId } from './definitions' +import { + canonicalizeDigitIndexBinding, + normalizeKeybindingWithOptions, + normalizeOptionsForAction +} from './normalization' +import { hasModifier, normalizeKeyToken } from './parser' + +const MODIFIER_KEYS = new Set([ + 'Alt', + 'AltGraph', + 'Control', + 'Meta', + 'Shift', + 'OS', + 'Fn', + 'FnLock', + 'Hyper', + 'Super', + 'Symbol', + 'SymbolLock' +]) + +export const PUNCTUATION_KEY_TOKENS = new Set([ + 'BracketLeft', + 'BracketRight', + 'Minus', + 'Underscore', + 'Equal', + 'Plus', + 'Comma', + 'Period', + 'Slash', + 'Backslash', + 'Semicolon', + 'Quote', + 'Backquote' +]) + +export function isPunctuationKeyToken(token: string | null): token is string { + return token !== null && PUNCTUATION_KEY_TOKENS.has(token) +} + +const PHYSICAL_CODE_FALLBACK_KEYS = new Set(['', 'Dead', 'Unidentified']) + +const SHIFTED_PUNCTUATION_KEY_TOKENS: Record = { + '<': 'Comma', + '>': 'Period', + '?': 'Slash', + '|': 'Backslash', + ':': 'Semicolon', + '"': 'Quote', + '~': 'Backquote' +} + +export function logicalKeyTokenFromInput(input: KeybindingInput): string | null { + const key = input.key ?? '' + if (MODIFIER_KEYS.has(key)) { + return null + } + const normalizedKey = normalizeKeyToken(key) + if (normalizedKey) { + return normalizedKey + } + if (hasModifier(input, 'shift')) { + return SHIFTED_PUNCTUATION_KEY_TOKENS[key] ?? null + } + return null +} + +export function canUsePhysicalCodeFallback(input: KeybindingInput): boolean { + // Why: layout-aware shortcuts trust real logical keys; physical code is only a fallback when the platform can't report the produced key. + return PHYSICAL_CODE_FALLBACK_KEYS.has(input.key ?? '') +} + +export function isLatinShortcutKey(key: string): boolean { + // Why: A-Z / 0-9 are the only chars a Latin shortcut names; a non-Latin char (Cyrillic с, Greek π) is never a Latin remap, so physical-code fallback is safe. + if (key.length !== 1) { + return false + } + const upper = key.toUpperCase() + return (upper >= 'A' && upper <= 'Z') || (key >= '0' && key <= '9') +} + +export function shouldUseNonLatinShortcutPhysicalFallback( + input: KeybindingInput, + platform: NodeJS.Platform +): boolean { + // Why: non-Latin layouts report non-Latin logical keys for physical letters (#6274), breaking Ctrl/Meta shortcuts; fall back to the physical code. + if (getKeybindingPlatform(platform) === 'darwin') { + return false + } + const hasPrimaryModifier = hasModifier(input, 'control') || hasModifier(input, 'meta') + if (!hasPrimaryModifier) { + return false + } + // AltGr surfaces as Ctrl+Alt on Windows/Linux; treat it as text, not a chord. + if (hasModifier(input, 'control') && hasModifier(input, 'alt')) { + return false + } + if (logicalKeyTokenFromInput(input) !== null) { + return false + } + const key = input.key ?? '' + return key !== '' && !MODIFIER_KEYS.has(key) && !isLatinShortcutKey(key) +} + +export function canFallBackToPhysicalCode( + input: KeybindingInput, + platform: NodeJS.Platform +): boolean { + return ( + canUsePhysicalCodeFallback(input) || shouldUseNonLatinShortcutPhysicalFallback(input, platform) + ) +} + +export function physicalCodeKeyTokenFromInput(input: KeybindingInput): string | null { + const code = input.code ?? '' + if (code.startsWith('Key') && code.length === 4) { + return code.slice(3).toUpperCase() + } + if (code.startsWith('Digit') && code.length === 6) { + return code.slice(5) + } + + return normalizeKeyToken(code) +} + +export function numpadCodeKeyTokenFromInput(input: KeybindingInput): string | null { + const code = input.code ?? '' + return code === 'NumpadAdd' || code === 'NumpadSubtract' ? normalizeKeyToken(code) : null +} + +export function shouldUseMacOptionComposedCaptureFallback( + input: KeybindingInput, + platform: NodeJS.Platform +): boolean { + // Why: macOS Option+key reports composed characters (Option+C -> ç), so capturing Alt shortcuts needs the physical-code fallback. + if ( + getKeybindingPlatform(platform) !== 'darwin' || + !hasModifier(input, 'alt') || + MODIFIER_KEYS.has(input.key ?? '') + ) { + return false + } + const physicalToken = physicalCodeKeyTokenFromInput(input) + if (!physicalToken) { + return false + } + return ( + (physicalToken.length === 1 && physicalToken >= 'A' && physicalToken <= 'Z') || + isPunctuationKeyToken(physicalToken) + ) +} + +export function keyTokenFromInput( + input: KeybindingInput, + platform: NodeJS.Platform +): string | null { + const numpadKey = numpadCodeKeyTokenFromInput(input) + if (numpadKey) { + return numpadKey + } + const logicalKey = logicalKeyTokenFromInput(input) + if (logicalKey) { + return logicalKey + } + if ( + !canUsePhysicalCodeFallback(input) && + !shouldUseMacOptionComposedCaptureFallback(input, platform) && + !shouldUseNonLatinShortcutPhysicalFallback(input, platform) + ) { + return null + } + return physicalCodeKeyTokenFromInput(input) +} + +// Why: the platform primary modifier canonicalizes to Mod (Cmd on macOS / Ctrl elsewhere), mirroring normal capture. +export function canonicalDoubleTapToken( + modifier: PhysicalModifierToken, + platform: NodeJS.Platform +): ModifierToken { + const isMac = platform === 'darwin' + if (modifier === 'Cmd' && isMac) { + return 'Mod' + } + if (modifier === 'Ctrl' && !isMac) { + return 'Mod' + } + return modifier +} + +export function keybindingFromInputWithOptions( + input: KeybindingInput, + platform: NodeJS.Platform, + options: NormalizeKeybindingOptions = {} +): KeybindingValidationResult { + if (input.doubleTapModifier) { + return normalizeKeybindingWithOptions( + `DoubleTap+${canonicalDoubleTapToken(input.doubleTapModifier, platform)}`, + options + ) + } + const key = keyTokenFromInput(input, platform) + if (!key) { + return { ok: false, error: 'Press a key, not only a modifier.' } + } + + const isMac = getKeybindingPlatform(platform) === 'darwin' + const parts: string[] = [] + const primaryModifierPressed = isMac ? hasModifier(input, 'meta') : hasModifier(input, 'control') + if (primaryModifierPressed) { + parts.push('Mod') + } + if (isMac && hasModifier(input, 'control')) { + parts.push('Ctrl') + } + if (!isMac && hasModifier(input, 'meta')) { + parts.push('Cmd') + } + if (hasModifier(input, 'alt')) { + parts.push('Alt') + } + if (hasModifier(input, 'shift')) { + parts.push('Shift') + } + parts.push(key) + + return normalizeKeybindingWithOptions(parts.join('+'), options) +} + +export function keybindingFromInput( + input: KeybindingInput, + platform: NodeJS.Platform +): KeybindingValidationResult { + return keybindingFromInputWithOptions(input, platform) +} + +export function keybindingFromInputForAction( + actionId: KeybindingActionId, + input: KeybindingInput, + platform: NodeJS.Platform +): KeybindingValidationResult { + const result = keybindingFromInputWithOptions( + input, + platform, + normalizeOptionsForAction(actionId) + ) + if (!result.ok || !isDigitIndexActionId(actionId)) { + return result + } + return canonicalizeDigitIndexBinding(result.value) +} diff --git a/src/shared/keybindings/matching-key.ts b/src/shared/keybindings/matching-key.ts new file mode 100644 index 00000000000..dd7a5c4bd14 --- /dev/null +++ b/src/shared/keybindings/matching-key.ts @@ -0,0 +1,187 @@ +import type { KeybindingInput, ModifierToken, ParsedKeybinding } from './types' +import { getKeybindingPlatform } from './definitions' +import { hasModifier } from './parser' +import { + canFallBackToPhysicalCode, + logicalKeyTokenFromInput, + physicalCodeKeyTokenFromInput, + numpadCodeKeyTokenFromInput, + isPunctuationKeyToken +} from './input' + +export function platformModifiers( + parsed: ParsedKeybinding, + platform: NodeJS.Platform +): { meta: boolean; control: boolean; alt: boolean; shift: boolean } { + const isMac = platform === 'darwin' + return { + meta: parsed.meta || (parsed.mod && isMac), + control: parsed.control || (parsed.mod && !isMac), + alt: parsed.alt, + shift: parsed.shift + } +} + +export function modifierStateMatches( + parsed: ParsedKeybinding, + input: KeybindingInput, + platform: NodeJS.Platform +): boolean { + const expected = platformModifiers(parsed, platform) + return ( + hasModifier(input, 'meta') === expected.meta && + hasModifier(input, 'control') === expected.control && + hasModifier(input, 'alt') === expected.alt && + hasModifier(input, 'shift') === expected.shift + ) +} + +export function shouldUseMacOptionLetterPhysicalFallback( + parsed: ParsedKeybinding, + input: KeybindingInput, + platform: NodeJS.Platform +): boolean { + // Why: macOS Option+letter reports composed characters (Option+A -> å), leaving no logical Latin key for Alt shortcuts. + return ( + getKeybindingPlatform(platform) === 'darwin' && + parsed.alt && + hasModifier(input, 'alt') && + logicalKeyTokenFromInput(input) === null + ) +} + +export function shouldUseMacOptionPunctuationPhysicalFallback( + parsed: ParsedKeybinding, + input: KeybindingInput, + platform: NodeJS.Platform +): boolean { + // Why: macOS Option+punctuation reports composed dead-key values, leaving no logical bracket token for Alt shortcuts. + return ( + getKeybindingPlatform(platform) === 'darwin' && + parsed.alt && + hasModifier(input, 'alt') && + logicalKeyTokenFromInput(input) === null + ) +} + +export function letterKeyMatches( + input: KeybindingInput, + letter: string, + parsed: ParsedKeybinding, + platform: NodeJS.Platform +): boolean { + const logicalKey = logicalKeyTokenFromInput(input) + if (logicalKey && logicalKey.length === 1 && logicalKey >= 'A' && logicalKey <= 'Z') { + return logicalKey === letter.toUpperCase() + } + return ( + (canFallBackToPhysicalCode(input, platform) || + shouldUseMacOptionLetterPhysicalFallback(parsed, input, platform)) && + input.code === `Key${letter.toUpperCase()}` + ) +} + +export function digitKeyMatches( + input: KeybindingInput, + digit: string, + platform: NodeJS.Platform +): boolean { + const logicalKey = logicalKeyTokenFromInput(input) + if (logicalKey && logicalKey.length === 1 && logicalKey >= '0' && logicalKey <= '9') { + return logicalKey === digit + } + return canFallBackToPhysicalCode(input, platform) && input.code === `Digit${digit}` +} + +export function semanticPunctuationKey(input: KeybindingInput): string | null { + const logicalKey = logicalKeyTokenFromInput(input) + return isPunctuationKeyToken(logicalKey) ? logicalKey : null +} + +export function physicalPunctuationKey(input: KeybindingInput): string | null { + const physicalKey = physicalCodeKeyTokenFromInput(input) + return isPunctuationKeyToken(physicalKey) ? physicalKey : null +} + +export function shouldUseSemanticPunctuation( + parsed: ParsedKeybinding, + input: KeybindingInput, + platform: NodeJS.Platform +): boolean { + // Why: Windows/Linux expose AltGr as Ctrl+Alt; don't turn international text input into Mod+Alt app shortcuts. + if ( + getKeybindingPlatform(platform) !== 'darwin' && + parsed.mod && + parsed.alt && + hasModifier(input, 'control') && + hasModifier(input, 'alt') && + !hasModifier(input, 'meta') && + physicalPunctuationKey(input) === null + ) { + return false + } + return true +} + +export function keyMatches( + parsedKey: string, + input: KeybindingInput, + parsed: ParsedKeybinding, + platform: NodeJS.Platform +): boolean { + if (parsedKey.length === 1 && parsedKey >= 'A' && parsedKey <= 'Z') { + return letterKeyMatches(input, parsedKey, parsed, platform) + } + if (parsedKey.length === 1 && parsedKey >= '0' && parsedKey <= '9') { + return digitKeyMatches(input, parsedKey, platform) + } + + if (parsedKey === 'NumpadAdd' || parsedKey === 'NumpadSubtract') { + return ( + numpadCodeKeyTokenFromInput(input) === parsedKey || + logicalKeyTokenFromInput(input) === parsedKey + ) + } + + if (isPunctuationKeyToken(parsedKey)) { + // Why: shortcut labels name logical punctuation, but international layouts can report it from different physical codes. + const semanticKey = semanticPunctuationKey(input) + if (semanticKey !== null) { + if (!shouldUseSemanticPunctuation(parsed, input, platform)) { + return false + } + return semanticKey === parsedKey + } + return ( + (canFallBackToPhysicalCode(input, platform) || + shouldUseMacOptionPunctuationPhysicalFallback(parsed, input, platform)) && + physicalPunctuationKey(input) === parsedKey + ) + } + + const logicalKey = logicalKeyTokenFromInput(input) + if (logicalKey !== null) { + return logicalKey === parsedKey + } + return ( + canFallBackToPhysicalCode(input, platform) && physicalCodeKeyTokenFromInput(input) === parsedKey + ) +} + +export function resolveModifierToken( + modifier: ModifierToken, + platform: NodeJS.Platform +): 'meta' | 'control' | 'alt' | 'shift' { + switch (modifier) { + case 'Mod': + return platform === 'darwin' ? 'meta' : 'control' + case 'Cmd': + return 'meta' + case 'Ctrl': + return 'control' + case 'Alt': + return 'alt' + case 'Shift': + return 'shift' + } +} diff --git a/src/shared/keybindings/matching.ts b/src/shared/keybindings/matching.ts new file mode 100644 index 00000000000..eb825724e55 --- /dev/null +++ b/src/shared/keybindings/matching.ts @@ -0,0 +1,140 @@ +import type { + KeybindingActionId, + KeybindingInput, + KeybindingOverrides, + KeybindingMatchOptions, + ParsedKeybinding +} from './types' +import { DEFINITIONS_BY_ID, DIGIT_INDEX_KEY_PATTERN, isDigitIndexActionId } from './definitions' +import { canonicalizeParsedKeybinding, parseKeybinding } from './parser' +import { + platformModifiers, + modifierStateMatches, + keyMatches, + digitKeyMatches +} from './matching-key' +import { resolveModifierToken } from './matching-key' +import { getEffectiveKeybindingsForAction, keybindingIsActiveInContext } from './effective' + +export function keybindingMatchesInput( + binding: string, + input: KeybindingInput, + platform: NodeJS.Platform +): boolean { + const parsed = parseKeybinding(binding) + if (!parsed) { + return false + } + // A double-tap binding matches only a synthetic double-tap input (and vice-versa), resolved per platform. + if (parsed.doubleTapModifier) { + return ( + input.doubleTapModifier !== undefined && + resolveModifierToken(parsed.doubleTapModifier, platform) === + resolveModifierToken(input.doubleTapModifier, platform) + ) + } + if (input.doubleTapModifier !== undefined) { + return false + } + return ( + modifierStateMatches(parsed, input, platform) && keyMatches(parsed.key, input, parsed, platform) + ) +} + +export function keybindingConflictIdentityForParsed( + parsed: ParsedKeybinding, + platform: NodeJS.Platform +): string { + if (parsed.doubleTapModifier) { + return `DoubleTap:${resolveModifierToken(parsed.doubleTapModifier, platform)}` + } + const modifiers = platformModifiers(parsed, platform) + return [ + modifiers.meta ? 'Meta' : '', + modifiers.control ? 'Control' : '', + modifiers.alt ? 'Alt' : '', + modifiers.shift ? 'Shift' : '', + parsed.key + ].join('+') +} + +export function getKeybindingConflictIdentity(binding: string, platform: NodeJS.Platform): string { + const parsed = parseKeybinding(binding) + return parsed ? keybindingConflictIdentityForParsed(parsed, platform) : binding +} + +export function keybindingConflictIdentities( + actionId: KeybindingActionId, + binding: string, + platform: NodeJS.Platform +): readonly string[] { + const exact = getKeybindingConflictIdentity(binding, platform) + if (!isDigitIndexActionId(actionId)) { + return [exact] + } + const parsed = parseKeybinding(binding) + if (!parsed || parsed.doubleTapModifier || !DIGIT_INDEX_KEY_PATTERN.test(parsed.key)) { + return [exact] + } + return Array.from({ length: 9 }, (_, index) => + keybindingConflictIdentityForParsed({ ...parsed, key: String(index + 1) }, platform) + ) +} + +export function keybindingMatchesAction( + actionId: KeybindingActionId, + input: KeybindingInput, + platform: NodeJS.Platform, + overrides?: KeybindingOverrides, + options: KeybindingMatchOptions = {} +): boolean { + const definition = DEFINITIONS_BY_ID.get(actionId) + if (!definition) { + return false + } + if (!keybindingIsActiveInContext(definition, options)) { + return false + } + return getEffectiveKeybindingsForAction(actionId, platform, overrides).some((binding) => + keybindingMatchesInput(binding, input, platform) + ) +} + +export function digitFromInput(input: KeybindingInput, platform: NodeJS.Platform): string | null { + for (let value = 1; value <= 9; value++) { + const digit = String(value) + if (digitKeyMatches(input, digit, platform)) { + return digit + } + } + return null +} + +// Why: a digit-index row's representative chord fires for any 1-9 — reuse its modifiers with the pressed digit via the normal matcher. +export function matchKeybindingDigitIndex( + actionId: KeybindingActionId, + input: KeybindingInput, + platform: NodeJS.Platform, + overrides?: KeybindingOverrides, + options: KeybindingMatchOptions = {} +): number | null { + const definition = DEFINITIONS_BY_ID.get(actionId) + if (!definition || !keybindingIsActiveInContext(definition, options)) { + return null + } + const digit = digitFromInput(input, platform) + if (!digit) { + return null + } + for (const binding of getEffectiveKeybindingsForAction(actionId, platform, overrides)) { + const parsed = parseKeybinding(binding) + if (!parsed || parsed.doubleTapModifier || !DIGIT_INDEX_KEY_PATTERN.test(parsed.key)) { + continue + } + const candidate = canonicalizeParsedKeybinding({ ...parsed, key: digit }) + if (keybindingMatchesInput(candidate, input, platform)) { + return Number(digit) - 1 + } + } + return null +} diff --git a/src/shared/keybindings/normalization.ts b/src/shared/keybindings/normalization.ts new file mode 100644 index 00000000000..bb069603424 --- /dev/null +++ b/src/shared/keybindings/normalization.ts @@ -0,0 +1,158 @@ +import type { + KeybindingActionId, + KeybindingValidationResult, + NormalizeKeybindingOptions +} from './types' +import { DEFINITIONS_BY_ID, DIGIT_INDEX_KEY_PATTERN, isDigitIndexActionId } from './definitions' +import { canonicalizeParsedKeybinding, isSafeBareKey, parseKeybinding } from './parser' + +export function normalizeKeybindingWithOptions( + binding: string, + options: NormalizeKeybindingOptions = {} +): KeybindingValidationResult { + const parsed = parseKeybinding(binding) + if (!parsed) { + return { ok: false, error: 'Use a shortcut like Ctrl+Shift+P or Cmd+K.' } + } + if (parsed.mod && (parsed.meta || parsed.control)) { + return { ok: false, error: 'Use either Mod or a platform-specific modifier, not both.' } + } + if (parsed.doubleTapModifier) { + return { ok: true, value: canonicalizeParsedKeybinding(parsed) } + } + const isShiftInsert = parsed.shift && parsed.key === 'Insert' + const isBareAllowed = options.allowBareKeybindings === true && isSafeBareKey(parsed) + const isShiftOnlyAllowed = + options.allowShiftOnlyKeybindings === true && + parsed.shift && + !parsed.mod && + !parsed.meta && + !parsed.control && + !parsed.alt + if ( + !parsed.mod && + !parsed.meta && + !parsed.control && + !parsed.alt && + !isShiftInsert && + !isBareAllowed && + !isShiftOnlyAllowed + ) { + return { ok: false, error: 'Include at least one modifier key.' } + } + return { ok: true, value: canonicalizeParsedKeybinding(parsed) } +} + +export function normalizeKeybinding(binding: string): KeybindingValidationResult { + return normalizeKeybindingWithOptions(binding) +} + +export function isDoubleTapBinding(binding: string): boolean { + return Boolean(parseKeybinding(binding)?.doubleTapModifier) +} + +export function normalizeKeybindingListWithOptions( + input: string, + options: NormalizeKeybindingOptions = {} +): KeybindingValidationResult | string[] { + const trimmed = input.trim() + if (!trimmed) { + return [] + } + const normalized: string[] = [] + for (const piece of trimmed.split(',')) { + const result = normalizeKeybindingWithOptions(piece, options) + if (!result.ok) { + return result + } + if (!normalized.includes(result.value)) { + normalized.push(result.value) + } + } + return normalized +} + +export function normalizeKeybindingList(input: string): KeybindingValidationResult | string[] { + return normalizeKeybindingListWithOptions(input) +} + +export function normalizeKeybindingArrayWithOptions( + input: readonly string[], + options: NormalizeKeybindingOptions = {} +): KeybindingValidationResult | string[] { + const normalized: string[] = [] + for (const binding of input) { + const piece = normalizeKeybindingListWithOptions(binding, options) + if (!Array.isArray(piece)) { + return piece + } + for (const normalizedBinding of piece) { + if (!normalized.includes(normalizedBinding)) { + normalized.push(normalizedBinding) + } + } + } + return normalized +} + +export function normalizeOptionsForAction( + actionId: KeybindingActionId +): NormalizeKeybindingOptions { + const definition = DEFINITIONS_BY_ID.get(actionId) + return { + allowBareKeybindings: definition?.allowBareKeybindings === true, + allowShiftOnlyKeybindings: definition?.allowShiftOnlyKeybindings === true + } +} + +// Why: rewrite a digit-index chord's key to 1 so display and conflict detection stay stable across the 1-9 range; reject any non 1-9 key. +export function canonicalizeDigitIndexBinding(binding: string): KeybindingValidationResult { + const parsed = parseKeybinding(binding) + if (!parsed || parsed.doubleTapModifier || !DIGIT_INDEX_KEY_PATTERN.test(parsed.key)) { + return { + ok: false, + error: 'Pick a number key 1–9 with a modifier, like Cmd+1 or Ctrl+1.' + } + } + return { ok: true, value: canonicalizeParsedKeybinding({ ...parsed, key: '1' }) } +} + +export function finalizeDigitIndexBindings( + actionId: KeybindingActionId, + result: KeybindingValidationResult | string[] +): KeybindingValidationResult | string[] { + if (!isDigitIndexActionId(actionId) || !Array.isArray(result)) { + return result + } + const canonical: string[] = [] + for (const binding of result) { + const normalized = canonicalizeDigitIndexBinding(binding) + if (!normalized.ok) { + return normalized + } + if (!canonical.includes(normalized.value)) { + canonical.push(normalized.value) + } + } + return canonical +} + +export function normalizeKeybindingListForAction( + actionId: KeybindingActionId, + input: string +): KeybindingValidationResult | string[] { + return finalizeDigitIndexBindings( + actionId, + normalizeKeybindingListWithOptions(input, normalizeOptionsForAction(actionId)) + ) +} + +export function normalizeKeybindingArrayForAction( + actionId: KeybindingActionId, + input: readonly string[] +): KeybindingValidationResult | string[] { + return finalizeDigitIndexBindings( + actionId, + normalizeKeybindingArrayWithOptions(input, normalizeOptionsForAction(actionId)) + ) +} diff --git a/src/shared/keybindings/parser.ts b/src/shared/keybindings/parser.ts new file mode 100644 index 00000000000..022aa4bec29 --- /dev/null +++ b/src/shared/keybindings/parser.ts @@ -0,0 +1,261 @@ +import type { KeybindingInput, ModifierToken, ParsedKeybinding } from './types' + +export function hasModifier( + input: KeybindingInput, + modifier: 'alt' | 'meta' | 'control' | 'shift' +): boolean { + if (modifier === 'alt') { + return Boolean(input.alt ?? input.altKey) + } + if (modifier === 'meta') { + return Boolean(input.meta ?? input.metaKey) + } + if (modifier === 'control') { + return Boolean(input.control ?? input.ctrlKey) + } + return Boolean(input.shift ?? input.shiftKey) +} + +function isFunctionKeyToken(key: string): boolean { + return /^F([1-9]|1[0-9]|2[0-4])$/.test(key) +} + +export function normalizeKeyToken(token: string): string | null { + if (token === ' ') { + return 'Space' + } + const trimmed = token.trim() + if (!trimmed) { + return null + } + const upper = trimmed.toUpperCase() + if (upper.length === 1 && upper >= 'A' && upper <= 'Z') { + return upper + } + if (upper.length === 1 && upper >= '0' && upper <= '9') { + return upper + } + // Function keys F1–F24 (event.key/event.code report them verbatim, e.g. F7). + if (isFunctionKeyToken(upper)) { + return upper + } + + const simple: Record = { + '[': 'BracketLeft', + ']': 'BracketRight', + '{': 'BracketLeft', + '}': 'BracketRight', + '-': 'Minus', + _: 'Underscore', + '=': 'Equal', + '+': 'Plus', + ',': 'Comma', + '.': 'Period', + '/': 'Slash', + '\\': 'Backslash', + ';': 'Semicolon', + "'": 'Quote', + '`': 'Backquote', + RETURN: 'Enter', + ESC: 'Escape', + SPACEBAR: 'Space', + PGUP: 'PageUp', + PGDN: 'PageDown', + PLUS: 'Plus', + MINUS: 'Minus', + EQUAL: 'Equal', + UNDERSCORE: 'Underscore', + ARROWLEFT: 'ArrowLeft', + LEFT: 'ArrowLeft', + ARROWRIGHT: 'ArrowRight', + RIGHT: 'ArrowRight', + ARROWUP: 'ArrowUp', + UP: 'ArrowUp', + ARROWDOWN: 'ArrowDown', + DOWN: 'ArrowDown', + PAGEUP: 'PageUp', + PAGEDOWN: 'PageDown', + BACKSPACE: 'Backspace', + DELETE: 'Delete', + DEL: 'Delete', + INSERT: 'Insert', + INS: 'Insert', + ENTER: 'Enter', + TAB: 'Tab', + ESCAPE: 'Escape', + SPACE: 'Space', + BRACKETLEFT: 'BracketLeft', + BRACKETRIGHT: 'BracketRight', + NUMPADADD: 'NumpadAdd', + NUMPADSUBTRACT: 'NumpadSubtract', + ADD: 'NumpadAdd', + SUBTRACT: 'NumpadSubtract', + COMMA: 'Comma', + PERIOD: 'Period', + SLASH: 'Slash', + BACKSLASH: 'Backslash', + SEMICOLON: 'Semicolon', + QUOTE: 'Quote', + BACKQUOTE: 'Backquote' + } + + return simple[upper] ?? null +} + +export function parseModifierToken(rawPart: string): ModifierToken | null { + const part = rawPart.toLowerCase() + if (part === 'mod' || part === 'cmdorctrl' || part === 'commandorcontrol') { + return 'Mod' + } + if (part === 'cmd' || part === 'command' || part === 'meta' || rawPart === '⌘') { + return 'Cmd' + } + if (part === 'ctrl' || part === 'control' || rawPart === '⌃') { + return 'Ctrl' + } + if (part === 'alt' || part === 'option' || part === 'opt' || rawPart === '⌥') { + return 'Alt' + } + if (part === 'shift' || rawPart === '⇧') { + return 'Shift' + } + return null +} + +export function applyModifierToken(parsed: ParsedKeybinding, modifier: ModifierToken): void { + if (modifier === 'Mod') { + parsed.mod = true + } else if (modifier === 'Cmd') { + parsed.meta = true + } else if (modifier === 'Ctrl') { + parsed.control = true + } else if (modifier === 'Alt') { + parsed.alt = true + } else { + parsed.shift = true + } +} + +export function emptyParsedKeybinding(): ParsedKeybinding { + return { mod: false, meta: false, control: false, alt: false, shift: false, key: '' } +} + +// Why: a double-tap is a bare modifier with no key, so it can't use the normal parse path; modifier validation is deferred to normalize. +export function parseDoubleTapKeybinding(rawParts: string[]): ParsedKeybinding | null { + const modifiers: ModifierToken[] = [] + let sawDoubleTap = false + for (const rawPart of rawParts) { + if (rawPart.toLowerCase() === 'doubletap') { + if (sawDoubleTap) { + return null + } + sawDoubleTap = true + continue + } + const modifier = parseModifierToken(rawPart) + if (!modifier) { + return null + } + modifiers.push(modifier) + } + if (modifiers.length === 0) { + return null + } + const parsed = emptyParsedKeybinding() + for (const modifier of modifiers) { + applyModifierToken(parsed, modifier) + } + // Keep both flags when Mod is combined with a platform modifier, so normalize emits the shared "Mod or platform-specific, not both" error. + if (parsed.mod && (parsed.meta || parsed.control)) { + parsed.doubleTapModifier = 'Mod' + return parsed + } + if (modifiers.length > 1) { + return null + } + parsed.doubleTapModifier = modifiers[0] + return parsed +} + +export function parseKeybinding(binding: string): ParsedKeybinding | null { + const rawParts = binding + .split('+') + .map((part) => part.trim()) + .filter(Boolean) + if (rawParts.length === 0) { + return null + } + + if (rawParts.some((part) => part.toLowerCase() === 'doubletap')) { + return parseDoubleTapKeybinding(rawParts) + } + + const parsed = emptyParsedKeybinding() + for (const rawPart of rawParts) { + const modifier = parseModifierToken(rawPart) + if (modifier) { + applyModifierToken(parsed, modifier) + continue + } + if (parsed.key) { + return null + } + const key = normalizeKeyToken(rawPart) + if (!key) { + return null + } + parsed.key = key + } + + return parsed.key ? parsed : null +} + +export function canonicalizeParsedKeybinding(parsed: ParsedKeybinding): string { + if (parsed.doubleTapModifier) { + return `DoubleTap+${parsed.doubleTapModifier}` + } + const parts: string[] = [] + if (parsed.mod) { + parts.push('Mod') + } + if (parsed.meta) { + parts.push('Cmd') + } + if (parsed.control) { + parts.push('Ctrl') + } + if (parsed.alt) { + parts.push('Alt') + } + if (parsed.shift) { + parts.push('Shift') + } + parts.push(parsed.key) + return parts.join('+') +} + +export function isSafeBareKey(parsed: ParsedKeybinding): boolean { + if (parsed.mod || parsed.meta || parsed.control || parsed.alt) { + return false + } + // Function keys produce no text, so they're safe bare or with Shift (Shift+letter stays unsafe). + if (parsed.shift) { + return isFunctionKeyToken(parsed.key) + } + return ( + isFunctionKeyToken(parsed.key) || + [ + 'Backspace', + 'Delete', + 'Enter', + 'Escape', + 'Tab', + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'ArrowDown', + 'PageUp', + 'PageDown' + ].includes(parsed.key) + ) +} diff --git a/src/shared/keybindings/types.ts b/src/shared/keybindings/types.ts new file mode 100644 index 00000000000..b42d311f0f0 --- /dev/null +++ b/src/shared/keybindings/types.ts @@ -0,0 +1,200 @@ +import type { TuiAgent } from '../tui-agent' + +export type KeybindingScope = + | 'global' + | 'tabs' + | 'terminal' + | 'browser' + | 'editor' + | 'fileExplorer' + | 'composer' + | 'settings' + +export type KeybindingContext = 'app' | 'terminal' | 'browser' + +export type KeybindingPlatform = 'darwin' | 'linux' | 'win32' + +export type TerminalShortcutPolicy = 'orca-first' | 'terminal-first' + +export type KeybindingMatchOptions = { + context?: KeybindingContext + terminalShortcutPolicy?: TerminalShortcutPolicy +} + +export type AgentTabActionId = `tab.newAgent.${TuiAgent}` +export type PluginKeybindingActionId = `plugin:${string}` + +export type KeybindingActionId = + | 'worktree.quickOpen' + | 'worktree.palette' + | 'worktree.navigateUp' + | 'worktree.navigateDown' + | 'app.settings' + | 'app.forceReload' + | 'workspace.create' + | 'workspace.rename' + | 'workspace.delete' + | 'workspace.openBoard' + | 'workspace.selectByIndex' + | 'voice.dictation' + | 'view.tasks' + | 'dashboard.toggle' + | 'sidebar.left.toggle' + | 'sidebar.right.toggle' + | 'sidebar.explorer.toggle' + | 'sidebar.search.toggle' + | 'sidebar.sourceControl.toggle' + | 'sidebar.checks.toggle' + | 'sidebar.ports.toggle' + | 'sidebar.sleepingWorkspaces.toggle' + | 'sidebar.focusWorktreeList' + | 'floatingTerminal.toggle' + | 'floatingWorkspace.maximize' + | 'floatingWorkspace.minimize' + | 'zoom.in' + | 'zoom.out' + | 'zoom.reset' + | 'worktree.history.back' + | 'worktree.history.forward' + | 'tab.newTerminal' + | 'tab.newAgent' + | AgentTabActionId + | 'tab.newBrowser' + | 'tab.newSimulator' + | 'tab.newMarkdown' + | 'tab.openMarkdown' + | 'tab.close' + | 'tab.closeAll' + | 'tab.rename' + | 'tab.reopenClosed' + | 'tab.nextSameType' + | 'tab.previousSameType' + | 'tab.nextAllTypes' + | 'tab.previousAllTypes' + | 'tab.previousRecent' + | 'tab.nextTerminal' + | 'tab.previousTerminal' + | 'tab.selectByIndex' + | 'tab.openQuickCommandsMenu' + | 'browser.find' + | 'browser.back' + | 'browser.forward' + | 'browser.reload' + | 'browser.hardReload' + | 'browser.focusAddressBar' + | 'browser.grabElement' + | 'editor.find' + | 'editor.replace' + | 'editor.save' + | 'editor.markdownPreview' + | 'editor.toggleWordWrap' + | 'editor.copyContext' + | 'editor.previousChange' + | 'editor.nextChange' + | 'editor.addReviewNote' + | 'sourceControl.sendReviewNotes' + | 'fileExplorer.undo' + | 'fileExplorer.redo' + | 'fileExplorer.copyPath' + | 'fileExplorer.copyRelativePath' + | 'fileExplorer.delete' + | 'settings.search' + | 'terminal.copySelection' + | 'terminal.selectAll' + | 'terminal.paste' + | 'terminal.search' + | 'terminal.clear' + | 'terminal.focusNextPane' + | 'terminal.focusPreviousPane' + | 'terminal.equalizePaneSizes' + | 'terminal.expandPane' + | 'terminal.setTitle' + | 'terminal.clearPaneTitle' + | 'terminal.closePane' + | 'terminal.splitRight' + | 'terminal.splitDown' + | 'terminal.switchInputSource' + | PluginKeybindingActionId + +export type KeybindingOverrides = Partial> + +export type KeybindingFileDiagnostic = { + severity: 'warning' | 'error' + message: string + actionId?: string + section?: string +} + +export type KeybindingFileSnapshot = { + path: string + platform: KeybindingPlatform + exists: boolean + overrides: KeybindingOverrides + commonOverrides: KeybindingOverrides + platformOverrides: Partial> + diagnostics: KeybindingFileDiagnostic[] +} + +export type PlatformBindings = { + darwin: readonly string[] + linux: readonly string[] + win32: readonly string[] +} + +export type KeybindingDefinition = { + id: KeybindingActionId + title: string + group: string + scope: KeybindingScope + searchKeywords: readonly string[] + defaultBindings: PlatformBindings + allowInTerminal?: boolean + allowBareKeybindings?: boolean + allowShiftOnlyKeybindings?: boolean + conflictGroup?: string +} + +export type ModifierToken = 'Mod' | 'Cmd' | 'Ctrl' | 'Alt' | 'Shift' +export type PhysicalModifierToken = Exclude + +export type KeybindingInput = { + key?: string + code?: string + alt?: boolean + meta?: boolean + control?: boolean + shift?: boolean + altKey?: boolean + metaKey?: boolean + ctrlKey?: boolean + shiftKey?: boolean + // Set only by the double-tap detector; always a physical token (never 'Mod'). + doubleTapModifier?: PhysicalModifierToken +} + +export type ParsedKeybinding = { + mod: boolean + meta: boolean + control: boolean + alt: boolean + shift: boolean + key: string + doubleTapModifier?: ModifierToken +} + +export type NormalizeKeybindingOptions = { + allowBareKeybindings?: boolean + allowShiftOnlyKeybindings?: boolean +} + +export type KeybindingValidationResult = { ok: true; value: string } | { ok: false; error: string } + +export type KeybindingConflict = { + binding: string + actionIds: KeybindingActionId[] +} + +export type FindKeybindingConflictOptions = { + ignoredActionIds?: Iterable + relevantActionIds?: Iterable +}