Files
orca/src/renderer/src/hooks/useIpcEvents.ts
T

3867 lines
148 KiB
TypeScript

/* oxlint-disable max-lines -- Why: this App-level IPC bridge intentionally keeps the renderer's main-process event contract in one place so shortcut, runtime, updater, and agent-status wiring do not drift across files. */
import { useEffect } from 'react'
import { toast } from 'sonner'
import { useAppStore } from '../store'
import { getTabIdsAwaitingHostHydrationRemount } from '@/lib/parked-terminal-host-hydration'
import { applyWorktreeHeadIdentities } from './worktree-head-identity-apply'
import { getWorktreeMapFromState, getRepoMapFromState } from '@/store/selectors'
import { applyUIZoom } from '@/lib/ui-zoom'
import { activateAndRevealWorktree, activateAndRevealWorkspace } from '@/lib/worktree-activation'
import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item'
import { runWorktreeDelete } from '@/components/sidebar/delete-worktree-flow'
import { runSleepWorktree } from '@/components/sidebar/sleep-worktree-flow'
import { createBackgroundSleepingAgentWakeDispatcher } from '@/lib/wake-sleeping-agents-in-background'
import { OPEN_WORKSPACE_BOARD_EVENT } from '@/components/sidebar/useWorkspaceBoardPanel'
import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal'
import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount'
import { planMobileTerminalTabMount } from '@/lib/mobile-terminal-tab-mount'
import { resolveTerminalTabPtyOwnership } from '@/lib/terminal-tab-for-pty-id'
import {
hasRegisteredRuntimeTerminalTab,
focusRuntimeTerminalSurface
} from '@/runtime/sync-runtime-graph'
import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal'
import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees'
import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts'
import { emitCmdJRowIndexJump } from '@/lib/cmd-j-row-index-jump'
import { nextEditorFontZoomLevel, computeEditorFontSize } from '@/lib/editor-font-zoom'
import { canConnectSshStatus } from '@/ssh/ssh-connection-recoverability'
import type {
TerminalLayoutSnapshot,
TerminalPaneLayoutNode,
UpdateStatus
} from '../../../shared/types'
import type { RateLimitState } from '../../../shared/rate-limit-types'
import type { DirectSshAuthority, SshConnectionState } from '../../../shared/ssh-types'
import {
toRuntimeExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from '../../../shared/execution-host'
import { isWslHookRelayConnectionId } from '../../../shared/wsl-hook-relay-contract'
import type {
RuntimeBrowserDriverState,
RuntimeTerminalPresentation,
RuntimeTerminalDriverState
} from '../../../shared/runtime-types'
import { zoomLevelToPercent } from '@/components/settings/SettingsConstants'
import { stepUIZoomLevel } from '../../../shared/ui-zoom-level'
import { dispatchZoomLevelChanged } from '@/lib/zoom-events'
import { canShowRightSidebarForView } from '@/lib/right-sidebar-visibility'
import { resolveZoomTarget } from './resolve-zoom-target'
import {
handleSwitchRecentTab,
handleSwitchTab,
handleSwitchTabAcrossAllTypes,
handleSwitchTerminalTab
} from './ipc-tab-switch'
import { ensureSimulatorTab } from '@/lib/ensure-simulator-tab'
import { openMobileEmulatorTab } from '@/lib/open-mobile-emulator-tab'
import {
isManualSimulatorLaunchPending,
rememberPrelaunchedSimulatorSession
} from '@/lib/simulator-launch-coordination'
import {
normalizeAgentStatusPayload,
type AgentStatusClearIpcPayload,
type AgentStatusIpcPayload,
type ParsedAgentStatusPayload
} from '../../../shared/agent-status-types'
import {
resolveAgentStatusIdentity,
shouldSuppressInheritedTerminalStatus
} from '../../../shared/agent-status-identity'
import { isGitRepoKind } from '../../../shared/repo-kind'
import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal'
import { TOGGLE_QUICK_COMMANDS_MENU_EVENT } from '@/lib/quick-commands-menu-events'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status'
import { getEnvironmentSshStateGeneration } from '@/store/slices/runtime-environment-ssh'
import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision'
import { setFitOverride, hydrateOverrides } from '@/lib/pane-manager/mobile-fit-overrides'
import { setDriverForPty, hydrateDrivers } from '@/lib/pane-manager/mobile-driver-state'
import {
hydrateBrowserDrivers,
setDriverForBrowserPage
} from '@/lib/pane-manager/browser-mobile-driver-state'
import { destroyPersistentWebview } from '@/components/browser-pane/webview-registry'
import { rememberLiveBrowserUrl } from '@/components/browser-pane/browser-runtime'
import {
acquireBrowserAutomationVisibility,
releaseBrowserAutomationVisibility
} from '@/components/browser-pane/browser-automation-visibility'
import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge'
import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close'
import { createWorktreeChangeRefreshQueue } from './worktree-change-refresh-queue'
import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events'
import { applyNativeChatLaunchDraftResolved } from '@/runtime/native-chat-launch-draft-runtime-resolution'
import { toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
import { dispatchTerminalSideEffectBatch } from '@/components/terminal-pane/terminal-side-effect-facts-handler'
import { subscribeToUnpairedDeviceAuthNotification } from './unpaired-device-auth-notification'
import {
applyRuntimeEnvironmentSshStateChanged,
hydrateRuntimeEnvironmentSshState,
refreshRuntimeEnvironmentSshTargetMetadata
} from '@/runtime/runtime-environment-ssh-state'
import {
createRuntimeProjectRefreshScheduler,
refreshRuntimeProjectWorktreesAndLineage
} from './runtime-project-refresh-scheduler'
import { createRuntimeClientEventsSync } from './runtime-client-events-sync'
import { detectLanguage } from '@/lib/language-detect'
import { makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization'
import { track } from '@/lib/telemetry'
import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers'
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
import { persistWorkspaceSessionByHost } from '@/lib/workspace-session-host-persistence'
import { verifyTerminalRevealIdentity } from '@/lib/terminal-reveal-identity'
import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name'
import type { RuntimeClientEvent } from '../../../shared/runtime-client-events'
import { applyHostWorktreeTerminalSleepState } from '@/components/terminal-pane/pty-shutdown-exit-deferral'
import {
resolveLegacyWorkerTerminalRecoveryAction,
rollbackLegacyWorkerTerminalSurfaceInStore
} from './legacy-worker-terminal-recovery-event'
import type { AppState } from '../store/types'
import { guardPinnedTabClose, resolvePinnedTabLabel } from '../store/pinned-tab-close-guard'
import {
closeWebRuntimeSessionTab,
createWebRuntimeSessionBrowserTab,
createWebRuntimeSessionTerminal,
isWebRuntimeSessionActive
} from '@/runtime/web-runtime-session'
import {
createFloatingWorkspaceBrowserTab,
createFloatingWorkspaceMarkdownTab,
createFloatingWorkspaceTerminalTab,
isEmptyFloatingWorkspacePanelVisible,
isFloatingWorkspacePanelFocused,
resolveFloatingWorkspaceBrowserWorkspaceId,
switchFloatingWorkspaceTab
} from '@/lib/floating-workspace-terminal-actions'
import {
dispatchFloatingWorkspaceGuestClose,
dispatchFloatingWorkspaceGuestSelectIndex
} from '@/lib/floating-workspace-guest-bridge'
import {
observeAgentHookCompletionForNotification,
resetAgentHookCompletionNotificationCoordinators,
syncAgentHookCompletionNotificationsForStoreUpdate
} from './agent-hook-completion-notifications'
import { shouldSuppressCodexAutoApprovalStatus } from '@/components/terminal-pane/codex-auto-approval-notification-suppression'
import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification'
import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title'
import { titleHasAgentName } from '../../../shared/agent-detection'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route'
import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority'
import { translate } from '@/i18n/i18n'
import { redactKagiSessionToken } from '../../../shared/browser-url'
import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions'
import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode'
import { getConnectionIdFromState } from '@/lib/connection-context'
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
import { acquireDirectSshDetectedWorktreeRefresh } from '@/store/slices/worktrees'
import { createDirectSshWorktreeRefreshScheduler } from './direct-ssh-worktree-refresh-scheduler'
import {
createDirectSshReconnectCoordinator,
type DirectSshPreparationInput,
type DirectSshPreparationReason
} from './direct-ssh-reconnect-coordinator'
import { directSshAuthoritiesEqual } from './direct-ssh-reconnect-tokens'
import { createDirectSshHostHydration } from './direct-ssh-host-hydration'
import { createDirectSshReconnectProductTelemetryAdapter } from '@/lib/direct-ssh-reconnect-product-telemetry'
import {
createRemoteWorkspaceTargetSync,
isDirectSshRemoteWorkspaceApplyInProgress,
type RemoteWorkspaceTargetSync
} from './remote-workspace-target-sync'
import {
registerDirectSshWakeRouting,
routeDirectSshConnectedState,
type DirectSshConnectedStateOrigin
} from './direct-ssh-state-routing'
import { isDirectSshReconnectCoordinatorRoutingEnabled } from './direct-ssh-reconnect-rollout'
function getShortcutPlatform(): NodeJS.Platform {
if (navigator.userAgent.includes('Mac')) {
return 'darwin'
}
if (navigator.userAgent.includes('Windows')) {
return 'win32'
}
return 'linux'
}
const BROWSER_AUTOMATION_BOOTSTRAP_LEASE_MS = 10_000
const browserAutomationBootstrapLeaseByPageId = new Map<string, { token: string; timer: number }>()
function resolveTerminalPresentation(data: {
presentation?: RuntimeTerminalPresentation
activate?: boolean
focus?: boolean
}): RuntimeTerminalPresentation | undefined {
if (data.presentation) {
return data.presentation
}
if (data.focus !== undefined) {
return data.focus ? 'focused' : 'background'
}
if (data.activate === true) {
return 'focused'
}
return undefined
}
function isPinnedSessionTab(store: AppState, worktreeId: string, visibleId: string): boolean {
return (store.unifiedTabsByWorktree?.[worktreeId] ?? []).some(
(tab) => (tab.id === visibleId || tab.entityId === visibleId) && tab.isPinned
)
}
function releaseBrowserAutomationBootstrapLease(browserPageId: string): void {
const existing = browserAutomationBootstrapLeaseByPageId.get(browserPageId)
if (!existing) {
return
}
window.clearTimeout(existing.timer)
releaseBrowserAutomationVisibility(existing.token)
browserAutomationBootstrapLeaseByPageId.delete(browserPageId)
}
function findBrowserPageWorktreeId(store: AppState, browserPageId: string): string | null {
for (const [worktreeId, browserTabs] of Object.entries(store.browserTabsByWorktree)) {
for (const workspace of browserTabs) {
if (
workspace.id === browserPageId ||
workspace.activePageId === browserPageId ||
workspace.pageIds?.includes(browserPageId)
) {
return worktreeId
}
}
}
for (const pages of Object.values(store.browserPagesByWorkspace)) {
const page = pages.find((candidate) => candidate.id === browserPageId)
if (page) {
return page.worktreeId
}
}
return null
}
function acquireBrowserAutomationBootstrapLease(
worktreeId: string | null | undefined,
browserPageId?: string | null
): void {
const store = useAppStore.getState()
const targetWorktreeId =
worktreeId ??
(browserPageId ? findBrowserPageWorktreeId(store, browserPageId) : null) ??
store.activeWorktreeId
if (!targetWorktreeId) {
return
}
requestBackgroundTerminalWorktreeMount({ worktreeId: targetWorktreeId })
let targetBrowserPageId = browserPageId ?? null
if (!targetBrowserPageId) {
const browserTabs = store.browserTabsByWorktree[targetWorktreeId] ?? []
const activeWorkspaceId = store.activeBrowserTabIdByWorktree[targetWorktreeId] ?? null
const workspace =
browserTabs.find((tab) => tab.id === activeWorkspaceId) ?? browserTabs[0] ?? null
targetBrowserPageId =
workspace?.activePageId ?? workspace?.pageIds?.[0] ?? workspace?.id ?? null
}
if (!targetBrowserPageId) {
return
}
releaseBrowserAutomationBootstrapLease(targetBrowserPageId)
const token = acquireBrowserAutomationVisibility(targetBrowserPageId)
const timer = window.setTimeout(() => {
releaseBrowserAutomationBootstrapLease(targetBrowserPageId)
}, BROWSER_AUTOMATION_BOOTSTRAP_LEASE_MS)
browserAutomationBootstrapLeaseByPageId.set(targetBrowserPageId, { token, timer })
}
export { resolveZoomTarget } from './resolve-zoom-target'
const PENDING_AGENT_STATUS_RETRY_MS = 100
const PENDING_AGENT_STATUS_TTL_MS = 15_000
const MAX_PENDING_AGENT_STATUS_EVENTS = 100
// Why: each live status event is its own IPC task, so a multi-agent burst pays
// one full render pass per event; same-task commits batch to ONE pass (React
// flushes external-store updates at the microtask boundary). One frame of
// buffering collapses a burst; the leading event still applies immediately.
const LIVE_AGENT_STATUS_BURST_WINDOW_MS = 33
// Why: mobile driver hydration is async; cap replay so a stuck IPC snapshot can't retain an unbounded startup buffer.
const MAX_PENDING_MOBILE_STATE_EVENTS = 300
// Why: a rename's event burst lags the on-disk move; shield both ids from the deletion diff for a grace window.
const WORKTREE_RENAME_PURGE_GRACE_MS = 20_000
const recentlyRenamedWorktreeIdExpiry = new Map<string, number>()
function isAgentStatusForRecentlyClosedTab(
store: Pick<AppState, 'recentlyClosedAgentStatusTabIds' | 'recentlyRetiredAgentStatusPaneKeys'>,
paneKey: string
): boolean {
const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey)
if (store.recentlyRetiredAgentStatusPaneKeys?.[ownerPaneKey] === true) {
return true
}
const tabId = parsePaneKey(ownerPaneKey)?.tabId
if (!tabId) {
return false
}
return store.recentlyClosedAgentStatusTabIds[tabId] === true
}
function getAuthoritativeDetectedWorktreeIds(state: AppState, repoId: string): Set<string> | null {
const detected = state.detectedWorktreesByRepo[repoId]
if (detected?.authoritative !== true) {
return null
}
return new Set(detected.worktrees.map((worktree) => worktree.id))
}
function getVisibleWorktreeIdsForRepo(state: AppState, repoId: string): Set<string> {
return new Set((state.worktreesByRepo[repoId] ?? []).map((worktree) => worktree.id))
}
function focusTerminalInitiatedTab(tabId: string, leafId?: string | null): void {
if (!focusRuntimeTerminalSurface(tabId, leafId)) {
focusTerminalTabSurface(tabId, leafId)
}
}
function activateTerminalInitiatedWorktree(store: AppState, worktreeId: string): void {
store.setActiveView('terminal')
store.setActiveWorktree(worktreeId)
// Why: CLI/runtime terminal focus is user-visible navigation, so feed both Cmd+J recency and the back/forward stack.
store.markWorktreeVisited(worktreeId)
if (!store.isNavigatingHistory) {
store.recordWorktreeVisit(worktreeId)
}
}
type TerminalSplitDirection = 'horizontal' | 'vertical'
function insertLeafAfterSource(
node: TerminalPaneLayoutNode,
sourceLeafId: string,
newLeafId: string,
direction: TerminalSplitDirection
): { node: TerminalPaneLayoutNode; inserted: boolean } {
if (node.type === 'leaf') {
if (node.leafId !== sourceLeafId) {
return { node, inserted: false }
}
return {
node: {
type: 'split',
direction,
first: node,
second: { type: 'leaf', leafId: newLeafId },
ratio: 0.5
},
inserted: true
}
}
const first = insertLeafAfterSource(node.first, sourceLeafId, newLeafId, direction)
if (first.inserted) {
return { node: { ...node, first: first.node }, inserted: true }
}
const second = insertLeafAfterSource(node.second, sourceLeafId, newLeafId, direction)
if (second.inserted) {
return { node: { ...node, second: second.node }, inserted: true }
}
return { node, inserted: false }
}
function addSplitLeafToLayout(
layout: TerminalLayoutSnapshot | null | undefined,
sourceLeafId: string,
newLeafId: string,
ptyId: string,
direction: TerminalSplitDirection,
title?: string | null,
activateNewLeaf = true
): TerminalLayoutSnapshot {
const root = layout?.root ?? { type: 'leaf', leafId: sourceLeafId }
const existingLeafIds = collectLeafIdsInOrder(root)
const nextActiveLeafId =
activateNewLeaf || !layout?.activeLeafId || !existingLeafIds.includes(layout.activeLeafId)
? newLeafId
: layout.activeLeafId
const nextRoot = existingLeafIds.includes(newLeafId)
? root
: (() => {
const inserted = insertLeafAfterSource(root, sourceLeafId, newLeafId, direction)
if (inserted.inserted) {
return inserted.node
}
return {
type: 'split' as const,
direction,
first: root,
second: { type: 'leaf' as const, leafId: newLeafId },
ratio: 0.5
}
})()
return {
...(layout ?? { root: null, activeLeafId: null, expandedLeafId: null }),
root: nextRoot,
activeLeafId: nextActiveLeafId,
expandedLeafId: null,
ptyIdsByLeafId: {
...layout?.ptyIdsByLeafId,
[newLeafId]: ptyId
},
...(title
? {
titlesByLeafId: {
...layout?.titlesByLeafId,
[newLeafId]: title
}
}
: {})
}
}
function activateExistingLeafInLayout(
layout: TerminalLayoutSnapshot | null | undefined,
leafId: string,
ptyId: string,
title?: string | null
): TerminalLayoutSnapshot | null {
if (!layout?.root || !collectLeafIdsInOrder(layout.root).includes(leafId)) {
return null
}
return {
...layout,
activeLeafId: leafId,
expandedLeafId: null,
ptyIdsByLeafId: {
...layout.ptyIdsByLeafId,
[leafId]: ptyId
},
...(title
? {
titlesByLeafId: {
...layout.titlesByLeafId,
[leafId]: title
}
}
: {})
}
}
export function isRemoteWorkspaceSnapshotApplyInProgress(): boolean {
return isDirectSshRemoteWorkspaceApplyInProgress()
}
type BrowserSessionTabTarget =
| { kind: 'unified-browser'; unifiedTabId: string; workspaceId: string; groupId: string }
| { kind: 'fallback-browser'; workspaceId: string }
type NewWorkspaceShortcutModalData = {
telemetrySource: 'shortcut'
prefilledName?: string
linkedWorkItem?: ReturnType<typeof buildLinearIssueLinkedWorkItem>
}
export function buildNewWorkspaceShortcutModalData(
state: Pick<AppState, 'activeView' | 'taskPageData'>
): NewWorkspaceShortcutModalData {
const linearIssue =
state.activeView === 'tasks' ? (state.taskPageData.openLinearIssue ?? null) : null
if (!linearIssue) {
return { telemetrySource: 'shortcut' }
}
return {
telemetrySource: 'shortcut',
prefilledName: getLinearIssueWorkspaceName(linearIssue),
// Why: Cmd+N from a Linear issue mirrors its Start-workspace action, else the agent launches without source context.
linkedWorkItem: buildLinearIssueLinkedWorkItem(linearIssue)
}
}
export function openNewWorkspaceFromShortcut(
state: Pick<AppState, 'activeModal' | 'activeView' | 'taskPageData' | 'openModal'>
): void {
if (state.activeModal === 'new-workspace-composer') {
return
}
state.openModal('new-workspace-composer', buildNewWorkspaceShortcutModalData(state))
}
export function resolveBrowserSessionTabTarget(
state: Pick<AppState, 'browserTabsByWorktree' | 'unifiedTabsByWorktree'>,
worktreeId: string,
tabId: string
): BrowserSessionTabTarget | null {
const tab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find((item) => item.id === tabId)
if (tab?.contentType === 'browser') {
return {
kind: 'unified-browser',
unifiedTabId: tab.id,
workspaceId: tab.entityId,
groupId: tab.groupId
}
}
const fallbackBrowser = (state.browserTabsByWorktree[worktreeId] ?? []).find(
(workspace) => workspace.id === tabId
)
return fallbackBrowser ? { kind: 'fallback-browser', workspaceId: fallbackBrowser.id } : null
}
function isRuntimeEnvironmentActive(): boolean {
return Boolean(useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim())
}
/** Remount panes that parked with no PTY while their owning host was unknown. */
function remountTerminalTabsAwaitingHostHydration(): void {
const store = useAppStore.getState()
for (const tabId of getTabIdsAwaitingHostHydrationRemount(store)) {
store.remountTerminalTabForRecovery(tabId)
}
}
function getActiveRuntimeEnvironmentId(): string | null {
return useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim() || null
}
function getRuntimeClientEventEnvironmentIds(): string[] {
const state = useAppStore.getState()
const ids = new Set<string>()
const activeEnvironmentId = getActiveRuntimeEnvironmentId()
if (activeEnvironmentId) {
ids.add(activeEnvironmentId)
}
for (const environment of state.runtimeEnvironments ?? []) {
const status = state.runtimeStatusByEnvironmentId?.get(environment.id)
if (status?.status) {
ids.add(environment.id)
}
}
return [...ids]
}
function getReachableRuntimeEnvironmentIds(): string[] {
const state = useAppStore.getState()
const ids: string[] = []
for (const [environmentId, status] of state.runtimeStatusByEnvironmentId ?? []) {
if (status?.status) {
ids.push(environmentId)
}
}
return ids
}
export function buildRuntimeClientEventEnvironmentKey(environmentIds: string[]): string {
return [...new Set(environmentIds)]
.sort()
.map(
(environmentId) =>
`${environmentId}:${getRuntimeEnvironmentConnectionGeneration(environmentId)}:${getEnvironmentSshStateGeneration(environmentId)}:${getRuntimeEnvironmentRevision(environmentId) ?? 'unknown'}`
)
.join('\u0000')
}
/** Ids in `next` not in `previous` — environments that just became connected (exported to unit-test on-connect discovery). */
export function getNewlyConnectedRuntimeEnvironmentIds(
previous: readonly string[],
next: readonly string[]
): string[] {
const known = new Set(previous)
return [...new Set(next)].filter((environmentId) => !known.has(environmentId))
}
/** Ids in `previous` not in `next` — environments whose transport was just observed down. */
export function getNewlyDisconnectedRuntimeEnvironmentIds(
previous: readonly string[],
next: readonly string[]
): string[] {
return getNewlyConnectedRuntimeEnvironmentIds(next, previous)
}
export function getRuntimeProjectRefreshEnvironmentIds(args: {
previousDesired: readonly string[]
nextDesired: readonly string[]
previousReachable: readonly string[]
nextReachable: readonly string[]
}): string[] {
return [
...new Set([
...getNewlyConnectedRuntimeEnvironmentIds(args.previousDesired, args.nextDesired),
...getNewlyConnectedRuntimeEnvironmentIds(args.previousReachable, args.nextReachable)
])
]
}
function getWorktreeRuntimeEnvironmentId(worktreeId: string | null | undefined): string | null {
return getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId)
}
export function useIpcEvents(): void {
useEffect(() => {
const unsubs: (() => void)[] = []
const reconnectAuthorityByTarget = new Map<string, DirectSshAuthority>()
const authorityReconciliationDeadlines = new Set<{
timer: ReturnType<typeof setTimeout>
settle: () => void
}>()
let directSshEffectStopped = false
const currentDirectSshAuthority = (targetId: string): DirectSshAuthority | null => {
const state = useAppStore.getState().sshConnectionStates?.get(targetId)
if (
state?.status !== 'connected' ||
state.targetId !== targetId ||
!state.providerEpoch ||
state.connectionGeneration === undefined
) {
return null
}
return {
targetId,
providerEpoch: state.providerEpoch,
connectionGeneration: state.connectionGeneration
}
}
const scheduler = createDirectSshWorktreeRefreshScheduler({
startAttempt: (key) => {
const acquired = acquireDirectSshDetectedWorktreeRefresh(useAppStore, {
repoId: key.repoId,
executionHostId: key.executionHostId,
authority: {
targetId: key.targetId,
providerEpoch: key.providerEpoch,
connectionGeneration: key.connectionGeneration
},
requireAuthoritative: key.authorityRequirement === 'required'
})
return {
providerRequestId: acquired.providerRequestId,
result: acquired.result.then((result) => acquired.merge(result)),
cancel: acquired.release
}
}
})
const hostHydration = createDirectSshHostHydration({
store: useAppStore,
isCurrentAuthority: (authority) =>
directSshAuthoritiesEqual(currentDirectSshAuthority(authority.targetId), authority),
listRepos: (authority) => {
const executionHostId = toSshExecutionHostId(authority.targetId)
return (
window.api.repos.listForExecutionHost?.({
executionHostId,
expectedAuthority: authority
}) ??
Promise.resolve({
authoritative: false,
executionHostId,
reason: 'unavailable' as const
})
)
},
listLineage: (authority) => {
const executionHostId = toSshExecutionHostId(authority.targetId)
return (
window.api.worktrees.listLineageForHost?.({
executionHostId,
expectedAuthority: authority
}) ??
Promise.resolve({
authoritative: false,
executionHostId,
reason: 'unavailable' as const
})
)
}
})
type DirectSshTerminalActions = Partial<
Pick<AppState, 'invalidateStaleDirectSshTargetPtyBindings' | 'retryDirectSshTargetPanes'>
>
const directSshTerminalActions = (): DirectSshTerminalActions =>
useAppStore.getState() as DirectSshTerminalActions
let remoteWorkspaceTargetSync: RemoteWorkspaceTargetSync | null = null
const reconnectCoordinator = createDirectSshReconnectCoordinator({
scheduler,
isCurrentConnectedAuthority: (authority) =>
directSshAuthoritiesEqual(currentDirectSshAuthority(authority.targetId), authority),
capturePreparationInput: hostHydration.capturePreparationInput,
readHostScopedLineage: hostHydration.readHostScopedLineage,
invalidateStaleTerminalBindings: (authority) =>
directSshTerminalActions().invalidateStaleDirectSshTargetPtyBindings?.(authority) ?? 0,
retryTargetPanes: (authority) =>
directSshTerminalActions().retryDirectSshTargetPanes?.(authority) ?? 0,
finalizeHydratedTerminalPanes: (authority) =>
directSshTerminalActions().retryDirectSshTargetPanes?.(authority) ?? 0,
correctUnboundTerminalPanes: (authority) =>
directSshTerminalActions().retryDirectSshTargetPanes?.(authority) ?? 0,
syncRemoteWorkspaceAfterConnect: (token) =>
remoteWorkspaceTargetSync?.syncAfterConnect(token),
onTelemetry: createDirectSshReconnectProductTelemetryAdapter()
})
const remoteWorkspaceApi = window.api.remoteWorkspace
if (remoteWorkspaceApi) {
remoteWorkspaceTargetSync = createRemoteWorkspaceTargetSync({
store: useAppStore,
remoteWorkspace: remoteWorkspaceApi,
getCurrentAuthority: currentDirectSshAuthority,
isPreparationTokenCurrent: hostHydration.isPreparationTokenCurrent,
capturePreparationInput: (authority, reason, snapshotRevision) =>
hostHydration.capturePreparationInput(authority, reason, snapshotRevision),
prepareOnly: reconnectCoordinator.prepareOnly,
finalizeHydratedTerminals: (authority) =>
directSshAuthoritiesEqual(reconnectAuthorityByTarget.get(authority.targetId), authority)
? reconnectCoordinator.finalizeHydratedTerminals(authority)
: 0
})
}
const prepareAndSyncDirectSshTarget = async (
authority: DirectSshAuthority,
reason: DirectSshPreparationReason,
options?: { authorityAlreadyReplaced?: boolean }
): Promise<void> => {
try {
if (!options?.authorityAlreadyReplaced) {
reconnectCoordinator.replaceAuthority(authority)
}
const input: DirectSshPreparationInput | null = await hostHydration.capturePreparationInput(
authority,
reason
)
if (!input) {
return
}
const prepared = await reconnectCoordinator.prepareOnly(input)
if (prepared.token && hostHydration.isPreparationTokenCurrent(prepared.token)) {
await remoteWorkspaceTargetSync?.syncAfterConnect(prepared.token)
}
} catch (error) {
if (directSshAuthoritiesEqual(currentDirectSshAuthority(authority.targetId), authority)) {
useAppStore.getState().setRemoteWorkspaceSyncStatus(authority.targetId, {
phase: 'error',
message: error instanceof Error ? error.message : 'Workspace sync failed'
})
}
}
}
const backgroundSleepingAgentWakeDispatcher = createBackgroundSleepingAgentWakeDispatcher()
unsubs.push(backgroundSleepingAgentWakeDispatcher.dispose)
type PendingAgentStatusEvent = {
data: AgentStatusIpcPayload
firstSeenAt: number
replay: boolean
}
type AgentStatusApplyResult = 'applied' | 'pending' | 'dropped'
const pendingAgentStatusEvents: PendingAgentStatusEvent[] = []
const transientClearWatermarkByConnectionId = new Map<string, number>()
let agentStatusEffectDisposed = false
let pendingAgentStatusRetryTimer: ReturnType<typeof setTimeout> | null = null
// Why: setAgentStatus notifies synchronously and re-enters this flush mid-drain; guard re-entrancy (crash 9fc89529).
let isFlushingAgentStatuses = false
const liveAgentStatusBurstQueue: AgentStatusIpcPayload[] = []
let liveAgentStatusBurstTimer: ReturnType<typeof setTimeout> | null = null
let lastLiveAgentStatusApplyAt = 0
unsubs.push(attachMobileMarkdownBridge())
const handleWorktreesChanged = async (
repoId: string,
renamed?: { oldWorktreeId: string; newWorktreeId: string },
options?: { forceLocalOwner?: boolean; executionHostId?: ExecutionHostId }
): Promise<void> => {
const localRefreshStartedWithRuntime =
options?.forceLocalOwner === true && isRuntimeEnvironmentActive()
// Why: capture active-ness before migration moves the pointer; re-key maps before the diff so a rename isn't a deletion.
const renamedWasActive =
renamed != null && useAppStore.getState().activeWorktreeId === renamed.oldWorktreeId
if (renamed) {
// Shield both ids from the deletion diff across the rename's event burst — the worktree list lags the on-disk move.
const expiry = Date.now() + WORKTREE_RENAME_PURGE_GRACE_MS
recentlyRenamedWorktreeIdExpiry.set(renamed.oldWorktreeId, expiry)
recentlyRenamedWorktreeIdExpiry.set(renamed.newWorktreeId, expiry)
useAppStore.getState().migrateWorktreeIdentity(renamed.oldWorktreeId, renamed.newWorktreeId)
}
// Why: diff before/after fetch to catch out-of-band deletions and purge worktree state, else zombie ptyId entries leak (design §2c, §4.4).
const state = useAppStore.getState()
const before =
getAuthoritativeDetectedWorktreeIds(state, repoId) ??
getVisibleWorktreeIdsForRepo(state, repoId)
await state.fetchWorktrees(
repoId,
options?.forceLocalOwner
? { forceLocalOwner: true }
: options?.executionHostId
? {
executionHostId: options.executionHostId,
suppressRemoteLineageRefresh: true
}
: undefined
)
await useAppStore
.getState()
.fetchWorktreeLineage(
options?.forceLocalOwner
? { forceLocalOwner: true }
: options?.executionHostId
? { executionHostId: options.executionHostId }
: undefined
)
// Why: an id change unmounts the active pane; re-activate so the tab reconciles, else it vanishes until re-select.
if (renamedWasActive && renamed) {
useAppStore.getState().setActiveWorktree(renamed.newWorktreeId)
}
// Sweep expired rename-grace entries before any early return, else forced-local
// (or non-authoritative) events let the map grow for the session.
const now = Date.now()
for (const [id, expiry] of recentlyRenamedWorktreeIdExpiry) {
if (expiry <= now) {
recentlyRenamedWorktreeIdExpiry.delete(id)
}
}
// Why: the deletion diff below is repo-wide, but a forced-local scan overlapping
// a runtime cannot prove remote absence (legacy runtime rows may lack hostId).
// fetchWorktrees still purges removed local rows host-scoped; accepted gap: the
// workspace-space entry survives until the next local-only rescan.
if (
options?.forceLocalOwner &&
(localRefreshStartedWithRuntime || isRuntimeEnvironmentActive())
) {
return
}
const afterState = useAppStore.getState()
const after = getAuthoritativeDetectedWorktreeIds(afterState, repoId)
if (!after) {
return
}
const removed: string[] = []
for (const id of before) {
if (after.has(id)) {
continue
}
// A recently renamed worktree's old/new id isn't a deletion — its state moved to the new id; the list just lags.
const graceExpiry = recentlyRenamedWorktreeIdExpiry.get(id)
if (graceExpiry != null && graceExpiry > now) {
continue
}
removed.push(id)
}
if (removed.length > 0) {
console.warn(
`[worktree-purge] diff-based purge removing state for ${removed.length} worktree(s):`,
removed
)
afterState.purgeWorktreeTerminalState(removed)
afterState.removeWorkspaceSpaceWorktrees(removed)
}
}
const worktreeChangeRefreshQueue = createWorktreeChangeRefreshQueue(handleWorktreesChanged)
unsubs.push(worktreeChangeRefreshQueue.dispose)
const activateNotifiedWorktree = async (
{
repoId,
worktreeId,
setup,
startup,
defaultTabs
}: Extract<RuntimeClientEvent, { type: 'activateWorktree' }>,
options: { allowRuntimeEnvironment: boolean }
): Promise<void> => {
if (!options.allowRuntimeEnvironment && isRuntimeEnvironmentActive()) {
// Why: local CLI worktree events carry local ids; runtime activation comes via the remote stream, allowed separately.
return
}
const existedBeforeFetch = Boolean(useAppStore.getState().getKnownWorktreeById(worktreeId))
// Why: fetch first so activation can resolve the CLI-created worktree; it arrived from main, not yet in renderer state.
await useAppStore.getState().fetchWorktrees(repoId)
const existsAfterFetch = Boolean(useAppStore.getState().getKnownWorktreeById(worktreeId))
// Why: use the canonical activation path so the CLI switch records a back/forward visit, or the nav buttons ignore it.
activateAndRevealWorktree(worktreeId, {
...(setup ? { setup } : {}),
...(startup ? { startup } : {}),
...(defaultTabs ? { defaultTabs } : {}),
...(!existedBeforeFetch && existsAfterFetch ? { sidebarRevealBehavior: 'auto' } : {}),
// Why: this activation came from the host runtime stream; echoing it back can create a selection loop.
notifyHostRuntime: false
})
}
const ensureRuntimeEventRepoKnown = async (
environmentId: string,
repoId: string
): Promise<void> => {
if ((useAppStore.getState().repos ?? []).some((repo) => repo.id === repoId)) {
return
}
await useAppStore.getState().fetchRuntimeEnvironmentRepos(environmentId)
}
const runtimeProjectRefreshScheduler = createRuntimeProjectRefreshScheduler({
refresh: async (environmentId) => {
// Why: project events can reveal target CRUD, but known target states already arrive by push.
void refreshRuntimeEnvironmentSshTargetMetadata(environmentId).catch(() => {})
const repos = await useAppStore.getState().fetchRuntimeEnvironmentRepos(environmentId)
await refreshRuntimeProjectWorktreesAndLineage(
environmentId,
repos,
(repoId, options) => useAppStore.getState().fetchWorktrees(repoId, options),
(options) => useAppStore.getState().fetchWorktreeLineage(options)
)
},
onError: (error) => {
console.error('Failed to refresh runtime projects:', error)
}
})
// Assigned later (by the ssh.onStateChanged wiring); safe because subscriptions attach asynchronously.
let handleSshStateChangedEvent: ((data: { targetId: string; state: unknown }) => void) | null =
null
const handleRuntimeClientEvent = (
environmentId: string,
event: RuntimeClientEvent,
generation = getEnvironmentSshStateGeneration(environmentId)
): void => {
if (event.type === 'worktreeTerminalSleepState') {
applyHostWorktreeTerminalSleepState(environmentId, event)
return
}
if (event.type === 'terminalSideEffects') {
dispatchTerminalSideEffectBatch({
...event.batch,
ptyId: toRemoteRuntimePtyId(event.batch.ptyId, environmentId)
})
return
}
if (event.type === 'nativeChatLaunchDraftResolved') {
applyNativeChatLaunchDraftResolved(useAppStore.getState(), event)
return
}
if (event.type === 'reposChanged') {
runtimeProjectRefreshScheduler.request(environmentId)
return
}
if (event.type === 'sshStateChanged') {
applyRuntimeEnvironmentSshStateChanged(
environmentId,
event.targetId,
event.state,
generation
)
return
}
if (event.type === 'worktreesChanged') {
void ensureRuntimeEventRepoKnown(environmentId, event.repoId).then(() =>
worktreeChangeRefreshQueue.enqueue({
repoId: event.repoId,
executionHostId: toRuntimeExecutionHostId(environmentId)
})
)
return
}
if (event.type === 'linearLinkedIssueUpdated') {
void useAppStore
.getState()
.refreshLinearIssue(event.identifier, event.workspaceId)
.catch((error) => {
console.error('Failed to refresh updated Linear issue:', error)
})
return
}
void ensureRuntimeEventRepoKnown(environmentId, event.repoId)
.then(() => activateNotifiedWorktree(event, { allowRuntimeEnvironment: true }))
.catch((error) => {
console.error('Failed to activate runtime-created worktree:', error)
})
}
const runtimeClientEventsSync = createRuntimeClientEventsSync({
getDesiredEnvironmentIds: getRuntimeClientEventEnvironmentIds,
getSubscriptionKey: (environmentId) => buildRuntimeClientEventEnvironmentKey([environmentId]),
subscribe: (environmentId, onEvent, onError) => {
const sshGeneration = getEnvironmentSshStateGeneration(environmentId)
const runtimeGeneration = getRuntimeEnvironmentConnectionGeneration(environmentId)
const runtimeRevision = getRuntimeEnvironmentRevision(environmentId)
return subscribeRuntimeClientEvents(
environmentId,
(event) => {
if (
sshGeneration === getEnvironmentSshStateGeneration(environmentId) &&
runtimeGeneration === getRuntimeEnvironmentConnectionGeneration(environmentId) &&
runtimeRevision === getRuntimeEnvironmentRevision(environmentId)
) {
onEvent(event)
}
},
onError,
() => {
// Why: events during a transport gap are lost; a quick reconnect won't flip unreachable, so refetch (#7970).
runtimeProjectRefreshScheduler.request(environmentId)
// Why: sshStateChanged events during the transport gap are lost, so downgrade the possibly-stale bucket, then refetch.
useAppStore.getState().markEnvironmentSshStateStale(environmentId)
void hydrateRuntimeEnvironmentSshState(environmentId, { force: true }).catch(() => {})
}
)
},
onEvent: handleRuntimeClientEvent
})
runtimeClientEventsSync.sync()
// Why: no on-connect repo fetch (PR #2); seed discovery for connected runtimes or remote projects hide until Add-Project.
let runtimeClientEventEnvironmentIds = getRuntimeClientEventEnvironmentIds()
for (const environmentId of runtimeClientEventEnvironmentIds) {
runtimeProjectRefreshScheduler.request(environmentId)
}
let runtimeClientEventEnvironmentKey = buildRuntimeClientEventEnvironmentKey(
runtimeClientEventEnvironmentIds
)
let reachableRuntimeEnvironmentIds = getReachableRuntimeEnvironmentIds()
let reachableRuntimeEnvironmentKey = buildRuntimeClientEventEnvironmentKey(
reachableRuntimeEnvironmentIds
)
const unsubscribeRuntimeEnvironmentStore = useAppStore.subscribe(() => {
const nextEnvironmentIds = getRuntimeClientEventEnvironmentIds()
const nextKey = buildRuntimeClientEventEnvironmentKey(nextEnvironmentIds)
const nextReachableEnvironmentIds = getReachableRuntimeEnvironmentIds()
const nextReachableKey = buildRuntimeClientEventEnvironmentKey(nextReachableEnvironmentIds)
if (
nextKey === runtimeClientEventEnvironmentKey &&
nextReachableKey === reachableRuntimeEnvironmentKey
) {
return
}
for (const environmentId of getRuntimeProjectRefreshEnvironmentIds({
previousDesired: runtimeClientEventEnvironmentIds,
nextDesired: nextEnvironmentIds,
previousReachable: reachableRuntimeEnvironmentIds,
nextReachable: nextReachableEnvironmentIds
})) {
runtimeProjectRefreshScheduler.request(environmentId)
}
for (const environmentId of getNewlyDisconnectedRuntimeEnvironmentIds(
reachableRuntimeEnvironmentIds,
nextReachableEnvironmentIds
)) {
// No-op when the environment has no SSH bucket (e.g. web client).
useAppStore.getState().markEnvironmentSshStateStale(environmentId)
}
runtimeClientEventEnvironmentIds = nextEnvironmentIds
runtimeClientEventEnvironmentKey = nextKey
reachableRuntimeEnvironmentIds = nextReachableEnvironmentIds
reachableRuntimeEnvironmentKey = nextReachableKey
runtimeClientEventsSync.sync()
})
unsubs.push(runtimeClientEventsSync.stop)
unsubs.push(runtimeProjectRefreshScheduler.stop)
unsubs.push(
window.api.repos.onChanged(() => {
const state = useAppStore.getState()
if (isRuntimeEnvironmentActive()) {
// Why: the all-host sidebar shows local repos even under a runtime; refresh the local slice, keep runtime slices.
void (async () => {
await state.fetchReposForAllHosts()
await state.fetchProjectGroupsForAllHosts()
await state.fetchFolderWorkspacesForAllHosts()
remountTerminalTabsAwaitingHostHydration()
})()
return
}
void state.fetchProjectGroups()
void state.fetchFolderWorkspaces()
void state.fetchRepos().then(remountTerminalTabsAwaitingHostHydration)
})
)
unsubs.push(
window.api.worktrees.onChanged(
async (data: {
repoId: string
renamed?: { oldWorktreeId: string; newWorktreeId: string }
}) => {
// Why: preserve this event's local origin across queue delays and runtime
// focus changes; otherwise an unbound repo can refresh from the wrong host.
// A folder rename changes the worktree id; handleWorktreesChanged re-keys
// state and shields it from the deletion diff.
worktreeChangeRefreshQueue.enqueue({
...data,
forceLocalOwner: true
})
}
)
)
if (window.api.worktrees.onHeadIdentitiesChanged) {
unsubs.push(
window.api.worktrees.onHeadIdentitiesChanged((data) => {
if (isRuntimeEnvironmentActive()) {
// Why: local worktree events carry local repo ids; the local-pinned list
// refresh (onChanged) covers local rows while a runtime is active.
return
}
const state = useAppStore.getState()
applyWorktreeHeadIdentities(data, {
getWorktreesForRepo: (repoId) => state.worktreesByRepo[repoId],
updateWorktreeGitIdentity: state.updateWorktreeGitIdentity
})
})
)
}
unsubs.push(
window.api.worktrees.onBaseStatus((event) => {
if (isRuntimeEnvironmentActive()) {
return
}
useAppStore.getState().updateWorktreeBaseStatus(event)
})
)
unsubs.push(
window.api.worktrees.onRemoteBranchConflict((event) => {
if (isRuntimeEnvironmentActive()) {
return
}
useAppStore.getState().updateWorktreeRemoteBranchConflict(event)
})
)
// Why: route main's two-phase creation progress to each pending entry by correlation id (?. guards stale preload).
unsubs.push(
window.api.worktrees.onCreateProgress?.((data) => {
if (!data.creationId) {
return
}
useAppStore.getState().updatePendingWorktreeCreation(data.creationId, { phase: data.phase })
}) ?? (() => {})
)
if (window.api.gh?.onPRRefreshEvent) {
unsubs.push(
window.api.gh.onPRRefreshEvent((event) => {
useAppStore.getState().applyGitHubPRRefreshEvent(event)
})
)
}
unsubs.push(
window.api.ui.onOpenSettings(() => {
useAppStore.getState().openSettingsPage()
})
)
// Why: a tray "Settings…" click can fire before this attaches; consume any queued intent (?. guards stale preload).
void window.api.ui
.consumePendingOpenSettings?.()
.then((open) => {
if (open) {
useAppStore.getState().openSettingsPage()
}
})
.catch(() => {})
unsubs.push(
window.api.ui.onOpenSetupGuide?.(() => {
useAppStore.getState().openModal('setup-guide', { telemetrySource: 'help_menu' })
}) ?? (() => {})
)
// Why: a phone stuck in a silent 4001 auth loop (lost device registry) reads as
// "phone won't connect" with no clue on either end; main throttles to once per session.
unsubs.push(
subscribeToUnpairedDeviceAuthNotification(window.api.mobile, () => {
toast.warning(
translate(
'auto.hooks.useIpcEvents.ef223fbb6b',
'A device tried to connect but is not paired'
),
{
id: 'unpaired-device-auth-failure',
description: translate(
'auto.hooks.useIpcEvents.11992d0337',
'If this was your phone or another Orca client, re-pair it from Settings → Mobile.'
),
// Why: main emits this recovery path once per session, so it must remain visible until acted on or dismissed.
duration: Infinity,
action: {
label: translate('auto.hooks.useIpcEvents.6573cfe955', 'Open Mobile Settings'),
onClick: () => {
const store = useAppStore.getState()
store.openSettingsTarget({ pane: 'mobile', repoId: null })
store.openSettingsPage()
}
}
}
)
})
)
unsubs.push(
window.api.ui.onOpenFeatureTour(() => {
useAppStore.getState().openModal('feature-wall', { source: 'help_menu' })
})
)
// Why: View > Appearance toggles settings in main and broadcasts; merge into the store for an immediate re-render.
unsubs.push(
window.api.settings.onChanged((updates) => {
const store = useAppStore.getState()
if (!store.settings) {
return
}
useAppStore.setState({
settings: {
...store.settings,
...updates,
notifications: {
...store.settings.notifications,
...updates.notifications
}
}
})
})
)
// Why: UI view-state is shared with mobile via ui.set; re-hydrate so mobile changes reflect live in the desktop sidebar.
unsubs.push(
window.api.ui.onStateChanged((ui) => {
useAppStore.getState().hydratePersistedUI(ui, 'sync')
})
)
if (window.api.keybindings) {
unsubs.push(
window.api.keybindings.onChanged((snapshot) => {
useAppStore.getState().setKeybindingSnapshot(snapshot)
})
)
}
unsubs.push(
window.api.ui.onToggleLeftSidebar(() => {
useAppStore.getState().toggleSidebar()
})
)
unsubs.push(
window.api.ui.onToggleRightSidebar(() => {
const store = useAppStore.getState()
if (!canShowRightSidebarForView(store.activeView)) {
return
}
store.toggleRightSidebar()
})
)
unsubs.push(
window.api.ui.onToggleWorktreePalette(() => {
const store = useAppStore.getState()
if (store.activeModal === 'worktree-palette') {
store.closeModal()
return
}
store.openModal('worktree-palette')
})
)
unsubs.push(
window.api.ui.onToggleFloatingTerminal(() => {
window.dispatchEvent(new CustomEvent(TOGGLE_FLOATING_TERMINAL_EVENT))
})
)
if (window.api.ui.onTerminalShortcutCaptured) {
unsubs.push(
window.api.ui.onTerminalShortcutCaptured(({ actionId }) => {
showTerminalShortcutCaptureNotification({
actionId,
platform: getShortcutPlatform(),
keybindings: useAppStore.getState().keybindings
})
})
)
}
unsubs.push(
window.api.ui.onOpenQuickOpen(() => {
const store = useAppStore.getState()
if (store.activeView === 'terminal' && store.activeWorktreeId !== null) {
store.openModal('quick-open')
}
})
)
unsubs.push(
window.api.ui.onToggleQuickCommandsMenu(() => {
window.dispatchEvent(new CustomEvent(TOGGLE_QUICK_COMMANDS_MENU_EVENT))
})
)
unsubs.push(
window.api.ui.onOpenNewWorkspace(() => {
const store = useAppStore.getState()
openNewWorkspaceFromShortcut(store)
})
)
if (window.api.ui.onDeleteCurrentWorkspace) {
unsubs.push(
window.api.ui.onDeleteCurrentWorkspace(() => {
const store = useAppStore.getState()
if (
store.activeModal !== 'none' ||
store.activeView !== 'terminal' ||
!store.activeWorktreeId
) {
return
}
runWorktreeDelete(store.activeWorktreeId)
})
)
}
if (window.api.ui.onOpenWorkspaceBoard) {
unsubs.push(
window.api.ui.onOpenWorkspaceBoard(() => {
const store = useAppStore.getState()
if (store.activeView === 'settings') {
return
}
store.setSidebarOpen(true)
window.dispatchEvent(new CustomEvent(OPEN_WORKSPACE_BOARD_EVENT))
})
)
}
unsubs.push(
window.api.ui.onOpenTasks(() => {
const store = useAppStore.getState()
if (store.activeView === 'settings' || !store.repos.some((repo) => isGitRepoKind(repo))) {
return
}
store.openTaskPage()
})
)
unsubs.push(
window.api.ui.onJumpToWorktreeIndex((index) => {
const store = useAppStore.getState()
// Why: while Cmd+J is open the digit chord means "activate recent row N" — main already
// preventDefault'd it, so routing it here keeps digits out of the palette's search input.
if (store.activeModal === 'worktree-palette') {
emitCmdJRowIndexJump(index)
return
}
if (store.activeView !== 'terminal') {
return
}
const visibleIds = getVisibleWorktreeIds()
if (index < visibleIds.length) {
activateAndRevealWorkspace(visibleIds[index])
}
})
)
unsubs.push(
window.api.ui.onJumpToTabIndex((index) => {
// Why: dropped while Cmd+J is open — never switch tabs behind the overlay.
if (useAppStore.getState().activeModal === 'worktree-palette') {
return
}
activateTabNumberShortcut(index)
})
)
unsubs.push(
window.api.ui.onWorktreeHistoryNavigate((direction) => {
const store = useAppStore.getState()
// Why: mirror button visibility — worktree history nav is only meaningful in the terminal view, so no-op elsewhere.
if (store.activeView !== 'terminal') {
return
}
if (direction === 'back') {
store.goBackWorktree()
} else {
store.goForwardWorktree()
}
})
)
unsubs.push(
window.api.ui.onToggleStatusBar(() => {
const store = useAppStore.getState()
store.setStatusBarVisible(!store.statusBarVisible)
})
)
unsubs.push(
window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup, startup, defaultTabs }) => {
void activateNotifiedWorktree(
{
type: 'activateWorktree',
repoId,
worktreeId,
...(setup ? { setup } : {}),
...(startup ? { startup } : {}),
...(defaultTabs ? { defaultTabs } : {})
},
{ allowRuntimeEnvironment: false }
).catch((error) => {
console.error('Failed to activate CLI-created worktree:', error)
})
})
)
unsubs.push(
window.api.ui.onCreateTerminal(
({
requestId,
worktreeId,
command,
cwd,
env,
launchConfig,
resumeProviderSession,
launchToken,
launchAgent,
viewMode,
title,
ptyId,
activate,
focus,
presentation,
surfaceOwner,
tabId,
leafId,
splitFromLeafId,
splitDirection,
splitTelemetrySource
}) => {
try {
const store = useAppStore.getState()
const terminalPresentation = resolveTerminalPresentation({
presentation,
activate,
focus
})
const shouldActivate = terminalPresentation === 'focused'
const shouldSurfaceOwner =
terminalPresentation !== 'background' && surfaceOwner !== false
if (shouldActivate) {
activateTerminalInitiatedWorktree(store, worktreeId)
}
const worktreeTabs = store.tabsByWorktree[worktreeId] ?? []
// Why: a split pane revealed from mobile is only bound in the persisted
// layout until its pane mounts; missing it minted a duplicate tab (#10486).
const ownership = ptyId
? resolveTerminalTabPtyOwnership(
store,
worktreeId,
ptyId,
tabId !== undefined ? { preferTabId: tabId } : {}
)
: { kind: 'none' as const }
const existingTab =
ownership.kind === 'owned'
? worktreeTabs.find((candidate) => candidate.id === ownership.tabId)
: undefined
const isSplitReveal = Boolean(ptyId && tabId && leafId && splitFromLeafId)
const splitTargetTab = isSplitReveal
? worktreeTabs.find((candidate) => candidate.id === tabId)
: undefined
if (isSplitReveal && !splitTargetTab) {
throw new Error(`Terminal tab ${tabId} not found`)
}
const reusedTab = existingTab ?? splitTargetTab
const tab =
reusedTab ??
(ptyId
? store.createTab(worktreeId, undefined, undefined, {
initialPtyId: ptyId,
activate: shouldActivate,
...(launchAgent
? {
launchAgent,
// Why: a paired client resolved explicit mode before PTY materialization; only omitted mode uses host defaults.
...(viewMode
? { viewMode }
: initialAgentTabViewModeProps(store.settings, {
agent: launchAgent,
nativeChatTranscriptIsLocalReadable:
isNativeChatTranscriptLocalReadable(
getConnectionIdFromState(store, worktreeId)
)
}))
}
: {}),
...(cwd ? { startupCwd: cwd } : {}),
// Why: CLI-spawned PTYs bake the pane key into env; adopt the same tab id so hook-event attribution keeps working.
...(tabId !== undefined ? { id: tabId } : {})
})
: store.createTab(
worktreeId,
undefined,
undefined,
shouldActivate
? cwd
? { startupCwd: cwd }
: undefined
: {
activate: false,
recordInteraction: false,
...(cwd ? { startupCwd: cwd } : {})
}
))
// Why: a reused tab whose id differs from the hint breaks the PTY's baked-in paneKey attribution; warn during dev.
if (tabId !== undefined && tab.id !== tabId) {
console.warn(
`[onCreateTerminal] tabId hint ${tabId} ignored for ptyId ${ptyId}; existing tab ${tab.id} adopted instead (hook attribution will degrade for this terminal)`
)
}
if (shouldActivate) {
store.setActiveTabType('terminal')
store.setActiveTab(tab.id)
}
if (shouldSurfaceOwner) {
store.revealWorktreeInSidebar(worktreeId)
focusTerminalInitiatedTab(tab.id, leafId)
}
// Why: only stamp the runtime title on fresh tabs; reused tabs may have a user customTitle it would overwrite on focus.
if (title && !reusedTab) {
store.setTabCustomTitle(tab.id, title, { recordInteraction: false })
}
if (leafId && ptyId) {
const launchPaneKey = tryMakePaneKey(tab.id, leafId)
if (launchConfig) {
if (launchPaneKey) {
store.registerAgentLaunchConfig(launchPaneKey, launchConfig, {
...(launchAgent ? { agentType: launchAgent } : {}),
...(launchToken ? { launchToken } : {}),
tabId: tab.id,
leafId
})
}
} else if (!splitFromLeafId && launchPaneKey) {
store.clearAgentLaunchConfig(launchPaneKey)
}
if (splitFromLeafId) {
// Why: runtime split PTYs already carry the parent tab's paneKey, so reuse the tab instead of minting a collision tab.
store.updateTabPtyId(tab.id, ptyId)
const existingLayout = store.terminalLayoutsByTabId?.[tab.id]
const sourcePtyId = existingLayout?.ptyIdsByLeafId?.[splitFromLeafId]
store.setTabLayout(
tab.id,
addSplitLeafToLayout(
existingLayout,
splitFromLeafId,
leafId,
ptyId,
splitDirection ?? 'horizontal',
title,
shouldActivate
)
)
window.dispatchEvent(
new CustomEvent<SplitTerminalPaneDetail>(SPLIT_TERMINAL_PANE_EVENT, {
detail: {
tabId: tab.id,
paneRuntimeId: -1,
direction: splitDirection ?? 'horizontal',
sourceLeafId: splitFromLeafId,
sourcePtyId,
telemetrySource: splitTelemetrySource,
newLeafId: leafId,
ptyId
}
})
)
} else {
// Why: CLI/runtime PTYs emit hook events before the tab mounts, so the leaf must exist in layout for paneKey validation.
const existingLayout = reusedTab
? activateExistingLeafInLayout(
store.terminalLayoutsByTabId?.[tab.id],
leafId,
ptyId,
title
)
: null
if (existingLayout) {
store.updateTabPtyId(tab.id, ptyId)
store.setTabLayout(tab.id, existingLayout)
} else {
store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId, ptyId, title))
}
}
}
if (command) {
store.queueTabStartupCommand(tab.id, {
command,
...(env ? { env } : {}),
...(launchConfig ? { launchConfig } : {}),
...(resumeProviderSession ? { resumeProviderSession } : {}),
...(launchToken ? { launchToken } : {}),
...(launchAgent ? { launchAgent } : {})
})
}
if (ptyId && terminalPresentation === 'background') {
requestBackgroundTerminalWorktreeMount({ worktreeId, tabIds: [tab.id] })
}
if (requestId) {
// Why: attest the actual binding; recovery callers compare it with their expected identity.
const identity =
ptyId && tabId && leafId
? verifyTerminalRevealIdentity(useAppStore.getState(), {
worktreeId,
tabId: tab.id,
leafId,
ptyId
})
: undefined
window.api.ui.replyTerminalCreate({
requestId,
tabId: tab.id,
title: title ?? tab.title,
...(identity ? { identity } : {})
})
}
} catch (err) {
if (!requestId) {
throw err
}
window.api.ui.replyTerminalCreate({
requestId,
error: err instanceof Error ? err.message : 'Terminal reveal failed'
})
}
}
)
)
// Why: background-mount a mobile-subscribed tab's PTY without navigating the desktop (STA-1840).
unsubs.push(
window.api.ui.onRequestTerminalTabMount(({ worktreeId, tabId, ptyId }) => {
if (!worktreeId) {
return
}
// Why: synthetic pty handles need persisted-tab resolution; a miss must not mount every saved tab in a hidden worktree.
const mount = planMobileTerminalTabMount(
useAppStore.getState(),
{
worktreeId,
...(tabId ? { tabId } : {}),
...(ptyId ? { ptyId } : {})
},
{
isTabMounted: hasRegisteredRuntimeTerminalTab
}
)
if (mount) {
requestBackgroundTerminalWorktreeMount(mount)
}
})
)
// Why: CLI-driven terminal creation waits for the tabId reply so it can hand the caller a usable handle immediately.
unsubs.push(
window.api.ui.onRequestTerminalCreate((data) => {
try {
const store = useAppStore.getState()
const worktreeId = data.worktreeId ?? store.activeWorktreeId
if (!worktreeId) {
window.api.ui.replyTerminalCreate({
requestId: data.requestId,
error: translate('auto.hooks.useIpcEvents.f000b2ff76', 'No active worktree')
})
return
}
const worktreeRoute = resolveTerminalWorktreeRoute(store, worktreeId)
if (!worktreeRoute) {
window.api.ui.replyTerminalCreate({
requestId: data.requestId,
error: translate(
'auto.hooks.useIpcEvents.unresolvedTerminalWorktreeOwner',
'Terminal creation is unavailable because the worktree owner could not be resolved'
)
})
return
}
// Why: runtime-session requests are host-owned tabs materialized by this renderer, not ordinary local creates.
if (worktreeRoute.runtimeEnvironmentId && data.source !== 'runtime-session') {
window.api.ui.replyTerminalCreate({
requestId: data.requestId,
error: translate(
'auto.hooks.useIpcEvents.7a64b31991',
'Local terminal creation is unavailable while a remote runtime is active'
)
})
return
}
const terminalPresentation = resolveTerminalPresentation(data)
const shouldActivate = terminalPresentation === 'focused'
const shouldSurfaceOwner =
terminalPresentation !== 'background' && data.surfaceOwner !== false
if (shouldActivate) {
activateTerminalInitiatedWorktree(store, worktreeId)
}
// Why: the paired launch client already resolved the mode, so its choice wins over the host renderer's local default.
const tabOptions = data.launchAgent
? {
...(shouldActivate ? {} : { activate: false, recordInteraction: false }),
launchAgent: data.launchAgent,
...(data.viewMode
? { viewMode: data.viewMode }
: initialAgentTabViewModeProps(store.settings, {
agent: data.launchAgent,
nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(
getConnectionIdFromState(store, worktreeId)
)
})),
...(data.cwd ? { startupCwd: data.cwd } : {})
}
: shouldActivate
? data.cwd
? { startupCwd: data.cwd }
: undefined
: {
activate: false,
recordInteraction: false,
...(data.cwd ? { startupCwd: data.cwd } : {})
}
const tab = store.createTab(worktreeId, data.targetGroupId, undefined, tabOptions)
if (!shouldActivate) {
// Why: renderer-backed Codex startup must mount its new TerminalPane without switching UI or connecting every saved tab.
requestBackgroundTerminalWorktreeMount({ worktreeId, tabIds: [tab.id] })
}
if (data.afterTabId) {
const createdUnifiedTab = useAppStore
.getState()
.unifiedTabsByWorktree[worktreeId]?.find((item) => item.entityId === tab.id)
const anchorUnifiedTab = useAppStore
.getState()
.unifiedTabsByWorktree[worktreeId]?.find((item) => item.id === data.afterTabId)
if (
createdUnifiedTab &&
anchorUnifiedTab &&
createdUnifiedTab.groupId === anchorUnifiedTab.groupId
) {
const group = useAppStore
.getState()
.groupsByWorktree[worktreeId]?.find((item) => item.id === createdUnifiedTab.groupId)
const order = (group?.tabOrder ?? []).filter((id) => id !== createdUnifiedTab.id)
const anchorIndex = order.indexOf(anchorUnifiedTab.id)
order.splice(
anchorIndex === -1 ? order.length : anchorIndex + 1,
0,
createdUnifiedTab.id
)
useAppStore.getState().reorderUnifiedTabs(createdUnifiedTab.groupId, order, {
recordInteraction: false
})
}
}
if (shouldActivate) {
store.setActiveTabType('terminal')
store.setActiveTab(tab.id)
}
if (shouldSurfaceOwner) {
store.revealWorktreeInSidebar(worktreeId)
focusTerminalInitiatedTab(tab.id)
}
if (data.title) {
store.setTabCustomTitle(tab.id, data.title, { recordInteraction: false })
}
if (data.command) {
store.queueTabStartupCommand(tab.id, {
command: data.command,
...(data.env ? { env: data.env } : {}),
...(data.envToDelete ? { envToDelete: data.envToDelete } : {}),
...(data.launchConfig ? { launchConfig: data.launchConfig } : {}),
...(data.resumeProviderSession
? { resumeProviderSession: data.resumeProviderSession }
: {}),
...(data.launchToken ? { launchToken: data.launchToken } : {}),
...(data.launchAgent ? { launchAgent: data.launchAgent } : {}),
...(data.startupCommandDelivery
? { startupCommandDelivery: data.startupCommandDelivery }
: {})
})
}
window.api.ui.replyTerminalCreate({
requestId: data.requestId,
tabId: tab.id,
title: data.title ?? tab.title
})
} catch (err) {
window.api.ui.replyTerminalCreate({
requestId: data.requestId,
error: err instanceof Error ? err.message : 'Terminal creation failed'
})
}
})
)
unsubs.push(
window.api.ui.onSplitTerminal(
({ tabId, paneRuntimeId, direction, command, telemetrySource }) => {
const detail: SplitTerminalPaneDetail = {
tabId,
paneRuntimeId,
direction,
command,
telemetrySource
}
window.dispatchEvent(new CustomEvent(SPLIT_TERMINAL_PANE_EVENT, { detail }))
}
)
)
unsubs.push(
window.api.ui.onRenameTerminal(({ tabId, title }) => {
useAppStore.getState().setTabCustomTitle(tabId, title)
})
)
unsubs.push(
window.api.ui.onFocusTerminal(
({
tabId,
worktreeId,
leafId,
ackPaneKeyOnSuccess,
flashFocusedPane,
scrollToBottomIfOutputSinceLastView
}) => {
const store = useAppStore.getState()
activateTerminalInitiatedWorktree(store, worktreeId)
store.setActiveTab(tabId)
store.revealWorktreeInSidebar(worktreeId)
if (ackPaneKeyOnSuccess || flashFocusedPane || scrollToBottomIfOutputSinceLastView) {
activateTabAndFocusPane(tabId, leafId ?? null, {
...(ackPaneKeyOnSuccess ? { ackPaneKeyOnSuccess } : {}),
...(flashFocusedPane ? { flashFocusedPane: true } : {}),
...(scrollToBottomIfOutputSinceLastView
? { scrollToBottomIfOutputSinceLastView: true }
: {})
})
return
}
focusTerminalInitiatedTab(tabId, leafId)
}
)
)
unsubs.push(
window.api.ui.onFocusEditorTab(({ tabId, worktreeId }) => {
const store = useAppStore.getState()
const tab = (store.unifiedTabsByWorktree[worktreeId] ?? []).find(
(item) => item.id === tabId
)
const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, tabId)
if (!tab) {
if (browserTarget) {
// Why: older/mobile fallback snapshots identify browser tabs by workspace id when no unified tab wrapper exists.
store.setActiveWorktree(worktreeId)
store.markWorktreeVisited(worktreeId)
store.setActiveView('terminal')
store.setActiveBrowserTab(browserTarget.workspaceId)
store.setActiveTabType('browser')
store.revealWorktreeInSidebar(worktreeId)
}
return
}
store.setActiveWorktree(worktreeId)
store.markWorktreeVisited(worktreeId)
store.setActiveView('terminal')
store.focusGroup(worktreeId, tab.groupId)
store.activateTab(tab.id)
if (browserTarget) {
// Why: browser tabs need their own active-page state, not the editor file activation path.
store.setActiveBrowserTab(browserTarget.workspaceId)
store.setActiveTabType('browser')
} else {
store.setActiveFile(tab.entityId)
store.setActiveTabType('editor')
}
store.revealWorktreeInSidebar(worktreeId)
})
)
unsubs.push(
window.api.ui.onCloseSessionTab(({ tabId, worktreeId }) => {
const store = useAppStore.getState()
const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, tabId)
if (browserTarget) {
guardPinnedTabClose({
isPinned: isPinnedSessionTab(store, worktreeId, browserTarget.workspaceId),
tabLabel: resolvePinnedTabLabel(store, worktreeId, browserTarget.workspaceId),
onClose: () => useAppStore.getState().closeBrowserTab(browserTarget.workspaceId)
})
return
}
guardPinnedTabClose({
isPinned: isPinnedSessionTab(store, worktreeId, tabId),
tabLabel: resolvePinnedTabLabel(store, worktreeId, tabId),
onClose: () => {
const currentStore = useAppStore.getState()
closeMobileSessionTabInStore(currentStore, worktreeId, tabId)
}
})
})
)
unsubs.push(
window.api.ui.onMoveSessionTab((move) => {
const { tabId, targetGroupId } = move
const store = useAppStore.getState()
if (move.kind === 'reorder') {
store.reorderUnifiedTabs(targetGroupId, move.tabOrder)
return
}
store.dropUnifiedTab(tabId, {
groupId: targetGroupId,
...(move.kind === 'move-to-group' ? { index: move.index } : {}),
...(move.kind === 'split' ? { splitDirection: move.splitDirection } : {})
})
})
)
unsubs.push(
window.api.ui.onOpenFileFromMobile(
({ worktreeId, filePath, relativePath, runtimeEnvironmentId }) => {
const store = useAppStore.getState()
const basename = relativePath.split(/[\\/]/).pop() || relativePath
store.setActiveWorktree(worktreeId)
store.markWorktreeVisited(worktreeId)
store.setActiveView('terminal')
// Why: renderer owns tab creation so grouped order and markdown bridges share the desktop File Explorer's store path.
store.openFile({
filePath,
relativePath,
worktreeId,
language: detectLanguage(basename),
runtimeEnvironmentId,
mode: 'edit'
})
store.setActiveTabType('editor')
store.revealWorktreeInSidebar(worktreeId)
}
)
)
unsubs.push(
window.api.ui.onOpenDiffFromMobile(
({ worktreeId, filePath, relativePath, staged, runtimeEnvironmentId }) => {
const store = useAppStore.getState()
const language = detectLanguage(relativePath)
store.setActiveWorktree(worktreeId)
store.markWorktreeVisited(worktreeId)
store.setActiveView('terminal')
// Why: mobile renders diffs from metadata; the editor-local Changes shortcut would send plain markdown back to mobile.
store.openDiff(worktreeId, filePath, relativePath, language, staged, {
runtimeEnvironmentId
})
store.setActiveTabType('editor')
store.revealWorktreeInSidebar(worktreeId)
}
)
)
unsubs.push(
window.api.ui.onCloseTerminal(({ tabId, paneRuntimeId }) => {
if (paneRuntimeId != null) {
// Why: route pane closes via the lifecycle hook for sibling promotion (falls through to closeTab on the last pane).
const detail: CloseTerminalPaneDetail = { tabId, paneRuntimeId }
window.dispatchEvent(new CustomEvent(CLOSE_TERMINAL_PANE_EVENT, { detail }))
} else {
// Why: the CLI/RPC caller is answered immediately, so it cannot wait on a modal.
closeTerminalTab(tabId, { skipRunningProcessConfirm: true })
}
})
)
// Why: during an in-place renderer reload an older preload can linger; keep this listener additive at that seam.
if (window.api.ui.onTerminalTabCloseRequest) {
unsubs.push(
window.api.ui.onTerminalTabCloseRequest(({ requestId, tabId }) => {
let responded = false
const respond = (error?: string): void => {
if (responded) {
return
}
responded = true
window.api.ui.respondTerminalTabClose({ requestId, ...(error ? { error } : {}) })
}
closeTerminalTab(tabId, {
rejectPinned: true,
onCancel: () => respond('terminal_tab_pinned'),
onClosed: () => {
void (async () => {
const state = useAppStore.getState()
await persistWorkspaceSessionByHost(
window.api.session,
buildWorkspaceSessionPayload(state),
state
)
respond()
})().catch((error: unknown) => {
respond(error instanceof Error ? error.message : 'terminal_tab_close_failed')
})
}
})
})
)
}
unsubs.push(
window.api.ui.onSleepWorktree(({ worktreeId }) => {
void runSleepWorktree(worktreeId)
})
)
unsubs.push(
window.api.ui.onResumeSleepingAgents(({ worktreeId }) => {
// Why: a phone opened this worktree; wake its slept agents without changing the desktop's worktree/tab/view.
backgroundSleepingAgentWakeDispatcher.request(worktreeId)
})
)
// Hydrate initial update status then subscribe to changes
window.api.updater.getStatus().then((status) => {
useAppStore.getState().setUpdateStatus(status as UpdateStatus)
})
unsubs.push(
window.api.updater.onStatus((raw) => {
const status = raw as UpdateStatus
useAppStore.getState().setUpdateStatus(status)
})
)
unsubs.push(
window.api.updater.onClearDismissal(() => {
useAppStore.getState().clearDismissedUpdateVersion()
})
)
unsubs.push(
window.api.ui.onFullscreenChanged((isFullScreen) => {
useAppStore.getState().setIsFullScreen(isFullScreen)
})
)
unsubs.push(
window.api.browser.onGuestLoadFailed(({ browserPageId, loadError }) => {
if (isRuntimeEnvironmentActive()) {
return
}
useAppStore.getState().updateBrowserPageState(browserPageId, {
loading: false,
loadError,
canGoBack: false,
canGoForward: false
})
})
)
const unsubscribeCertificateFailure = window.api.browser.onCertificateFailureChanged?.(
({ browserPageId, failure }) => {
if (isRuntimeEnvironmentActive()) {
return
}
useAppStore.getState().setBrowserPageCertificateFailure(browserPageId, failure)
}
)
if (unsubscribeCertificateFailure) {
unsubs.push(unsubscribeCertificateFailure)
}
// Why: agent-browser navigates via CDP so did-navigate never fires; this IPC pushes live URL/title to the stale store.
unsubs.push(
window.api.browser.onNavigationUpdate(({ browserPageId, url, title }) => {
if (isRuntimeEnvironmentActive()) {
return
}
const store = useAppStore.getState()
rememberLiveBrowserUrl(browserPageId, redactKagiSessionToken(url))
store.setBrowserPageUrl(browserPageId, url)
store.updateBrowserPageState(browserPageId, { title, loading: false })
})
)
// Why: webviews start their guest only when shown; sent pre-automation so hidden tabs mount without moving the active pane.
unsubs.push(
window.api.browser.onActivateView(({ worktreeId, browserPageId }) => {
if (isRuntimeEnvironmentActive()) {
return
}
acquireBrowserAutomationBootstrapLease(worktreeId, browserPageId)
})
)
// Why: `orca tab switch --focus` must NOT call setActiveWorktree — a global focus from one agent's parallel-worktree switch would steal the user's view.
// focusBrowserTabInWorktree updates per-worktree state in place; globals flip only when the user is already on the targeted worktree.
unsubs.push(
window.api.browser.onPaneFocus(({ worktreeId, browserPageId }) => {
if (isRuntimeEnvironmentActive()) {
return
}
const store = useAppStore.getState()
// Why: worktreeId is null if the tab closed mid-switch; the activeWorktreeId fallback makes the focus call a safe no-op for a stale page id.
const targetWt = worktreeId ?? store.activeWorktreeId
if (!targetWt) {
return
}
store.focusBrowserTabInWorktree(targetWt, browserPageId)
})
)
unsubs.push(
window.api.browser.onOpenLinkInOrcaTab(({ browserPageId, url }) => {
const store = useAppStore.getState()
const sourcePage = Object.values(store.browserPagesByWorkspace)
.flat()
.find((page) => page.id === browserPageId)
if (!sourcePage) {
return
}
if (getRuntimeEnvironmentIdForWorktree(store, sourcePage.worktreeId)) {
return
}
// Why: only the renderer owns Orca's tab model, so main delegates link-open here.
store.createBrowserTab(sourcePage.worktreeId, url, { title: url })
})
)
// Why: embedded browser guests capture keyboard focus and bypass window-level keydown, so shortcuts are forwarded via IPC.
unsubs.push(
window.api.ui.onNewBrowserTab(() => {
const store = useAppStore.getState()
if (isFloatingWorkspacePanelFocused()) {
void createFloatingWorkspaceBrowserTab(store)
return
}
const worktreeId = store.activeWorktreeId
if (worktreeId) {
const environmentId = getWorktreeRuntimeEnvironmentId(worktreeId)
if (environmentId) {
if (!isWebRuntimeSessionActive(environmentId)) {
store.createBrowserTab(worktreeId, store.browserDefaultUrl ?? 'about:blank', {
title: translate('auto.hooks.useIpcEvents.f6300deb8b', 'New Browser Tab'),
focusAddressBar: true
})
return
}
void (async () => {
// Why: paired web tabs are host-owned; on RPC failure leave local state so the next host snapshot stays authoritative.
await createWebRuntimeSessionBrowserTab({
worktreeId,
environmentId,
url: store.browserDefaultUrl ?? 'about:blank'
})
})()
return
}
store.createBrowserTab(worktreeId, store.browserDefaultUrl ?? 'about:blank', {
title: translate('auto.hooks.useIpcEvents.f6300deb8b', 'New Browser Tab'),
focusAddressBar: true
})
}
})
)
unsubs.push(
window.api.ui.onNewMarkdownTab(() => {
const store = useAppStore.getState()
if (isFloatingWorkspacePanelFocused()) {
void createFloatingWorkspaceMarkdownTab(store).catch((err) => {
toast.error(
err instanceof Error
? err.message
: translate(
'auto.hooks.useIpcEvents.56d3ec4203',
'Failed to create untitled markdown file.'
)
)
})
return
}
const worktreeId = store.activeWorktreeId
if (!worktreeId) {
return
}
const targetGroupId =
store.activeGroupIdByWorktree[worktreeId] ?? store.groupsByWorktree[worktreeId]?.[0]?.id
if (targetGroupId) {
void store.openNewMarkdownInActiveWorkspace(targetGroupId)
}
})
)
// Why: emulator IPC is additive; guard so older clients or partial preload mocks don't crash the hook when it's absent.
const unsubscribeNewSimulatorTab = window.api.ui.onNewSimulatorTab?.(() => {
if (isRuntimeEnvironmentActive()) {
return
}
const store = useAppStore.getState()
const worktreeId = store.activeWorktreeId
if (!worktreeId) {
return
}
void openMobileEmulatorTab(worktreeId, { placement: 'rightSplit' })
})
if (unsubscribeNewSimulatorTab) {
unsubs.push(unsubscribeNewSimulatorTab)
}
const unsubscribeEmulatorAutoAttach = window.api.emulator?.onAutoAttach(
({ worktreeId, info }) => {
if (isRuntimeEnvironmentActive()) {
return
}
if (isManualSimulatorLaunchPending(worktreeId)) {
// Why: manual launches pre-attach so the ready pane opens in the right split, not as a hidden tab in this group.
rememberPrelaunchedSimulatorSession(worktreeId, info)
return
}
ensureSimulatorTab(worktreeId, { surfacePane: false })
// Why: watcher may detect a helper while a simulator tab is already mounted; push stream info so the pane updates without re-attach.
window.setTimeout(() => {
window.dispatchEvent(
new CustomEvent('orca:emulator-auto-attach', {
detail: { worktreeId, info }
})
)
}, 0)
}
)
if (unsubscribeEmulatorAutoAttach) {
unsubs.push(unsubscribeEmulatorAutoAttach)
}
const unsubscribeEmulatorPaneFocus = window.api.emulator?.onPaneFocus(({ worktreeId }) => {
if (isRuntimeEnvironmentActive()) {
return
}
ensureSimulatorTab(worktreeId, { surfacePane: true })
})
if (unsubscribeEmulatorPaneFocus) {
unsubs.push(unsubscribeEmulatorPaneFocus)
}
// Why: reply with the page ID so main can await registerGuest before returning to the CLI.
unsubs.push(
window.api.ui.onRequestTabCreate((data) => {
try {
if (isRuntimeEnvironmentActive()) {
// Why: browser automation targets client-local Electron webviews that runtime agents can't see or control.
window.api.ui.replyTabCreate({
requestId: data.requestId,
error: translate(
'auto.hooks.useIpcEvents.291c8ed902',
'Browser tabs are unavailable while a remote runtime is active'
)
})
return
}
const store = useAppStore.getState()
const worktreeId = data.worktreeId ?? store.activeWorktreeId
if (!worktreeId) {
window.api.ui.replyTabCreate({
requestId: data.requestId,
error: translate('auto.hooks.useIpcEvents.f000b2ff76', 'No active worktree')
})
return
}
// Why: CLI-created tabs should land in the active browser tab's group, not the terminal's UI-active group.
const activeBrowserTabId = store.activeBrowserTabIdByWorktree[worktreeId]
const activeBrowserUnifiedTab = activeBrowserTabId
? (store.unifiedTabsByWorktree[worktreeId] ?? []).find(
(t) => t.contentType === 'browser' && t.entityId === activeBrowserTabId
)
: undefined
// Why: a user-initiated open (data.activate, e.g. mobile tapping an HTML path) foregrounds the tab so it lands in active-group order and publishes to mobile.
// Agent/automation opens stay in the background (activate:false) in the active browser group.
const workspace = store.createBrowserTab(worktreeId, data.url, {
title: data.url,
targetGroupId: data.activate ? undefined : activeBrowserUnifiedTab?.groupId,
sessionProfileId: data.sessionProfileId,
sessionPartition: data.sessionPartition,
activate: data.activate === true
})
// Why: registerGuest fires with the page ID, not the workspace ID; return it so waitForTabRegistration can correlate.
const pages = useAppStore.getState().browserPagesByWorkspace[workspace.id] ?? []
const browserPageId = pages[0]?.id ?? workspace.id
acquireBrowserAutomationBootstrapLease(worktreeId, browserPageId)
window.api.ui.replyTabCreate({ requestId: data.requestId, browserPageId })
} catch (err) {
window.api.ui.replyTabCreate({
requestId: data.requestId,
error: err instanceof Error ? err.message : 'Tab creation failed'
})
}
})
)
unsubs.push(
window.api.ui.onRequestTabSetProfile((data) => {
try {
if (isRuntimeEnvironmentActive()) {
window.api.ui.replyTabSetProfile({
requestId: data.requestId,
error: translate(
'auto.hooks.useIpcEvents.f45fa2b03c',
'Browser profiles are unavailable while a remote runtime is active'
)
})
return
}
const store = useAppStore.getState()
const owningWorkspace = Object.values(store.browserTabsByWorktree)
.flat()
.find((workspace) => {
if (workspace.id === data.browserPageId) {
return true
}
const pages = store.browserPagesByWorkspace[workspace.id] ?? []
return pages.some((page) => page.id === data.browserPageId)
})
if (!owningWorkspace) {
window.api.ui.replyTabSetProfile({
requestId: data.requestId,
error: translate(
'auto.hooks.useIpcEvents.0e3cf53060',
'Browser tab {{value0}} not found',
{ value0: data.browserPageId }
)
})
return
}
// Why: a workspace may host several browser pages; profile switch must tear down all sibling webviews, not just the IPC's.
const workspacePages = store.browserPagesByWorkspace[owningWorkspace.id] ?? []
if (workspacePages.length > 0) {
for (const page of workspacePages) {
destroyPersistentWebview(page.id)
}
} else {
destroyPersistentWebview(data.browserPageId)
}
store.switchBrowserTabProfile(owningWorkspace.id, data.profileId, data.sessionPartition)
window.api.ui.replyTabSetProfile({ requestId: data.requestId })
} catch (err) {
window.api.ui.replyTabSetProfile({
requestId: data.requestId,
error: err instanceof Error ? err.message : 'Tab profile update failed'
})
}
})
)
unsubs.push(
window.api.ui.onRequestTabClose((data) => {
try {
if (isRuntimeEnvironmentActive()) {
window.api.ui.replyTabClose({
requestId: data.requestId,
error: translate(
'auto.hooks.useIpcEvents.291c8ed902',
'Browser tabs are unavailable while a remote runtime is active'
)
})
return
}
const store = useAppStore.getState()
const explicitTargetId = data.tabId ?? null
const replyPinnedBrowserCloseCanceled = (tabId: string): void => {
window.api.ui.replyTabClose({
requestId: data.requestId,
error: translate(
'auto.hooks.useIpcEvents.2f6637fe6c',
'Browser tab {{value0}} is pinned',
{ value0: tabId }
)
})
}
const closeBrowserWorkspaceWithReply = (
worktreeId: string,
workspaceId: string
): void => {
const currentStore = useAppStore.getState()
guardPinnedTabClose({
isPinned: isPinnedSessionTab(currentStore, worktreeId, workspaceId),
tabLabel: resolvePinnedTabLabel(currentStore, worktreeId, workspaceId),
onClose: () => {
useAppStore.getState().closeBrowserTab(workspaceId)
window.api.ui.replyTabClose({ requestId: data.requestId })
},
onCancel: () => replyPinnedBrowserCloseCanceled(workspaceId)
})
}
const tabToClose =
explicitTargetId ??
(data.worktreeId
? (store.activeBrowserTabIdByWorktree?.[data.worktreeId] ?? null)
: store.activeBrowserTabId)
if (!tabToClose) {
window.api.ui.replyTabClose({
requestId: data.requestId,
error: translate(
'auto.hooks.useIpcEvents.a8d2bf8e9e',
'No active browser tab to close'
)
})
return
}
// Why: the bridge keys tabs by browserPageId, but closeBrowserTab expects a workspace id.
// Per the CLI's `tab close --page` contract, close only that page unless it is the last in its workspace.
const isWorkspaceId = Object.values(store.browserTabsByWorktree)
.flat()
.some((ws) => ws.id === tabToClose)
if (!isWorkspaceId) {
const owningWorkspace = Object.entries(store.browserPagesByWorkspace).find(
([, pages]) => pages.some((p) => p.id === tabToClose)
)
if (owningWorkspace) {
const [workspaceId, pages] = owningWorkspace
if (pages.length <= 1) {
const owningWorktreeId =
Object.entries(store.browserTabsByWorktree).find(([, tabs]) =>
tabs.some((tab) => tab.id === workspaceId)
)?.[0] ?? null
if (owningWorktreeId) {
closeBrowserWorkspaceWithReply(owningWorktreeId, workspaceId)
return
}
store.closeBrowserTab(workspaceId)
} else {
store.closeBrowserPage(tabToClose)
}
window.api.ui.replyTabClose({ requestId: data.requestId })
return
}
}
const owningWorktreeId =
Object.entries(store.browserTabsByWorktree).find(([, tabs]) =>
tabs.some((tab) => tab.id === tabToClose)
)?.[0] ?? null
if (owningWorktreeId) {
closeBrowserWorkspaceWithReply(owningWorktreeId, tabToClose)
return
}
if (explicitTargetId) {
window.api.ui.replyTabClose({
requestId: data.requestId,
error: translate(
'auto.hooks.useIpcEvents.0e3cf53060',
'Browser tab {{value0}} not found',
{ value0: explicitTargetId }
)
})
return
}
store.closeBrowserTab(tabToClose)
window.api.ui.replyTabClose({ requestId: data.requestId })
} catch (err) {
window.api.ui.replyTabClose({
requestId: data.requestId,
error: err instanceof Error ? err.message : 'Tab close failed'
})
}
})
)
unsubs.push(
window.api.ui.onNewTerminalTab(() => {
const store = useAppStore.getState()
if (isFloatingWorkspacePanelFocused()) {
void createFloatingWorkspaceTerminalTab(store)
return
}
const worktreeId = store.activeWorktreeId
if (!worktreeId) {
return
}
void (async () => {
const environmentId = getWorktreeRuntimeEnvironmentId(worktreeId)
const outcome = await createWebRuntimeSessionTerminal({
worktreeId,
environmentId,
activate: true
})
if (outcome.status === 'created' || isWebRuntimeSessionActive(environmentId)) {
return
}
const newTab = store.createTab(worktreeId)
store.setActiveTabType('terminal')
// Why: mirror Terminal.tsx handleNewTab so a new tab appends at the end, not index 0, when tabBarOrder is unset.
const freshStore = useAppStore.getState()
const currentTerminals = freshStore.tabsByWorktree[worktreeId] ?? []
const currentEditors = freshStore.openFiles.filter((f) => f.worktreeId === worktreeId)
const currentBrowsers = freshStore.browserTabsByWorktree[worktreeId] ?? []
const stored = freshStore.tabBarOrderByWorktree[worktreeId]
const termIds = currentTerminals.map((t) => t.id)
const editorIds = currentEditors.map((f) => f.id)
const browserIds = currentBrowsers.map((tab) => tab.id)
const validIds = new Set([...termIds, ...editorIds, ...browserIds])
const base = (stored ?? []).filter((id) => validIds.has(id))
const inBase = new Set(base)
for (const id of [...termIds, ...editorIds, ...browserIds]) {
if (!inBase.has(id)) {
base.push(id)
inBase.add(id)
}
}
const order = base.filter((id) => id !== newTab.id)
order.push(newTab.id)
freshStore.setTabBarOrder(worktreeId, order)
focusTerminalTabSurface(newTab.id)
})()
})
)
unsubs.push(
window.api.ui.onCloseActiveTab(() => {
if (isEmptyFloatingWorkspacePanelVisible()) {
window.dispatchEvent(new Event(TOGGLE_FLOATING_TERMINAL_EVENT))
return
}
const store = useAppStore.getState()
if (store.activeTabType === 'browser' && store.activeBrowserTabId) {
const tabId = store.activeBrowserTabId
const worktreeId = store.activeWorktreeId
const closeActiveBrowserTab = (): void => {
const currentStore = useAppStore.getState()
const environmentId = getWorktreeRuntimeEnvironmentId(worktreeId)
if (environmentId && worktreeId) {
if (!isWebRuntimeSessionActive(environmentId)) {
currentStore.closeBrowserTab(tabId)
return
}
void closeWebRuntimeSessionTab({
worktreeId,
tabId,
environmentId,
reason: 'user'
})
return
}
currentStore.closeBrowserTab(tabId)
}
if (worktreeId && isPinnedSessionTab(store, worktreeId, tabId)) {
guardPinnedTabClose({
isPinned: true,
tabLabel: resolvePinnedTabLabel(store, worktreeId, tabId),
onClose: closeActiveBrowserTab
})
return
}
closeActiveBrowserTab()
}
})
)
unsubs.push(
window.api.ui.onCloseFloatingItem(({ sourceId }) => {
// Main forwards the guest's browser *page* id; resolve it to the owning live floating
// browser workspace (the id space the panel closes by), then hand off to the mounted
// panel's own close closure (pin guard + reclaim intent). Stale id = no-op.
const workspaceId = resolveFloatingWorkspaceBrowserWorkspaceId(
useAppStore.getState(),
sourceId
)
if (!workspaceId) {
return
}
dispatchFloatingWorkspaceGuestClose({ sourceId: workspaceId })
})
)
unsubs.push(
window.api.ui.onSelectFloatingIndex(({ index }) => {
dispatchFloatingWorkspaceGuestSelectIndex({ index })
})
)
unsubs.push(
window.api.ui.onSwitchTab((direction) => {
const store = useAppStore.getState()
if (isFloatingWorkspacePanelFocused()) {
switchFloatingWorkspaceTab(store, direction, 'same-type')
return
}
handleSwitchTab(direction)
})
)
unsubs.push(
window.api.ui.onSwitchTabAcrossAllTypes((direction) => {
const store = useAppStore.getState()
if (isFloatingWorkspacePanelFocused()) {
switchFloatingWorkspaceTab(store, direction, 'all-types')
return
}
handleSwitchTabAcrossAllTypes(direction)
})
)
unsubs.push(window.api.ui.onSwitchRecentTab(handleSwitchRecentTab))
unsubs.push(
window.api.ui.onSwitchTerminalTab((direction) => {
const store = useAppStore.getState()
if (isFloatingWorkspacePanelFocused()) {
switchFloatingWorkspaceTab(store, direction, 'terminal')
return
}
handleSwitchTerminalTab(direction)
})
)
let initialRateLimitsSnapshotPending = true
let receivedRateLimitsPushBeforeInitialSnapshot = false
unsubs.push(
window.api.rateLimits.onUpdate((state) => {
if (initialRateLimitsSnapshotPending) {
receivedRateLimitsPushBeforeInitialSnapshot = true
}
useAppStore.getState().setRateLimitsFromPush(state as RateLimitState)
})
)
// Why: the startup get is a fallback; a live push may already include account snapshots the get result lacks.
window.api.rateLimits.get().then((state) => {
initialRateLimitsSnapshotPending = false
if (receivedRateLimitsPushBeforeInitialSnapshot) {
return
}
useAppStore.getState().setRateLimitsFromPush(state as RateLimitState)
})
const unsubscribeWorkspaceSpaceProgress = window.api.workspaceSpace?.onProgress?.(
(progress) => {
useAppStore.getState().applyWorkspaceSpaceProgress(progress)
}
)
if (unsubscribeWorkspaceSpaceProgress) {
unsubs.push(unsubscribeWorkspaceSpaceProgress)
}
const sshStateWatermarkByTargetId = new Map<string, number>()
const pendingPortHydrationByTargetId = new Map<
string,
{ receivedForwardPush: boolean; receivedDetectedPush: boolean }
>()
const hydrateSshPorts = (targetId: string, authority: DirectSshAuthority): void => {
const pendingPortHydration = {
receivedForwardPush: false,
receivedDetectedPush: false
}
pendingPortHydrationByTargetId.set(targetId, pendingPortHydration)
const isHydrationAuthorityCurrent = (): boolean =>
!directSshEffectStopped &&
directSshAuthoritiesEqual(currentDirectSshAuthority(targetId), authority)
const forwardHydration = window.api.ssh.listPortForwards({ targetId }).then((forwards) => {
// Why: if the session disconnected while awaiting the snapshot, applying it would resurrect a dead session's ports.
if (isHydrationAuthorityCurrent() && !pendingPortHydration.receivedForwardPush) {
useAppStore.getState().setPortForwards(targetId, forwards)
}
})
const detectedHydration = window.api.ssh.listDetectedPorts({ targetId }).then((detected) => {
if (isHydrationAuthorityCurrent() && !pendingPortHydration.receivedDetectedPush) {
useAppStore.getState().setDetectedPorts(targetId, detected)
}
})
// Why: one failed or stalled port stream must not block the other stream or later targets.
void Promise.allSettled([forwardHydration, detectedHydration]).then(() => {
if (pendingPortHydrationByTargetId.get(targetId) === pendingPortHydration) {
pendingPortHydrationByTargetId.delete(targetId)
}
})
}
let applySshConnectionStateChange!: (
targetId: string,
state: SshConnectionState,
origin: DirectSshConnectedStateOrigin
) => void
// Why: hydrate initial SSH state for all targets so worktree cards show correct connect state on launch.
void (async () => {
try {
const targets = await window.api.ssh.listTargets()
if (directSshEffectStopped) {
return
}
useAppStore.getState().setSshTargetsMetadata(targets)
// Why: ghost-host UI (removed target still referenced by a workspace) shows a tombstone name instead of the raw id.
try {
const removedLabels = await window.api.ssh.listRemovedTargetLabels()
if (directSshEffectStopped) {
return
}
useAppStore.getState().setRemovedSshTargetLabels(removedLabels)
} catch {
// Best-effort — a missing map just falls back to the raw target id.
}
for (const target of targets) {
const hydrationWatermark = sshStateWatermarkByTargetId.get(target.id) ?? 0
const state = await window.api.ssh.getState({ targetId: target.id })
if (
!directSshEffectStopped &&
state &&
(sshStateWatermarkByTargetId.get(target.id) ?? 0) === hydrationWatermark
) {
applySshConnectionStateChange(
target.id,
state as SshConnectionState,
'initial-hydration'
)
}
}
} catch {
// SSH may not be configured
}
})()
unsubs.push(
window.api.ssh.onCredentialRequest((data) => {
useAppStore.getState().enqueueSshCredentialRequest(data)
})
)
unsubs.push(
window.api.ssh.onCredentialResolved(({ requestId }) => {
useAppStore.getState().removeSshCredentialRequest(requestId)
})
)
unsubs.push(
window.api.ssh.onPortForwardsChanged(({ targetId, forwards }) => {
const pendingPortHydration = pendingPortHydrationByTargetId.get(targetId)
if (pendingPortHydration) {
pendingPortHydration.receivedForwardPush = true
}
useAppStore.getState().setPortForwards(targetId, forwards)
})
)
unsubs.push(
window.api.ssh.onDetectedPortsChanged(({ targetId, ports }) => {
const pendingPortHydration = pendingPortHydrationByTargetId.get(targetId)
if (pendingPortHydration) {
pendingPortHydration.receivedDetectedPush = true
}
useAppStore.getState().setDetectedPorts(targetId, ports)
})
)
const reconcileSshAuthority = (
targetId: string,
initiatingState: SshConnectionState,
origin: DirectSshConnectedStateOrigin,
watermark: number
): void => {
let pendingDeadline: { timer: ReturnType<typeof setTimeout>; settle: () => void } | undefined
const deadline = new Promise<null>((resolve) => {
const settle = (): void => resolve(null)
const timer = setTimeout(settle, 5_000)
pendingDeadline = { timer, settle }
authorityReconciliationDeadlines.add(pendingDeadline)
})
void Promise.race([window.api.ssh.getState({ targetId }).catch(() => null), deadline])
.then((latest) => {
if (
directSshEffectStopped ||
latest?.targetId !== targetId ||
!latest?.providerEpoch ||
latest.connectionGeneration === undefined ||
(sshStateWatermarkByTargetId.get(targetId) ?? 0) !== watermark
) {
return
}
const current = useAppStore.getState().sshConnectionStates?.get(targetId)
if (
current?.status !== initiatingState.status ||
latest.status !== initiatingState.status ||
current.providerEpoch !== initiatingState.providerEpoch ||
current.connectionGeneration !== initiatingState.connectionGeneration ||
(current.providerEpoch !== undefined &&
current.providerEpoch !== null &&
current.providerEpoch !== latest.providerEpoch) ||
(current.connectionGeneration !== undefined &&
current.connectionGeneration !== latest.connectionGeneration)
) {
return
}
applySshConnectionStateChange(
targetId,
{
...current,
providerEpoch: latest.providerEpoch,
connectionGeneration: latest.connectionGeneration
},
origin
)
})
.catch(() => undefined)
.finally(() => {
if (pendingDeadline) {
clearTimeout(pendingDeadline.timer)
authorityReconciliationDeadlines.delete(pendingDeadline)
}
})
}
applySshConnectionStateChange = (
targetId: string,
state: SshConnectionState,
origin: DirectSshConnectedStateOrigin
): void => {
const store = useAppStore.getState()
const previous = store.sshConnectionStates?.get(targetId)
store.setSshConnectionState(targetId, state)
if (canConnectSshStatus(state.status)) {
reconnectAuthorityByTarget.delete(targetId)
reconnectCoordinator.invalidate(targetId)
// Why: remote agent list is tied to a live relay; clear on disconnect so reconnect re-detects against the new relay.
store.clearRemoteDetectedAgents(targetId)
// Why: defensive — clear port state in case the removeAllForwards broadcast races this state change.
store.clearPortForwards(targetId)
store.setDetectedPorts(targetId, [])
// SSH teardown has no per-PTY exits; clear only exact-target bindings in one store publication.
store.clearDirectSshTargetPtyBindings(targetId)
return
}
if (state.status !== 'connected') {
return
}
const authority = currentDirectSshAuthority(targetId)
if (!authority) {
reconcileSshAuthority(
targetId,
state,
origin,
sshStateWatermarkByTargetId.get(targetId) ?? 0
)
return
}
const previousAuthority =
previous?.status === 'connected' &&
previous.providerEpoch &&
previous.connectionGeneration !== undefined
? {
targetId,
providerEpoch: previous.providerEpoch,
connectionGeneration: previous.connectionGeneration
}
: null
routeDirectSshConnectedState(
{
coordinator: reconnectCoordinator,
coordinatorRoutingEnabled: isDirectSshReconnectCoordinatorRoutingEnabled(),
invalidateStaleTerminalBindings: (nextAuthority) =>
directSshTerminalActions().invalidateStaleDirectSshTargetPtyBindings?.(nextAuthority) ??
0,
retryTargetPanes: (nextAuthority) =>
directSshTerminalActions().retryDirectSshTargetPanes?.(nextAuthority) ?? 0,
prepareAndSync: prepareAndSyncDirectSshTarget,
rememberReconnectAuthority: (nextAuthority) => {
if (nextAuthority) {
reconnectAuthorityByTarget.set(targetId, nextAuthority)
} else {
reconnectAuthorityByTarget.delete(targetId)
}
}
},
{ authority, previousAuthority, origin }
)
// Why: initial connected state can be partial; hydrate only after reconciliation yields a complete authority.
if (origin === 'initial-hydration') {
hydrateSshPorts(targetId, authority)
}
}
let sshTargetStateEventId = 0
const latestSshTargetStateEventByTargetId = new Map<string, number>()
handleSshStateChangedEvent = (data: { targetId: string; state: unknown }): void => {
const store = useAppStore.getState()
const state = data.state as SshConnectionState
const stateEventId = ++sshTargetStateEventId
sshStateWatermarkByTargetId.set(
data.targetId,
(sshStateWatermarkByTargetId.get(data.targetId) ?? 0) + 1
)
latestSshTargetStateEventByTargetId.set(data.targetId, stateEventId)
if (!store.sshTargetLabels.has(data.targetId)) {
// Why: unknown target id could be a post-boot add or a removed target racing disconnect; confirm with main first.
window.api.ssh
.listTargets()
// Why: refresh doubles as a deletion guard; retry once so a transient IPC failure doesn't drop a real added-target event.
.catch(() => window.api.ssh.listTargets())
.then((targets) => {
if (latestSshTargetStateEventByTargetId.get(data.targetId) !== stateEventId) {
return
}
latestSshTargetStateEventByTargetId.delete(data.targetId)
if (directSshEffectStopped) {
return
}
const latestStore = useAppStore.getState()
if (!targets.some((target) => target.id === data.targetId)) {
// Why: state events can race after target removal; absence from main's target list means deletion, not a new target.
latestStore.clearRemovedSshTargetState(data.targetId)
return
}
latestStore.setSshTargetsMetadata(targets)
applySshConnectionStateChange(data.targetId, state, 'push')
})
.catch(() => {
if (
!directSshEffectStopped &&
latestSshTargetStateEventByTargetId.get(data.targetId) === stateEventId
) {
latestSshTargetStateEventByTargetId.delete(data.targetId)
applySshConnectionStateChange(data.targetId, state, 'push')
}
})
return
}
latestSshTargetStateEventByTargetId.delete(data.targetId)
applySshConnectionStateChange(data.targetId, state, 'push')
}
unsubs.push(window.api.ssh.onStateChanged(handleSshStateChangedEvent))
unsubs.push(
registerDirectSshWakeRouting({
getConnectionStates: () => useAppStore.getState().sshConnectionStates ?? [],
wakeAuthority: (authority) => {
reconnectCoordinator.correctUnboundTerminals(authority, 'wake-refresh')
void prepareAndSyncDirectSshTarget(authority, 'wake-refresh')
},
...(typeof window.api.ui.onSystemResumed === 'function'
? { onSystemResumed: (callback: () => void) => window.api.ui.onSystemResumed(callback) }
: {})
})
)
let remoteWorkspaceClientId: string | null = null
let remoteWorkspaceClientIdPromise: Promise<string | null> | null = null
const getRemoteWorkspaceClientId = (): Promise<string | null> => {
const remoteWorkspace = window.api.remoteWorkspace
if (!remoteWorkspace) {
return Promise.resolve(null)
}
if (remoteWorkspaceClientId) {
return Promise.resolve(remoteWorkspaceClientId)
}
remoteWorkspaceClientIdPromise ??= remoteWorkspace
.clientId()
.then((id) => {
remoteWorkspaceClientId = id
return id
})
.catch(() => null)
return remoteWorkspaceClientIdPromise
}
if (window.api.remoteWorkspace) {
void getRemoteWorkspaceClientId()
unsubs.push(
window.api.remoteWorkspace.onChanged((event) => {
void (async () => {
// Why: relay notifications can race the client-id IPC; self-originated writes must never bounce back into restore.
const clientId = await getRemoteWorkspaceClientId()
if (event.sourceClientId && clientId && event.sourceClientId === clientId) {
return
}
await remoteWorkspaceTargetSync
?.applyUnsolicitedSnapshot(event.targetId, event.snapshot)
.catch((err) => {
useAppStore.getState().setRemoteWorkspaceSyncStatus(event.targetId, {
phase: 'error',
revision: event.snapshot.revision,
message: err instanceof Error ? err.message : 'Failed to apply remote workspace'
})
})
})()
})
)
}
// Zoom handling for menu accelerators and keyboard fallback paths.
unsubs.push(
window.api.ui.onTerminalZoom((direction) => {
const store = useAppStore.getState()
const { activeView, activeTabType, editorFontZoomLevel, setEditorFontZoomLevel, settings } =
store
const target = resolveZoomTarget({
activeView,
activeTabType,
activeElement: document.activeElement
})
if (target === 'terminal') {
return
}
if (target === 'editor') {
const next = nextEditorFontZoomLevel(editorFontZoomLevel, direction)
setEditorFontZoomLevel(next)
void window.api.ui.set({ editorFontZoomLevel: next })
// Why: mirror the editor's base font (terminalFontSize) + clamping so the overlay percent matches the rendered size.
const baseFontSize = settings?.terminalFontSize ?? 13
const actual = computeEditorFontSize(baseFontSize, next)
const percent = Math.round((actual / baseFontSize) * 100)
dispatchZoomLevelChanged('editor', percent)
return
}
const current = window.api.ui.getZoomLevel()
const next = stepUIZoomLevel(current, direction)
applyUIZoom(next)
void window.api.ui.set({ uiZoomLevel: next })
dispatchZoomLevelChanged('ui', zoomLevelToPercent(next))
})
)
// Why: re-parse main-process agent status here so the renderer applies the same normalization regardless of hook vs OSC source.
// Startup pushes are ignored until workspace session hydration finishes; the snapshot pull below replays main's cache once tab identity exists.
function schedulePendingAgentStatusFlush(): void {
if (pendingAgentStatusRetryTimer !== null || pendingAgentStatusEvents.length === 0) {
return
}
pendingAgentStatusRetryTimer = globalThis.setTimeout(() => {
pendingAgentStatusRetryTimer = null
flushPendingAgentStatuses()
}, PENDING_AGENT_STATUS_RETRY_MS)
}
function enqueuePendingAgentStatus(
data: AgentStatusIpcPayload,
options?: { replay?: boolean }
): void {
pendingAgentStatusEvents.push({
data,
firstSeenAt: Date.now(),
replay: options?.replay === true
})
while (pendingAgentStatusEvents.length > MAX_PENDING_AGENT_STATUS_EVENTS) {
pendingAgentStatusEvents.shift()
}
schedulePendingAgentStatusFlush()
}
function flushPendingAgentStatuses(): void {
// Why: guard re-entrancy — a subscriber firing mid-loop must not reprocess queued events the outer flush already owns.
if (isFlushingAgentStatuses) {
return
}
if (pendingAgentStatusEvents.length === 0) {
return
}
isFlushingAgentStatuses = true
try {
const now = Date.now()
const remaining: PendingAgentStatusEvent[] = []
for (const event of pendingAgentStatusEvents) {
if (now - event.firstSeenAt > PENDING_AGENT_STATUS_TTL_MS) {
continue
}
const result = applyAgentStatus(event.data, { retry: true, replay: event.replay })
if (result === 'pending') {
remaining.push(event)
}
}
pendingAgentStatusEvents.length = 0
pendingAgentStatusEvents.push(...remaining)
if (pendingAgentStatusEvents.length === 0 && pendingAgentStatusRetryTimer !== null) {
globalThis.clearTimeout(pendingAgentStatusRetryTimer)
pendingAgentStatusRetryTimer = null
}
} finally {
isFlushingAgentStatuses = false
}
schedulePendingAgentStatusFlush()
}
const applyAgentStatus = (
data: AgentStatusIpcPayload,
options?: { replay?: boolean; retry?: boolean }
): AgentStatusApplyResult => {
const store = useAppStore.getState()
if (!store.workspaceSessionReady) {
return 'dropped'
}
if (isAgentStatusForRecentlyClosedTab(store, data.paneKey)) {
return 'dropped'
}
const paneKey = resolveAgentPaneAuthorityKey(data.paneKey)
const ownerTabId = parsePaneKey(paneKey)?.tabId ?? data.tabId
const payload = normalizeAgentStatusPayload({
state: data.state,
prompt: data.prompt,
agentType: data.agentType,
model: data.model,
toolName: data.toolName,
toolInput: data.toolInput,
// Why: the live AskUserQuestion prompt rides this field; omitting it drops the native question card on web/mobile.
interactivePrompt: data.interactivePrompt,
lastAssistantMessage: data.lastAssistantMessage,
interrupted: data.interrupted,
sessionBoundary: data.sessionBoundary,
// Why: same trap as interactivePrompt — this rebuild is a field whitelist, so subagent child rows vanish if omitted.
subagents: data.subagents
})
if (!payload) {
return 'dropped'
}
let {
exists,
title,
identityTitle,
repoConnectionId,
repoConnectionResolved,
owningWorktreeId
} = resolvePaneKey(store, paneKey)
if (!exists && data.worktreeId && hasRuntimeBackedWorktreeAttribution(data)) {
// Why: orchestration worker hooks may carry worktree attribution before this renderer has a tab for the pane.
// Require runtime identity too — worktreeId-only snapshots can be stale rows from closed/remounted panes.
const fallbackOwnership = resolveWorktreeConnection(store, data.worktreeId)
if (fallbackOwnership.worktreeExists) {
owningWorktreeId = data.worktreeId
repoConnectionId = fallbackOwnership.repoConnectionId
repoConnectionResolved = fallbackOwnership.repoConnectionResolved
exists = true
}
}
if (!exists) {
// Why: startup snapshot replay can beat tab/layout hydration too.
// Reuse the same bounded retry queue when the row still carries
// runtime-backed worktree provenance so the cached status can adopt
// once the pane becomes visible.
if (options?.replay === true) {
if (data.worktreeId && hasRuntimeBackedWorktreeAttribution(data)) {
if (options?.retry !== true) {
enqueuePendingAgentStatus(data, { replay: true })
}
return 'pending'
}
return 'dropped'
}
if (options?.retry !== true) {
// Why: empty paneKeys are dropped in main before IPC fanout. Reaching
// this branch means a non-empty paneKey escaped without a matching
// renderer tab, so track the adoption/routing failure separately.
track('agent_hook_unattributed', { reason: 'unknown_tab_id' })
enqueuePendingAgentStatus(data)
}
return 'pending'
}
if (options?.replay !== true && options?.retry !== true) {
for (let index = pendingAgentStatusEvents.length - 1; index >= 0; index -= 1) {
if (pendingAgentStatusEvents[index].data.paneKey === data.paneKey) {
pendingAgentStatusEvents.splice(index, 1)
}
}
}
// Why: drop in-flight events stamped with a dead connection's id after SSH disconnect/reconnect — see docs/design/agent-status-over-ssh.md §5.
// Why: startup snapshot replay can beat SSH repo hydration; accept when worktreeId matches the tab until repo ownership resolves.
// Why: WSL relay stamps a `wsl:<distro>` connectionId but the pane is a local repo (ownership null); normalize so the strict check below doesn't drop it.
const ownershipConnectionId = isWslHookRelayConnectionId(data.connectionId)
? null
: data.connectionId
const transientClearWatermark =
typeof data.connectionId === 'string'
? transientClearWatermarkByConnectionId.get(data.connectionId)
: undefined
// Why: delayed snapshots/queued relay events must not resurrect a status cleared by a newer disconnect on this connection.
if (transientClearWatermark !== undefined && data.receivedAt <= transientClearWatermark) {
return 'dropped'
}
const canAcceptPendingRemoteOwnership =
ownershipConnectionId !== undefined &&
ownershipConnectionId !== null &&
!repoConnectionResolved &&
data.worktreeId !== undefined &&
data.worktreeId === owningWorktreeId
if (
ownershipConnectionId !== undefined &&
ownershipConnectionId !== repoConnectionId &&
!canAcceptPendingRemoteOwnership
) {
return 'dropped'
}
const existingStatus = store.agentStatusByPaneKey[paneKey]
if (existingStatus && data.receivedAt < existingStatus.updatedAt) {
// Why: the store rejects out-of-order status rows; keep metadata-only session identity on the same event boundary.
return 'dropped'
}
if (data.providerSessionOnly) {
if (!data.providerSession || data.agentType !== 'pi') {
return 'dropped'
}
store.recordAgentProviderSession(
paneKey,
'pi',
data.providerSession,
{ updatedAt: data.receivedAt },
{
tabId: ownerTabId,
worktreeId: data.worktreeId ?? owningWorktreeId,
// Why: persist the WSL-normalized ownership id, not raw relay provenance; a `wsl:*` connectionId would misroute later resumes.
...(ownershipConnectionId !== undefined ? { connectionId: ownershipConnectionId } : {})
},
data.launchToken ? { launchToken: data.launchToken } : undefined
)
return 'applied'
}
const resolvedPayload = resolveHookPayloadAgentType(payload, identityTitle ?? title)
const statusPayload = data.orchestration
? { ...resolvedPayload, orchestration: data.orchestration }
: resolvedPayload
const statusPayloadWithTurnBoundary = data.promptInteractionKey
? { ...statusPayload, promptInteractionKey: data.promptInteractionKey }
: statusPayload
// Why: hydrated-unconfirmed provenance is envelope data the payload whitelist above drops; re-thread it or freshness gates confirm restored rows.
const statusPayloadWithProvenance =
data.restoredUnconfirmed === true
? { ...statusPayloadWithTurnBoundary, restoredUnconfirmed: true }
: statusPayloadWithTurnBoundary
const identity = resolveAgentStatusIdentity({
existing: existingStatus
? {
agentType: existingStatus.agentType,
state: existingStatus.state,
updatedAt: existingStatus.updatedAt,
restoredUnconfirmed: existingStatus.restoredUnconfirmed
}
: undefined,
incoming: statusPayload.agentType,
now: data.receivedAt
})
if (
existingStatus &&
shouldSuppressInheritedTerminalStatus({
inheritedFromActivePane: identity.inheritedFromActivePane,
incomingState: statusPayload.state
})
) {
// Why: guards against a stale main-process child completion resurrecting terminal status.
return 'dropped'
}
if (
shouldSuppressCodexAutoApprovalStatus(statusPayload, {
paneKey,
tabId: ownerTabId,
terminalHandle: data.terminalHandle,
launchToken: data.launchToken,
providerSession: data.providerSession,
existingProviderSession: existingStatus?.providerSession
})
) {
// Why: Codex yolo permission hooks are not user-actionable; they must not drive status, titles, badges, or notifications.
return 'dropped'
}
const terminalTitle = resolveAgentStatusTerminalTitle(statusPayload, title)
const statusWorktreeId = data.worktreeId ?? owningWorktreeId
store.setAgentStatus(
paneKey,
statusPayloadWithProvenance,
terminalTitle,
{
updatedAt: data.receivedAt,
stateStartedAt: data.stateStartedAt
},
{
tabId: ownerTabId,
worktreeId: statusWorktreeId,
terminalHandle: data.terminalHandle,
...(ownershipConnectionId !== undefined ? { connectionId: ownershipConnectionId } : {})
},
data.providerSession || data.launchToken
? {
...(data.providerSession ? { providerSession: data.providerSession } : {}),
...(data.launchToken ? { launchToken: data.launchToken } : {})
}
: undefined
)
applyResolvedAgentTerminalTitleToTab(store, paneKey, title, terminalTitle)
if (options?.replay !== true && statusWorktreeId) {
// Why: local Codex/Claude hooks arrive via this main-process IPC path, not the PTY OSC fallback, so task-complete notifications must observe accepted hook state here too.
const notificationPayload =
typeof data.stateStartedAt === 'number'
? { ...resolvedPayload, stateStartedAt: data.stateStartedAt }
: resolvedPayload
observeAgentHookCompletionForNotification({
paneKey,
worktreeId: statusWorktreeId,
payload: notificationPayload
})
}
return 'applied'
}
let snapshotRequestedForReadyWindow = false
let snapshotRequestId = 0
const requestAgentStatusSnapshotIfReady = (): void => {
const store = useAppStore.getState()
if (!store.workspaceSessionReady) {
snapshotRequestedForReadyWindow = false
return
}
if (snapshotRequestedForReadyWindow) {
return
}
const getSnapshot = window.api.agentStatus.getSnapshot
if (typeof getSnapshot !== 'function') {
return
}
snapshotRequestedForReadyWindow = true
const requestId = ++snapshotRequestId
void getSnapshot()
.then((entries) => {
if (agentStatusEffectDisposed || requestId !== snapshotRequestId) {
return
}
const current = useAppStore.getState()
if (!current.workspaceSessionReady) {
return
}
for (const entry of entries) {
applyAgentStatus(entry, { replay: true })
}
const getMigrationUnsupportedSnapshot =
window.api.agentStatus.getMigrationUnsupportedSnapshot
if (typeof getMigrationUnsupportedSnapshot !== 'function') {
return
}
void getMigrationUnsupportedSnapshot().then((unsupportedEntries) => {
if (agentStatusEffectDisposed || requestId !== snapshotRequestId) {
return
}
const unsupportedStore = useAppStore.getState()
if (!unsupportedStore.workspaceSessionReady) {
return
}
for (const entry of unsupportedEntries) {
if (entry.paneKey && resolvePaneKey(unsupportedStore, entry.paneKey).exists) {
unsupportedStore.setMigrationUnsupportedPty(entry)
}
}
})
})
.catch((err) => {
// Why: stay latched on failure; the store subscriber fires on every update, so resetting here would turn a persistent IPC failure into a retry storm (flag clears on workspaceSessionReady toggle).
console.warn('[agent-status] failed to load startup snapshot:', err)
})
}
function flushLiveAgentStatusBurst(): void {
liveAgentStatusBurstTimer = null
lastLiveAgentStatusApplyAt = Date.now()
// Why: splice before applying — applyAgentStatus can synchronously
// re-enter the queue via subscribers, and it must not see this batch.
const batch = liveAgentStatusBurstQueue.splice(0)
let anyApplied = false
for (const data of batch) {
if (applyAgentStatus(data) === 'applied') {
anyApplied = true
}
}
if (!anyApplied) {
lastLiveAgentStatusApplyAt = 0
}
}
function drainQueuedLiveAgentStatusesForPane(paneKey: string): void {
const queuedForPane: AgentStatusIpcPayload[] = []
const remaining: AgentStatusIpcPayload[] = []
for (const queued of liveAgentStatusBurstQueue) {
if (queued.paneKey === paneKey) {
queuedForPane.push(queued)
} else {
remaining.push(queued)
}
}
liveAgentStatusBurstQueue.length = 0
liveAgentStatusBurstQueue.push(...remaining)
for (const queued of queuedForPane) {
applyAgentStatus(queued)
}
}
function enqueueLiveAgentStatus(data: AgentStatusIpcPayload): void {
const now = Date.now()
if (
liveAgentStatusBurstTimer === null &&
now - lastLiveAgentStatusApplyAt >= LIVE_AGENT_STATUS_BURST_WINDOW_MS
) {
lastLiveAgentStatusApplyAt = now
// Why: only an applied event commits state and costs a render pass —
// a dropped/pending leading edge must not make its successor pay
// burst latency (startup replay and unmounted panes stay immediate).
if (applyAgentStatus(data) !== 'applied') {
lastLiveAgentStatusApplyAt = 0
}
return
}
liveAgentStatusBurstQueue.push(data)
if (liveAgentStatusBurstTimer === null) {
liveAgentStatusBurstTimer = globalThis.setTimeout(
flushLiveAgentStatusBurst,
LIVE_AGENT_STATUS_BURST_WINDOW_MS
)
}
}
unsubs.push(
window.api.agentStatus.onSet((data) => {
enqueueLiveAgentStatus(data)
})
)
const unsubscribeAgentStatusClear = window.api.agentStatus.onClear?.(
(data: AgentStatusClearIpcPayload) => {
if (typeof data !== 'object' || data === null) {
return
}
if ('transient' in data && data.transient === true) {
if (
typeof data.connectionId !== 'string' ||
data.connectionId.length === 0 ||
!Number.isFinite(data.clearedAt)
) {
return
}
const previousWatermark =
transientClearWatermarkByConnectionId.get(data.connectionId) ?? -1
const effectiveWatermark = Math.max(previousWatermark, data.clearedAt)
transientClearWatermarkByConnectionId.set(data.connectionId, effectiveWatermark)
for (let index = pendingAgentStatusEvents.length - 1; index >= 0; index -= 1) {
const pending = pendingAgentStatusEvents[index].data
if (
pending.connectionId === data.connectionId &&
pending.receivedAt <= effectiveWatermark
) {
pendingAgentStatusEvents.splice(index, 1)
}
}
for (let index = liveAgentStatusBurstQueue.length - 1; index >= 0; index -= 1) {
const queued = liveAgentStatusBurstQueue[index]
if (
queued.connectionId === data.connectionId &&
queued.receivedAt <= effectiveWatermark
) {
liveAgentStatusBurstQueue.splice(index, 1)
}
}
useAppStore.getState().clearTransientAgentStatuses(data.connectionId, effectiveWatermark)
return
}
if (!('paneKey' in data) || typeof data.paneKey !== 'string') {
return
}
// Why: preserve set→clear FIFO so a queued completion still survives pane teardown.
if (liveAgentStatusBurstQueue.some((queued) => queued.paneKey === data.paneKey)) {
drainQueuedLiveAgentStatusesForPane(data.paneKey)
}
for (let index = pendingAgentStatusEvents.length - 1; index >= 0; index -= 1) {
if (pendingAgentStatusEvents[index].data.paneKey === data.paneKey) {
pendingAgentStatusEvents.splice(index, 1)
}
}
const store = useAppStore.getState()
if (store.agentStatusByPaneKey[data.paneKey]?.state === 'done') {
return
}
store.removeAgentStatus(data.paneKey)
}
)
if (unsubscribeAgentStatusClear) {
unsubs.push(unsubscribeAgentStatusClear)
}
const unsubscribeMigrationUnsupported = window.api.agentStatus.onMigrationUnsupported?.(
(entry) => {
const store = useAppStore.getState()
if (!store.workspaceSessionReady) {
return
}
if (entry.paneKey && resolvePaneKey(store, entry.paneKey).exists) {
store.setMigrationUnsupportedPty(entry)
}
}
)
if (unsubscribeMigrationUnsupported) {
unsubs.push(unsubscribeMigrationUnsupported)
}
const unsubscribeMigrationUnsupportedClear =
window.api.agentStatus.onMigrationUnsupportedClear?.(({ ptyId }) => {
useAppStore.getState().clearMigrationUnsupportedPty(ptyId)
})
if (unsubscribeMigrationUnsupportedClear) {
unsubs.push(unsubscribeMigrationUnsupportedClear)
}
const unsubscribeLegacyWorkerTerminalRecovery =
window.api.agentStatus.onLegacyWorkerTerminalRecovery?.((event) => {
const action = resolveLegacyWorkerTerminalRecoveryAction(event)
if (action.kind === 'rollback-surface') {
window.dispatchEvent(
new CustomEvent(CLOSE_TERMINAL_PANE_EVENT, { detail: action.detail })
)
rollbackLegacyWorkerTerminalSurfaceInStore(useAppStore.getState(), action.detail)
} else if (action.kind === 'clear-sleeping') {
useAppStore.getState().clearSleepingAgentSession(action.paneKey)
}
})
if (unsubscribeLegacyWorkerTerminalRecovery) {
unsubs.push(unsubscribeLegacyWorkerTerminalRecovery)
}
// Why: main hook server is the durable source of truth; pull the snapshot only after tabs are ready so early startup pushes can be ignored, not buffered.
requestAgentStatusSnapshotIfReady()
const unsubscribeAgentStatusStore = useAppStore.subscribe((state, previousState) => {
requestAgentStatusSnapshotIfReady()
flushPendingAgentStatuses()
syncAgentHookCompletionNotificationsForStoreUpdate(state, previousState)
})
let mobileStateHydrated = isRuntimeEnvironmentActive()
type PendingMobileStateEvent =
| {
kind: 'fit'
event: {
ptyId: string
mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit'
cols: number
rows: number
}
}
| {
kind: 'driver'
event: {
ptyId: string
driver: RuntimeTerminalDriverState
}
}
| {
kind: 'browser-driver'
event: {
browserPageId: string
driver: RuntimeBrowserDriverState
}
}
const pendingMobileStateEvents: PendingMobileStateEvent[] = []
let mobileStateHydrationDisposed = false
const applyPendingMobileStateEvents = (): void => {
for (const pending of pendingMobileStateEvents) {
if (pending.kind === 'fit') {
const { ptyId, mode, cols, rows } = pending.event
setFitOverride(ptyId, mode, cols, rows)
} else if (pending.kind === 'driver') {
setDriverForPty(pending.event.ptyId, pending.event.driver)
} else {
setDriverForBrowserPage(pending.event.browserPageId, pending.event.driver)
}
}
pendingMobileStateEvents.length = 0
}
const enqueuePendingMobileStateEvent = (event: PendingMobileStateEvent): void => {
pendingMobileStateEvents.push(event)
while (pendingMobileStateEvents.length > MAX_PENDING_MOBILE_STATE_EVENTS) {
pendingMobileStateEvents.shift()
}
}
unsubs.push(
window.api.runtime.onTerminalFitOverrideChanged((event) => {
if (isRuntimeEnvironmentActive()) {
return
}
if (!mobileStateHydrated) {
enqueuePendingMobileStateEvent({ kind: 'fit', event })
return
}
setFitOverride(event.ptyId, event.mode, event.cols, event.rows)
})
)
unsubs.push(
// Why: mirror presence-lock driver state so TerminalPane / pty-connection guards know which PTYs are mobile-driven. See docs/mobile-presence-lock.md.
window.api.runtime.onTerminalDriverChanged((event) => {
if (isRuntimeEnvironmentActive()) {
return
}
if (!mobileStateHydrated) {
enqueuePendingMobileStateEvent({ kind: 'driver', event })
return
}
setDriverForPty(event.ptyId, event.driver)
})
)
const unsubscribeLaunchDraftResolution = window.api.runtime.onNativeChatLaunchDraftResolved?.(
(event) => {
applyNativeChatLaunchDraftResolved(useAppStore.getState(), {
type: 'nativeChatLaunchDraftResolved',
...event
})
}
)
if (unsubscribeLaunchDraftResolution) {
unsubs.push(unsubscribeLaunchDraftResolution)
}
unsubs.push(
window.api.runtime.onBrowserDriverChanged((event) => {
if (isRuntimeEnvironmentActive()) {
return
}
if (!mobileStateHydrated) {
enqueuePendingMobileStateEvent({ kind: 'browser-driver', event })
return
}
setDriverForBrowserPage(event.browserPageId, event.driver)
})
)
// Why: subscribe before the snapshot round trip and buffer live events; otherwise an older snapshot could overwrite a newer live lock and hide the overlay.
if (!isRuntimeEnvironmentActive()) {
void Promise.all([
window.api.runtime.getTerminalFitOverrides(),
window.api.runtime.getTerminalDrivers(),
window.api.runtime.getBrowserDrivers()
])
.then(([overrides, drivers, browserDrivers]) => {
if (mobileStateHydrationDisposed) {
return
}
hydrateOverrides(overrides)
hydrateDrivers(drivers)
hydrateBrowserDrivers(browserDrivers)
mobileStateHydrated = true
applyPendingMobileStateEvents()
})
.catch((error: unknown) => {
if (mobileStateHydrationDisposed) {
return
}
console.error('Failed to hydrate mobile terminal state:', error)
mobileStateHydrated = true
applyPendingMobileStateEvents()
})
}
return () => {
// Why: React remount can leave an older snapshot promise in flight; it must not write through after the replacement effect processes a clear.
agentStatusEffectDisposed = true
snapshotRequestId += 1
if (pendingAgentStatusRetryTimer !== null) {
globalThis.clearTimeout(pendingAgentStatusRetryTimer)
}
pendingAgentStatusEvents.length = 0
if (liveAgentStatusBurstTimer !== null) {
globalThis.clearTimeout(liveAgentStatusBurstTimer)
liveAgentStatusBurstTimer = null
}
liveAgentStatusBurstQueue.length = 0
mobileStateHydrationDisposed = true
pendingMobileStateEvents.length = 0
unsubscribeRuntimeEnvironmentStore()
unsubscribeAgentStatusStore()
unsubs.forEach((fn) => fn())
directSshEffectStopped = true
for (const deadline of authorityReconciliationDeadlines) {
clearTimeout(deadline.timer)
deadline.settle()
}
authorityReconciliationDeadlines.clear()
remoteWorkspaceTargetSync?.stop()
hostHydration.stop()
reconnectCoordinator.stop()
reconnectAuthorityByTarget.clear()
resetAgentHookCompletionNotificationCoordinators()
}
}, [])
}
function hasRuntimeBackedWorktreeAttribution(data: AgentStatusIpcPayload): boolean {
return (
(typeof data.terminalHandle === 'string' && data.terminalHandle.length > 0) ||
data.orchestration !== undefined
)
}
function tryMakePaneKey(tabId: string, leafId: string): string | null {
try {
return makePaneKey(tabId, leafId)
} catch {
return null
}
}
function applyResolvedAgentTerminalTitleToTab(
store: ReturnType<typeof useAppStore.getState>,
paneKey: string,
previousTitle: string | undefined,
nextTitle: string | undefined
): void {
if (!nextTitle || nextTitle === previousTitle) {
return
}
const parsed = parsePaneKey(paneKey)
if (!parsed) {
return
}
const layout = store.terminalLayoutsByTabId?.[parsed.tabId]
if (layout?.root && layout.activeLeafId && layout.activeLeafId !== parsed.leafId) {
return
}
// Why: hook completion can arrive while the pane transport is unmounted; keep the tab label synced to the resolved state title.
store.updateTabTitle(parsed.tabId, nextTitle)
}
/** Resolve a paneKey (tabId:leafId) to liveness, current title, owning worktree,
* and the owning repo's connectionId. Used for agent-type inference and to drop
* status updates for torn-down tabs or dead connections
* (see docs/design/agent-status-over-ssh.md §5). */
function resolvePaneKey(
store: ReturnType<typeof useAppStore.getState>,
paneKey: string
): {
exists: boolean
title: string | undefined
identityTitle: string | undefined
repoConnectionId: string | null
repoConnectionResolved: boolean
owningWorktreeId: string | undefined
} {
const parsed = parsePaneKey(paneKey)
if (!parsed) {
return {
exists: false,
title: undefined,
identityTitle: undefined,
repoConnectionId: null,
repoConnectionResolved: false,
owningWorktreeId: undefined
}
}
const { tabId, leafId } = parsed
const layout = store.terminalLayoutsByTabId?.[tabId]
let exists = false
let tabTitle: string | undefined
let unifiedTabLabel: string | undefined
let owningWorktreeId: string | undefined
for (const [worktreeId, tabs] of Object.entries(store.tabsByWorktree)) {
for (const tab of tabs) {
if (tab.id === tabId) {
exists = true
tabTitle = tab.title
owningWorktreeId = worktreeId
const visibleTab = (store.unifiedTabsByWorktree?.[worktreeId] ?? []).find(
(entry) => entry.contentType === 'terminal' && entry.entityId === tabId
)
const rawVisibleLabel = visibleTab?.label?.trim()
unifiedTabLabel =
rawVisibleLabel && rawVisibleLabel.length > 0 ? rawVisibleLabel : undefined
break
}
}
if (exists) {
break
}
}
// Why: keep "resolved to a local repo" distinct from "not hydrated yet" so callers filter strictly post-hydration but still accept SSH snapshots during the startup ownership gap.
let repoConnectionId: string | null = null
let repoConnectionResolved = false
if (owningWorktreeId !== undefined) {
const worktree = getWorktreeMapFromState(store).get(owningWorktreeId)
if (worktree) {
const repo = getRepoMapFromState(store).get(worktree.repoId)
repoConnectionResolved = repo !== undefined
repoConnectionId = repo?.connectionId ?? null
}
}
if (!exists) {
return {
exists: false,
title: undefined,
identityTitle: undefined,
repoConnectionId,
repoConnectionResolved,
owningWorktreeId
}
}
// Why: an empty layout snapshot from a worktree switch (tab/PTY still live) counts as missing metadata; a non-empty layout lacking the leaf still means closed.
const leafExists = layout?.root ? collectLeafIdsInOrder(layout.root).includes(leafId) : true
if (!leafExists) {
return {
exists: false,
title: undefined,
identityTitle: undefined,
repoConnectionId,
repoConnectionResolved,
owningWorktreeId
}
}
// Why: inactive worktrees can have a durable tab and live PTY while the layout is unmounted; hook state must still land there.
const rawPaneTitle = layout?.titlesByLeafId?.[leafId]
// Why: treat empty-string paneTitle as "no title" so the tab-level fallback fires; nullish-coalescing on '' would short-circuit and erase cached terminalTitle.
const paneTitle = rawPaneTitle && rawPaneTitle.length > 0 ? rawPaneTitle : undefined
return {
exists,
title: paneTitle ?? tabTitle,
// Why: some agents (OpenClaude) keep the terminal title generic while the tab label carries the agent identity; use only the non-custom label for attribution.
identityTitle: paneTitle ?? unifiedTabLabel ?? tabTitle,
repoConnectionId,
repoConnectionResolved,
owningWorktreeId
}
}
function resolveWorktreeConnection(
store: ReturnType<typeof useAppStore.getState>,
worktreeId: string
): {
worktreeExists: boolean
repoConnectionId: string | null
repoConnectionResolved: boolean
} {
const worktree = getWorktreeMapFromState(store).get(worktreeId)
if (!worktree) {
return { worktreeExists: false, repoConnectionId: null, repoConnectionResolved: false }
}
const repo = getRepoMapFromState(store).get(worktree.repoId)
return {
worktreeExists: true,
repoConnectionId: repo?.connectionId ?? null,
repoConnectionResolved: repo !== undefined
}
}
function resolveHookPayloadAgentType(
payload: ParsedAgentStatusPayload,
terminalTitle: string | undefined
): ParsedAgentStatusPayload {
if (
payload.agentType !== 'claude' ||
!terminalTitle ||
!titleHasAgentName(terminalTitle, 'openclaude')
) {
return payload
}
// Why: OpenClaude emits Claude-compatible hooks; the title is the last renderer signal to keep it out of Claude-only status paths.
return { ...payload, agentType: 'openclaude' }
}