From 2c77f71c4f2ce2bb00a8c61cf65e7f9442213680 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:45:23 -0700 Subject: [PATCH] refactor(renderer): split IPC event bridges (#16185) * refactor(renderer): split IPC event bridges * test(renderer): follow extracted IPC shortcut bridge --- config/max-lines-baseline.txt | 1 - .../app-shell/use-app-session-persistence.ts | 4 +- .../ipc-events/agent-dashboard-command.ts | 30 + .../ipc-events/agent-status-bridge-types.ts | 36 + .../agent-status-event-applicator.ts | 295 ++ .../ipc-events/agent-status-ipc-bridge.ts | 284 ++ .../ipc-events/agent-status-listeners.ts | 129 + .../agent-status-pane-routing-index.ts | 153 + .../hooks/ipc-events/agent-status-routing.ts | 217 + .../ipc-events/app-lifetime-ipc-bridge.ts | 122 + ...browser-automation-bootstrap-lease.test.ts | 63 + .../browser-automation-bootstrap-lease.ts | 75 + .../ipc-events/browser-request-ipc-bridge.ts | 242 + .../ipc-events/browser-session-tab-target.ts | 25 + .../ipc-events/browser-state-ipc-bridge.ts | 83 + .../ipc-events/content-creation-ipc-bridge.ts | 133 + .../ipc-events/direct-ssh-bridge-runtime.ts | 199 + .../ipc-events/direct-ssh-state-ipc-bridge.ts | 296 ++ .../ipc-events/mobile-driver-ipc-bridge.ts | 139 + .../mobile-terminal-close-ipc-bridge.ts | 116 + .../hooks/ipc-events/new-workspace-command.ts | 36 + .../normalize-agent-status-event.ts | 25 + .../ipc-events/project-catalog-ipc-bridge.ts | 101 + .../hooks/ipc-events/rate-limit-ipc-bridge.ts | 30 + .../ipc-events/remote-workspace-ipc-bridge.ts | 50 + .../ipc-events/runtime-client-ipc-bridge.ts | 199 + ...time-environment-subscription-selection.ts | 203 + .../ipc-events/session-tab-ipc-bridge.ts | 108 + .../ipc-events/settings-sidebar-ipc-bridge.ts | 190 + .../ipc-events/tab-lifecycle-ipc-bridge.ts | 180 + .../ipc-events/terminal-command-state.ts | 132 + .../terminal-presentation-ipc-bridge.ts | 272 + .../ipc-events/terminal-request-ipc-bridge.ts | 152 + .../terminal-ui-routing-ipc-bridge.ts | 95 + .../updater-status-ipc-bridge.test.ts | 62 + .../ipc-events/updater-status-ipc-bridge.ts | 21 + .../workspace-shortcut-ipc-bridge.ts | 136 + .../ipc-events/worktree-event-runtime.ts | 165 + .../src/hooks/ipc-events/zoom-ipc-bridge.ts | 48 + .../src/hooks/remote-workspace-target-sync.ts | 1 - ...IpcEvents-agent-dashboard-shortcut.test.ts | 2 +- .../useIpcEvents-browser-navigation.test.ts | 2 +- .../src/hooks/useIpcEvents-lifecycle.test.ts | 502 ++ ...seIpcEvents-new-workspace-shortcut.test.ts | 5 +- ...ents-runtime-environment-selectors.test.ts | 4 +- .../hooks/useIpcEvents-zoom-routing.test.ts | 12 +- src/renderer/src/hooks/useIpcEvents.ts | 4455 +---------------- .../workspace-activation-path-gate.test.ts | 5 +- 48 files changed, 5372 insertions(+), 4463 deletions(-) create mode 100644 src/renderer/src/hooks/ipc-events/agent-dashboard-command.ts create mode 100644 src/renderer/src/hooks/ipc-events/agent-status-bridge-types.ts create mode 100644 src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts create mode 100644 src/renderer/src/hooks/ipc-events/agent-status-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/agent-status-listeners.ts create mode 100644 src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.ts create mode 100644 src/renderer/src/hooks/ipc-events/agent-status-routing.ts create mode 100644 src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.test.ts create mode 100644 src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.ts create mode 100644 src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/browser-session-tab-target.ts create mode 100644 src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/content-creation-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/direct-ssh-bridge-runtime.ts create mode 100644 src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/mobile-driver-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/new-workspace-command.ts create mode 100644 src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts create mode 100644 src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/rate-limit-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/runtime-environment-subscription-selection.ts create mode 100644 src/renderer/src/hooks/ipc-events/session-tab-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/settings-sidebar-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/tab-lifecycle-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/terminal-command-state.ts create mode 100644 src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.test.ts create mode 100644 src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/workspace-shortcut-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts create mode 100644 src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 67f8212e06d..43aa99cf788 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -124,7 +124,6 @@ inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts inline src/renderer/src/hooks/useAutomationDispatchEvents.ts inline src/renderer/src/hooks/useComposerState.ts inline src/renderer/src/hooks/useEditorExternalWatch.ts -inline src/renderer/src/hooks/useIpcEvents.ts inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts diff --git a/src/renderer/src/app-shell/use-app-session-persistence.ts b/src/renderer/src/app-shell/use-app-session-persistence.ts index aed0685dbb7..5047feac381 100644 --- a/src/renderer/src/app-shell/use-app-session-persistence.ts +++ b/src/renderer/src/app-shell/use-app-session-persistence.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react' import { translate } from '@/i18n/i18n' import { useAppStore } from '../store' -import { isRemoteWorkspaceSnapshotApplyInProgress } from '../hooks/useIpcEvents' +import { isDirectSshRemoteWorkspaceApplyInProgress } from '../hooks/remote-workspace-snapshot-apply' import { createSessionWriteSubscriber } from '../lib/session-write-subscriber' import { buildActiveViewUnloadPatch } from '../lib/active-view-persist' import { @@ -76,7 +76,7 @@ export function useAppSessionPersistence(): void { useEffect(() => { return createSessionWriteSubscriber({ store: useAppStore, - shouldSchedulePersist: () => !isRemoteWorkspaceSnapshotApplyInProgress(), + shouldSchedulePersist: () => !isDirectSshRemoteWorkspaceApplyInProgress(), persist: ({ patch }) => { const state = useAppStore.getState() // Why: route each host's worktree-scoped slice to its own partition; return the local write so the remote-workspace upload chain below keeps its ordering. diff --git a/src/renderer/src/hooks/ipc-events/agent-dashboard-command.ts b/src/renderer/src/hooks/ipc-events/agent-dashboard-command.ts new file mode 100644 index 00000000000..32812f74d71 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-dashboard-command.ts @@ -0,0 +1,30 @@ +import type { AppState } from '../../store/types' + +export function toggleAgentDashboardFromShortcut( + state: Pick< + AppState, + | 'activeView' + | 'settings' + | 'agentDashboardDrawerOpen' + | 'setSidebarOpen' + | 'setAgentDashboardDrawerOpen' + >, + openPopout: () => void +): void { + if ( + state.activeView === 'settings' || + state.settings?.experimentalAgentDashboardPopout !== true + ) { + return + } + if (state.settings.experimentalAgentDashboardMode === 'popout') { + openPopout() + return + } + const nextOpen = !state.agentDashboardDrawerOpen + // The drawer self-closes with the sidebar: reveal only when opening, never while closing. + if (nextOpen) { + state.setSidebarOpen(true) + } + state.setAgentDashboardDrawerOpen(nextOpen) +} diff --git a/src/renderer/src/hooks/ipc-events/agent-status-bridge-types.ts b/src/renderer/src/hooks/ipc-events/agent-status-bridge-types.ts new file mode 100644 index 00000000000..c5d827eea73 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-bridge-types.ts @@ -0,0 +1,36 @@ +import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' +import type { AgentStatusBatchTransaction } from '@/store/slices/agent-status' +import type { AgentStatusPaneRoutingIndex } from './agent-status-pane-routing-index' + +export type PendingAgentStatusEvent = { + data: AgentStatusIpcPayload + firstSeenAt: number + replay: boolean +} + +export type AgentStatusApplyResult = 'applied' | 'pending' | 'dropped' + +type ProjectedAgentTabTitles = { + title: string | undefined + identityTitle: string | undefined +} + +export type AgentStatusBatchContext = { + transaction: AgentStatusBatchTransaction + routingIndex: AgentStatusPaneRoutingIndex + projectedTitlesByTabId: Map + tabTitlesByTabId: Map + notificationEffects: (() => void)[] +} + +export type AgentStatusBatchEvent = { + data: AgentStatusIpcPayload + replay?: boolean + retry?: boolean +} + +export type AgentStatusApplyOptions = { + replay?: boolean + retry?: boolean + batch?: AgentStatusBatchContext +} diff --git a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts new file mode 100644 index 00000000000..4e24bf97168 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts @@ -0,0 +1,295 @@ +import { isWslHookRelayConnectionId } from '../../../../shared/wsl-hook-relay-contract' +import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' +import { + resolveAgentStatusIdentity, + shouldSuppressInheritedTerminalStatus +} from '../../../../shared/agent-status-identity' +import { isDecorativeAgentTitleFrameChange } from '../../../../shared/agent-decorative-title-signature' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { shouldSuppressCodexAutoApprovalStatus } from '@/components/terminal-pane/codex-auto-approval-notification-suppression' +import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title' +import { track } from '@/lib/telemetry' +import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority' +import type { AgentStatusBatchUpdate, AgentStatusUpdate } from '@/store/slices/agent-status' +import { observeAgentHookCompletionForNotification } from '../agent-hook-completion-notifications' +import { useAppStore } from '../../store' +import { + applyResolvedAgentTerminalTitleToTab, + hasRuntimeBackedWorktreeAttribution, + isAgentStatusForRecentlyClosedTab, + resolveHookPayloadAgentType, + resolvePaneKey, + resolveWorktreeConnection, + shouldApplyResolvedAgentTerminalTitleToTab +} from './agent-status-routing' +import { + resolvePaneKeyFromRoutingIndex, + resolveWorktreeConnectionFromRoutingIndex +} from './agent-status-pane-routing-index' +import type { + AgentStatusApplyOptions, + AgentStatusApplyResult, + PendingAgentStatusEvent +} from './agent-status-bridge-types' +import { normalizeAgentStatusEvent } from './normalize-agent-status-event' + +export function createAgentStatusEventApplicator(args: { + pendingAgentStatusEvents: PendingAgentStatusEvent[] + transientClearWatermarkByConnectionId: Map + enqueuePendingAgentStatus: (data: AgentStatusIpcPayload, options?: { replay?: boolean }) => void +}): (data: AgentStatusIpcPayload, options?: AgentStatusApplyOptions) => AgentStatusApplyResult { + const { + pendingAgentStatusEvents, + transientClearWatermarkByConnectionId, + enqueuePendingAgentStatus + } = args + const applyAgentStatus = ( + data: AgentStatusIpcPayload, + options?: AgentStatusApplyOptions + ): AgentStatusApplyResult => { + const store = options?.batch?.transaction.getState() ?? 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 = normalizeAgentStatusEvent(data) + if (!payload) { + return 'dropped' + } + let { + exists, + title, + identityTitle, + repoConnectionId, + repoConnectionResolved, + owningWorktreeId, + titleUsesTabTitle + } = options?.batch + ? resolvePaneKeyFromRoutingIndex(options.batch.routingIndex, paneKey) + : resolvePaneKey(store, paneKey) + const projectedTitles = + titleUsesTabTitle && ownerTabId + ? options?.batch?.projectedTitlesByTabId.get(ownerTabId) + : undefined + if (projectedTitles) { + title = projectedTitles.title + identityTitle = projectedTitles.identityTitle + } + if (!exists && data.worktreeId && hasRuntimeBackedWorktreeAttribution(data)) { + const fallbackOwnership = options?.batch + ? resolveWorktreeConnectionFromRoutingIndex(options.batch.routingIndex, data.worktreeId) + : resolveWorktreeConnection(store, data.worktreeId) + if (fallbackOwnership.worktreeExists) { + owningWorktreeId = data.worktreeId + repoConnectionId = fallbackOwnership.repoConnectionId + repoConnectionResolved = fallbackOwnership.repoConnectionResolved + exists = true + } + } + if (!exists) { + 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) { + 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) + } + } + } + const ownershipConnectionId = isWslHookRelayConnectionId(data.connectionId) + ? null + : data.connectionId + const transientClearWatermark = + typeof data.connectionId === 'string' + ? transientClearWatermarkByConnectionId.get(data.connectionId) + : undefined + 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) { + return 'dropped' + } + if (data.providerSessionOnly) { + if (!data.providerSession || data.agentType !== 'pi') { + return 'dropped' + } + const providerSessionUpdate: AgentStatusBatchUpdate = { + kind: 'providerSession', + paneKey, + agent: 'pi', + providerSession: data.providerSession, + timing: { updatedAt: data.receivedAt }, + routing: { + tabId: ownerTabId, + worktreeId: data.worktreeId ?? owningWorktreeId, + ...(ownershipConnectionId !== undefined ? { connectionId: ownershipConnectionId } : {}) + }, + metadata: data.launchToken ? { launchToken: data.launchToken } : undefined + } + if (options?.batch) { + return options.batch.transaction.apply(providerSessionUpdate) ? 'applied' : 'dropped' + } + store.recordAgentProviderSession( + providerSessionUpdate.paneKey, + providerSessionUpdate.agent, + providerSessionUpdate.providerSession, + providerSessionUpdate.timing, + providerSessionUpdate.routing, + providerSessionUpdate.metadata + ) + 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 + const statusPayloadWithProvenance = + data.restoredUnconfirmed === true + ? { ...statusPayloadWithTurnBoundary, restoredUnconfirmed: true } + : statusPayloadWithTurnBoundary + const statusPayloadWithObservation = data.observation + ? { ...statusPayloadWithProvenance, observation: data.observation } + : statusPayloadWithProvenance + 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 + }) + ) { + return 'dropped' + } + if ( + shouldSuppressCodexAutoApprovalStatus(statusPayload, { + paneKey, + tabId: ownerTabId, + terminalHandle: data.terminalHandle, + launchToken: data.launchToken, + providerSession: data.providerSession, + existingProviderSession: existingStatus?.providerSession + }) + ) { + return 'dropped' + } + const terminalTitle = resolveAgentStatusTerminalTitle(statusPayload, title) + const statusWorktreeId = data.worktreeId ?? owningWorktreeId + const update: AgentStatusUpdate = { + paneKey, + payload: statusPayloadWithObservation, + terminalTitle, + timing: { + updatedAt: data.receivedAt, + stateStartedAt: data.stateStartedAt + }, + routing: { + tabId: ownerTabId, + worktreeId: statusWorktreeId, + terminalHandle: data.terminalHandle, + ...(ownershipConnectionId !== undefined ? { connectionId: ownershipConnectionId } : {}) + }, + metadata: + data.providerSession || data.launchToken + ? { + ...(data.providerSession ? { providerSession: data.providerSession } : {}), + ...(data.launchToken ? { launchToken: data.launchToken } : {}) + } + : undefined + } + const applyPostCommitNotification = (): void => { + if (statusWorktreeId && (options?.replay !== true || resolvedPayload.state === 'working')) { + const notificationPayload = + typeof data.stateStartedAt === 'number' + ? { ...resolvedPayload, stateStartedAt: data.stateStartedAt } + : resolvedPayload + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: statusWorktreeId, + payload: notificationPayload, + ...(options?.replay === true ? { seedOnly: true } : {}) + }) + } + } + if (options?.batch) { + if (!options.batch.transaction.apply(update)) { + return 'dropped' + } + options.batch.notificationEffects.push(applyPostCommitNotification) + if ( + terminalTitle && + shouldApplyResolvedAgentTerminalTitleToTab(store, paneKey, title, terminalTitle) + ) { + const tabId = parsePaneKey(paneKey)?.tabId + if (tabId) { + options.batch.tabTitlesByTabId.set(tabId, terminalTitle) + if (titleUsesTabTitle) { + const titleChanges = !title || !isDecorativeAgentTitleFrameChange(title, terminalTitle) + options.batch.projectedTitlesByTabId.set(tabId, { + title: titleChanges ? terminalTitle : title, + identityTitle: titleChanges ? terminalTitle : identityTitle + }) + } + } + } + } else { + store.setAgentStatus( + update.paneKey, + update.payload, + update.terminalTitle, + update.timing, + update.routing, + update.metadata + ) + applyResolvedAgentTerminalTitleToTab(useAppStore.getState(), paneKey, title, terminalTitle) + applyPostCommitNotification() + } + return 'applied' + } + + return applyAgentStatus +} diff --git a/src/renderer/src/hooks/ipc-events/agent-status-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/agent-status-ipc-bridge.ts new file mode 100644 index 00000000000..3a99527b1d3 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-ipc-bridge.ts @@ -0,0 +1,284 @@ +import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' +import { syncAgentHookCompletionNotificationsForStoreUpdate } from '../agent-hook-completion-notifications' +import { registerAgentStatusListeners } from './agent-status-listeners' +import { useAppStore } from '../../store' +import { + createAgentStatusPaneRoutingIndex, + resolvePaneKeyFromRoutingIndex +} from './agent-status-pane-routing-index' +import { createAgentStatusEventApplicator } from './agent-status-event-applicator' +import type { + AgentStatusApplyResult, + AgentStatusBatchContext, + AgentStatusBatchEvent, + PendingAgentStatusEvent +} from './agent-status-bridge-types' + +const PENDING_AGENT_STATUS_RETRY_MS = 100 +const PENDING_AGENT_STATUS_TTL_MS = 15_000 +const MAX_PENDING_AGENT_STATUS_EVENTS = 100 +const LIVE_AGENT_STATUS_BURST_WINDOW_MS = 33 + +export type AgentStatusIpcBridge = { + disposeAsyncState: () => void + unsubscribeStore: () => void +} + +export function registerAgentStatusIpcBridge(unsubs: (() => void)[]): AgentStatusIpcBridge { + const pendingAgentStatusEvents: PendingAgentStatusEvent[] = [] + const transientClearWatermarkByConnectionId = new Map() + let disposed = false + let pendingAgentStatusRetryTimer: ReturnType | null = null + let isFlushingAgentStatuses = false + const liveAgentStatusBurstQueue: AgentStatusIpcPayload[] = [] + let liveAgentStatusBurstTimer: ReturnType | null = null + let lastLiveAgentStatusApplyAt = 0 + 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 candidates = pendingAgentStatusEvents + .splice(0) + .filter((event) => now - event.firstSeenAt <= PENDING_AGENT_STATUS_TTL_MS) + let results: AgentStatusApplyResult[] + try { + results = applyAgentStatusBatch( + candidates.map((event) => ({ data: event.data, replay: event.replay, retry: true })) + ) + } catch (err) { + // Why: the queue was already spliced, so a throwing fold would drop the whole + // burst and strand every pane in it. Requeue ahead of newer arrivals and retry. + pendingAgentStatusEvents.unshift(...candidates) + throw err + } + for (let index = 0; index < candidates.length; index += 1) { + if (results[index] === 'pending') { + pendingAgentStatusEvents.push(candidates[index]) + } + } + if (pendingAgentStatusEvents.length === 0 && pendingAgentStatusRetryTimer !== null) { + globalThis.clearTimeout(pendingAgentStatusRetryTimer) + pendingAgentStatusRetryTimer = null + } + } finally { + isFlushingAgentStatuses = false + } + schedulePendingAgentStatusFlush() + } + + const applyAgentStatus = createAgentStatusEventApplicator({ + pendingAgentStatusEvents, + transientClearWatermarkByConnectionId, + enqueuePendingAgentStatus + }) + 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 (disposed || requestId !== snapshotRequestId) { + return + } + const current = useAppStore.getState() + if (!current.workspaceSessionReady) { + return + } + applyAgentStatusBatch(entries.map((data) => ({ data, replay: true }))) + const getMigrationUnsupportedSnapshot = + window.api.agentStatus.getMigrationUnsupportedSnapshot + if (typeof getMigrationUnsupportedSnapshot !== 'function') { + return + } + void getMigrationUnsupportedSnapshot().then((unsupportedEntries) => { + if (disposed || requestId !== snapshotRequestId) { + return + } + const unsupportedStore = useAppStore.getState() + if (!unsupportedStore.workspaceSessionReady) { + return + } + const unsupportedRoutingIndex = createAgentStatusPaneRoutingIndex(unsupportedStore) + for (const entry of unsupportedEntries) { + if ( + entry.paneKey && + resolvePaneKeyFromRoutingIndex(unsupportedRoutingIndex, 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 applyAgentStatusBatch( + events: readonly AgentStatusBatchEvent[] + ): AgentStatusApplyResult[] { + if (events.length === 0) { + return [] + } + return useAppStore.getState().transactAgentStatuses((transaction) => { + const batch: AgentStatusBatchContext = { + transaction, + routingIndex: createAgentStatusPaneRoutingIndex(transaction.getState()), + projectedTitlesByTabId: new Map(), + tabTitlesByTabId: new Map(), + notificationEffects: [] + } + const results = events.map(({ data, replay, retry }) => + applyAgentStatus(data, { batch, replay, retry }) + ) + if (batch.tabTitlesByTabId.size > 0) { + transaction.afterCommit(() => { + useAppStore + .getState() + .updateTabTitles( + [...batch.tabTitlesByTabId].map(([tabId, title]) => ({ tabId, title })) + ) + }) + } + for (const effect of batch.notificationEffects) { + transaction.afterCommit(effect) + } + return results + }) + } + + function applyLiveAgentStatusBatch(batch: readonly AgentStatusIpcPayload[]): boolean { + return applyAgentStatusBatch(batch.map((data) => ({ data }))).some( + (result) => result === 'applied' + ) + } + + function flushLiveAgentStatusBurst(): void { + liveAgentStatusBurstTimer = null + lastLiveAgentStatusApplyAt = Date.now() + // Why: splice before publishing — synchronous Zustand subscribers can enqueue the next burst. + const batch = liveAgentStatusBurstQueue.splice(0) + if (!applyLiveAgentStatusBatch(batch)) { + 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) + applyLiveAgentStatusBatch(queuedForPane) + } + + 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 + ) + } + } + + registerAgentStatusListeners({ + unsubs, + enqueueLiveAgentStatus, + drainQueuedLiveAgentStatusesForPane, + pendingAgentStatusEvents, + transientClearWatermarkByConnectionId, + liveAgentStatusBurstQueue + }) + + // 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) + }) + + return { + disposeAsyncState: () => { + disposed = true + snapshotRequestId += 1 + if (pendingAgentStatusRetryTimer !== null) { + globalThis.clearTimeout(pendingAgentStatusRetryTimer) + } + pendingAgentStatusEvents.length = 0 + if (liveAgentStatusBurstTimer !== null) { + globalThis.clearTimeout(liveAgentStatusBurstTimer) + liveAgentStatusBurstTimer = null + } + liveAgentStatusBurstQueue.length = 0 + }, + unsubscribeStore: unsubscribeAgentStatusStore + } +} diff --git a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts new file mode 100644 index 00000000000..70b13e22a41 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts @@ -0,0 +1,129 @@ +import { CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal' +import type { + AgentStatusClearIpcPayload, + AgentStatusIpcPayload +} from '../../../../shared/agent-status-types' +import { + resolveLegacyWorkerTerminalRecoveryAction, + rollbackLegacyWorkerTerminalSurfaceInStore +} from '../legacy-worker-terminal-recovery-event' +import { useAppStore } from '../../store' +import { resolvePaneKey } from './agent-status-routing' +import type { PendingAgentStatusEvent } from './agent-status-bridge-types' + +export function registerAgentStatusListeners(args: { + unsubs: (() => void)[] + enqueueLiveAgentStatus: (data: AgentStatusIpcPayload) => void + drainQueuedLiveAgentStatusesForPane: (paneKey: string) => void + pendingAgentStatusEvents: PendingAgentStatusEvent[] + transientClearWatermarkByConnectionId: Map + liveAgentStatusBurstQueue: AgentStatusIpcPayload[] +}): void { + const { + unsubs, + enqueueLiveAgentStatus, + drainQueuedLiveAgentStatusesForPane, + pendingAgentStatusEvents, + transientClearWatermarkByConnectionId, + liveAgentStatusBurstQueue + } = args + 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) + } +} diff --git a/src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.ts b/src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.ts new file mode 100644 index 00000000000..b7f9467bfd2 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.ts @@ -0,0 +1,153 @@ +import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization' +import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' +import type { AppState } from '../../store/types' + +type AgentStatusPaneResolution = { + exists: boolean + title: string | undefined + identityTitle: string | undefined + repoConnectionId: string | null + repoConnectionResolved: boolean + owningWorktreeId: string | undefined + titleUsesTabTitle: boolean +} + +type AgentStatusWorktreeConnectionResolution = { + worktreeExists: boolean + repoConnectionId: string | null + repoConnectionResolved: boolean +} + +type IndexedAgentStatusTab = { + title: string | undefined + unifiedLabel: string | undefined + owningWorktreeId: string +} + +export type AgentStatusPaneRoutingIndex = { + tabsById: Map + layoutsByTabId: AppState['terminalLayoutsByTabId'] + leafIdsByRoot: WeakMap> + worktreesById: ReturnType + reposById: ReturnType +} + +function createUnifiedTerminalLabelIndex( + entries: AppState['unifiedTabsByWorktree'][string] | undefined +): Map { + const labelsByTabId = new Map() + for (const entry of entries ?? []) { + if (entry.contentType !== 'terminal' || labelsByTabId.has(entry.entityId)) { + continue + } + const rawLabel = entry.label?.trim() + labelsByTabId.set(entry.entityId, rawLabel && rawLabel.length > 0 ? rawLabel : undefined) + } + return labelsByTabId +} + +export function createAgentStatusPaneRoutingIndex(store: AppState): AgentStatusPaneRoutingIndex { + const tabsById = new Map() + for (const [worktreeId, tabs] of Object.entries(store.tabsByWorktree)) { + const unifiedLabelsByTabId = createUnifiedTerminalLabelIndex( + store.unifiedTabsByWorktree?.[worktreeId] + ) + for (const tab of tabs) { + const tabId = tab.id + if (!tabsById.has(tabId)) { + tabsById.set(tabId, { + title: tab.title, + unifiedLabel: unifiedLabelsByTabId.get(tabId), + owningWorktreeId: worktreeId + }) + } + } + } + return { + tabsById, + layoutsByTabId: store.terminalLayoutsByTabId, + leafIdsByRoot: new WeakMap(), + worktreesById: getWorktreeMapFromState(store), + reposById: getRepoMapFromState(store) + } +} + +export function resolveWorktreeConnectionFromRoutingIndex( + index: AgentStatusPaneRoutingIndex, + worktreeId: string +): AgentStatusWorktreeConnectionResolution { + const worktree = index.worktreesById.get(worktreeId) + if (!worktree) { + return { worktreeExists: false, repoConnectionId: null, repoConnectionResolved: false } + } + const repo = index.reposById.get(worktree.repoId) + return { + worktreeExists: true, + repoConnectionId: repo?.connectionId ?? null, + repoConnectionResolved: repo !== undefined + } +} + +export function resolvePaneKeyFromRoutingIndex( + index: AgentStatusPaneRoutingIndex, + paneKey: string +): AgentStatusPaneResolution { + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return { + exists: false, + title: undefined, + identityTitle: undefined, + repoConnectionId: null, + repoConnectionResolved: false, + owningWorktreeId: undefined, + titleUsesTabTitle: false + } + } + const { tabId, leafId } = parsed + const tab = index.tabsById.get(tabId) + if (!tab) { + return { + exists: false, + title: undefined, + identityTitle: undefined, + repoConnectionId: null, + repoConnectionResolved: false, + owningWorktreeId: undefined, + titleUsesTabTitle: false + } + } + const connection = resolveWorktreeConnectionFromRoutingIndex(index, tab.owningWorktreeId) + const layout = index.layoutsByTabId?.[tabId] + if (layout?.root) { + let leafIds = index.leafIdsByRoot.get(layout.root) + if (!leafIds) { + leafIds = new Set(collectLeafIdsInOrder(layout.root)) + index.leafIdsByRoot.set(layout.root, leafIds) + } + if (!leafIds.has(leafId)) { + return { + exists: false, + title: undefined, + identityTitle: undefined, + repoConnectionId: connection.repoConnectionId, + repoConnectionResolved: connection.repoConnectionResolved, + owningWorktreeId: tab.owningWorktreeId, + titleUsesTabTitle: false + } + } + } + const rawPaneTitle = layout?.titlesByLeafId?.[leafId] + const paneTitle = rawPaneTitle && rawPaneTitle.length > 0 ? rawPaneTitle : undefined + return { + exists: true, + title: paneTitle ?? tab.title, + identityTitle: paneTitle ?? tab.unifiedLabel ?? tab.title, + repoConnectionId: connection.repoConnectionId, + repoConnectionResolved: connection.repoConnectionResolved, + owningWorktreeId: tab.owningWorktreeId, + titleUsesTabTitle: paneTitle === undefined + } +} diff --git a/src/renderer/src/hooks/ipc-events/agent-status-routing.ts b/src/renderer/src/hooks/ipc-events/agent-status-routing.ts new file mode 100644 index 00000000000..1aa80a282ef --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-routing.ts @@ -0,0 +1,217 @@ +import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization' +import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority' +import type { AppState } from '../../store/types' +import { titleHasAgentName } from '../../../../shared/agent-detection' +import type { + AgentStatusIpcPayload, + ParsedAgentStatusPayload +} from '../../../../shared/agent-status-types' +import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' +import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors' +import type { useAppStore } from '../../store' + +export function isAgentStatusForRecentlyClosedTab( + store: Pick, + paneKey: string +): boolean { + const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey) + if (store.recentlyRetiredAgentStatusPaneKeys?.[ownerPaneKey] === true) { + return true + } + const tabId = parsePaneKey(ownerPaneKey)?.tabId + return tabId ? store.recentlyClosedAgentStatusTabIds[tabId] === true : false +} + +export function hasRuntimeBackedWorktreeAttribution(data: AgentStatusIpcPayload): boolean { + return ( + (typeof data.terminalHandle === 'string' && data.terminalHandle.length > 0) || + data.orchestration !== undefined + ) +} + +export function tryMakePaneKey(tabId: string, leafId: string): string | null { + try { + return makePaneKey(tabId, leafId) + } catch { + return null + } +} + +export function applyResolvedAgentTerminalTitleToTab( + store: ReturnType, + paneKey: string, + previousTitle: string | undefined, + nextTitle: string | undefined +): void { + if ( + !nextTitle || + !shouldApplyResolvedAgentTerminalTitleToTab(store, paneKey, previousTitle, nextTitle) + ) { + return + } + const parsed = parsePaneKey(paneKey) + if (!parsed) { + 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) +} + +export function shouldApplyResolvedAgentTerminalTitleToTab( + store: ReturnType, + paneKey: string, + previousTitle: string | undefined, + nextTitle: string | undefined +): boolean { + if (!nextTitle || nextTitle === previousTitle) { + return false + } + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return false + } + const layout = store.terminalLayoutsByTabId?.[parsed.tabId] + if (layout?.root && layout.activeLeafId && layout.activeLeafId !== parsed.leafId) { + return false + } + return true +} + +/** 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 (an SSH reconnect retires the + * old connectionId, so events still in flight under it must not land). */ +export function resolvePaneKey( + store: ReturnType, + paneKey: string +): { + exists: boolean + title: string | undefined + identityTitle: string | undefined + repoConnectionId: string | null + repoConnectionResolved: boolean + owningWorktreeId: string | undefined + titleUsesTabTitle: boolean +} { + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return { + exists: false, + title: undefined, + identityTitle: undefined, + repoConnectionId: null, + repoConnectionResolved: false, + owningWorktreeId: undefined, + titleUsesTabTitle: false + } + } + 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, + titleUsesTabTitle: false + } + } + // 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, + titleUsesTabTitle: false + } + } + // 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, + titleUsesTabTitle: paneTitle === undefined + } +} + +export function resolveWorktreeConnection( + store: ReturnType, + 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 + } +} + +export 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' } +} diff --git a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts new file mode 100644 index 00000000000..f4bc7c6c007 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts @@ -0,0 +1,122 @@ +import { getTabIdsAwaitingHostHydrationRemount } from '@/lib/parked-terminal-host-hydration' +import { createBackgroundSleepingAgentWakeDispatcher } from '@/lib/wake-sleeping-agents-in-background' +import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge' +import { resetAgentHookCompletionNotificationCoordinators } from '../agent-hook-completion-notifications' +import { useAppStore } from '../../store' +import { registerAgentStatusIpcBridge } from './agent-status-ipc-bridge' +import { registerBrowserRequestIpcBridge } from './browser-request-ipc-bridge' +import { registerBrowserStateIpcBridge } from './browser-state-ipc-bridge' +import { registerContentCreationIpcBridge } from './content-creation-ipc-bridge' +import { createDirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' +import { registerDirectSshStateIpcBridge } from './direct-ssh-state-ipc-bridge' +import { registerMobileAndTerminalCloseIpcBridge } from './mobile-terminal-close-ipc-bridge' +import { registerMobileDriverIpcBridge } from './mobile-driver-ipc-bridge' +import { registerProjectCatalogIpcBridge } from './project-catalog-ipc-bridge' +import { registerRateLimitIpcBridge } from './rate-limit-ipc-bridge' +import { registerRemoteWorkspaceIpcBridge } from './remote-workspace-ipc-bridge' +import { registerRuntimeClientIpcBridge } from './runtime-client-ipc-bridge' +import { registerSessionTabIpcBridge } from './session-tab-ipc-bridge' +import { registerSettingsAndSidebarIpcBridge } from './settings-sidebar-ipc-bridge' +import { registerTabLifecycleIpcBridge } from './tab-lifecycle-ipc-bridge' +import { registerTerminalPresentationIpcBridge } from './terminal-presentation-ipc-bridge' +import { registerTerminalRequestIpcBridge } from './terminal-request-ipc-bridge' +import { registerTerminalUiRoutingIpcBridge } from './terminal-ui-routing-ipc-bridge' +import { registerUpdaterStatusIpcBridge } from './updater-status-ipc-bridge' +import { createWorktreeEventRuntime } from './worktree-event-runtime' +import { registerWorkspaceShortcutIpcBridge } from './workspace-shortcut-ipc-bridge' +import { registerZoomIpcBridge } from './zoom-ipc-bridge' + +function isRuntimeEnvironmentActive(): boolean { + return Boolean(useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim()) +} + +function remountTerminalTabsAwaitingHostHydration(): void { + const store = useAppStore.getState() + for (const tabId of getTabIdsAwaitingHostHydrationRemount(store)) { + store.remountTerminalTabForRecovery(tabId) + } +} + +export type IpcEventsCleanupPhase = + | 'agent.disposeAsyncState' + | 'mobile.disposeHydration' + | 'runtimeStore.unsubscribe' + | 'agentStore.unsubscribe' + | 'ipc.dispose' + | 'directSsh.stop' + | 'notifications.reset' + +export function installAppLifetimeIpcEvents( + onCleanupPhase?: (phase: IpcEventsCleanupPhase) => void +): () => void { + const unsubs: (() => void)[] = [] + const directSshRuntime = createDirectSshBridgeRuntime() + const backgroundWakeDispatcher = createBackgroundSleepingAgentWakeDispatcher() + unsubs.push(backgroundWakeDispatcher.dispose) + unsubs.push(attachMobileMarkdownBridge()) + + const worktreeRuntime = createWorktreeEventRuntime(unsubs, isRuntimeEnvironmentActive) + const unsubscribeRuntimeEnvironmentStore = registerRuntimeClientIpcBridge(unsubs, worktreeRuntime) + registerProjectCatalogIpcBridge( + unsubs, + worktreeRuntime.worktreeChangeRefreshQueue, + isRuntimeEnvironmentActive, + remountTerminalTabsAwaitingHostHydration + ) + registerSettingsAndSidebarIpcBridge(unsubs) + registerWorkspaceShortcutIpcBridge(unsubs) + unsubs.push( + window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup, startup, defaultTabs }) => { + void worktreeRuntime + .activateNotifiedWorktree( + { + type: 'activateWorktree', + repoId, + worktreeId, + ...(setup ? { setup } : {}), + ...(startup ? { startup } : {}), + ...(defaultTabs ? { defaultTabs } : {}) + }, + { allowRuntimeEnvironment: false } + ) + .catch((error) => console.error('Failed to activate CLI-created worktree:', error)) + }) + ) + + registerTerminalPresentationIpcBridge(unsubs) + registerTerminalRequestIpcBridge(unsubs) + registerTerminalUiRoutingIpcBridge(unsubs) + registerSessionTabIpcBridge(unsubs) + registerMobileAndTerminalCloseIpcBridge(unsubs, backgroundWakeDispatcher.request) + registerUpdaterStatusIpcBridge(unsubs) + registerBrowserStateIpcBridge(unsubs, isRuntimeEnvironmentActive) + registerContentCreationIpcBridge(unsubs, isRuntimeEnvironmentActive) + registerBrowserRequestIpcBridge(unsubs, isRuntimeEnvironmentActive) + registerTabLifecycleIpcBridge(unsubs) + registerRateLimitIpcBridge(unsubs) + registerDirectSshStateIpcBridge(unsubs, directSshRuntime) + registerRemoteWorkspaceIpcBridge(unsubs, directSshRuntime) + registerZoomIpcBridge(unsubs) + const agentStatusBridge = registerAgentStatusIpcBridge(unsubs) + const disposeMobileDriverHydration = registerMobileDriverIpcBridge( + unsubs, + isRuntimeEnvironmentActive + ) + + return () => { + agentStatusBridge.disposeAsyncState() + onCleanupPhase?.('agent.disposeAsyncState') + disposeMobileDriverHydration() + onCleanupPhase?.('mobile.disposeHydration') + unsubscribeRuntimeEnvironmentStore() + onCleanupPhase?.('runtimeStore.unsubscribe') + agentStatusBridge.unsubscribeStore() + onCleanupPhase?.('agentStore.unsubscribe') + unsubs.forEach((unsubscribe) => unsubscribe()) + onCleanupPhase?.('ipc.dispose') + directSshRuntime.stop() + onCleanupPhase?.('directSsh.stop') + resetAgentHookCompletionNotificationCoordinators() + onCleanupPhase?.('notifications.reset') + } +} diff --git a/src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.test.ts b/src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.test.ts new file mode 100644 index 00000000000..5bf85bca8bb --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { acquireBrowserAutomationBootstrapLease } from './browser-automation-bootstrap-lease' + +const mocks = vi.hoisted(() => ({ + acquireVisibility: vi.fn(), + releaseVisibility: vi.fn(), + requestBackgroundMount: vi.fn() +})) + +vi.mock('@/components/browser-pane/host-guest/browser-automation-visibility', () => ({ + acquireBrowserAutomationVisibility: mocks.acquireVisibility, + releaseBrowserAutomationVisibility: mocks.releaseVisibility +})) +vi.mock('@/components/terminal/background-terminal-worktree-mount', () => ({ + requestBackgroundTerminalWorktreeMount: mocks.requestBackgroundMount +})) +vi.mock('../../store', () => ({ + useAppStore: { + getState: () => ({ + activeWorktreeId: 'wt-active', + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + activeBrowserTabIdByWorktree: {} + }) + } +})) + +describe('browser automation bootstrap lease ownership', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + mocks.acquireVisibility + .mockReturnValueOnce('lease-1') + .mockReturnValueOnce('lease-2') + .mockReturnValueOnce('lease-3') + }) + + afterEach(() => { + vi.runOnlyPendingTimers() + vi.useRealTimers() + }) + + it('replaces a same-page lease and expires different page leases independently after 10s', () => { + acquireBrowserAutomationBootstrapLease('wt-1', 'page-1') + acquireBrowserAutomationBootstrapLease('wt-1', 'page-1') + acquireBrowserAutomationBootstrapLease('wt-2', 'page-2') + + expect(mocks.releaseVisibility).toHaveBeenCalledTimes(1) + expect(mocks.releaseVisibility).toHaveBeenLastCalledWith('lease-1') + expect(mocks.requestBackgroundMount.mock.calls).toEqual([ + [{ worktreeId: 'wt-1' }], + [{ worktreeId: 'wt-1' }], + [{ worktreeId: 'wt-2' }] + ]) + + vi.advanceTimersByTime(9_999) + expect(mocks.releaseVisibility).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(1) + expect(mocks.releaseVisibility.mock.calls).toEqual([['lease-1'], ['lease-2'], ['lease-3']]) + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.ts b/src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.ts new file mode 100644 index 00000000000..2bf48de78e8 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/browser-automation-bootstrap-lease.ts @@ -0,0 +1,75 @@ +import { + acquireBrowserAutomationVisibility, + releaseBrowserAutomationVisibility +} from '@/components/browser-pane/host-guest/browser-automation-visibility' +import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' +import { useAppStore } from '../../store' +import type { AppState } from '../../store/types' + +const BROWSER_AUTOMATION_BOOTSTRAP_LEASE_MS = 10_000 +const browserAutomationBootstrapLeaseByPageId = new Map() + +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 +} + +export 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 }) +} diff --git a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts new file mode 100644 index 00000000000..056b1857021 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts @@ -0,0 +1,242 @@ +import { destroyPersistentWebview } from '@/components/browser-pane/host-guest/webview-registry' +import { + guardPinnedTabClose, + isUnifiedTabPinned, + resolvePinnedTabLabel +} from '../../store/pinned-tab-close-guard' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '../../store' +import { acquireBrowserAutomationBootstrapLease } from './browser-automation-bootstrap-lease' + +export function registerBrowserRequestIpcBridge( + unsubs: (() => void)[], + isRuntimeEnvironmentActive: () => boolean +): void { + 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, + browserPageId: data.browserPageId, + 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 replyBrowserTabNotFound = (tabId: string): void => { + window.api.ui.replyTabClose({ + requestId: data.requestId, + code: 'browser_tab_not_found', + error: translate( + 'auto.hooks.useIpcEvents.0e3cf53060', + 'Browser tab {{value0}} not found', + { value0: tabId } + ) + }) + } + 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: isUnifiedTabPinned(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 + const owningWorktreeId = + Object.entries(store.browserTabsByWorktree).find(([, tabs]) => + tabs.some((tab) => tab.id === workspaceId) + )?.[0] ?? null + if (data.worktreeId && owningWorktreeId !== data.worktreeId) { + replyBrowserTabNotFound(tabToClose) + return + } + if (pages.length <= 1) { + 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) { + if (data.worktreeId && owningWorktreeId !== data.worktreeId) { + replyBrowserTabNotFound(tabToClose) + return + } + closeBrowserWorkspaceWithReply(owningWorktreeId, tabToClose) + return + } + if (explicitTargetId) { + replyBrowserTabNotFound(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' + }) + } + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/browser-session-tab-target.ts b/src/renderer/src/hooks/ipc-events/browser-session-tab-target.ts new file mode 100644 index 00000000000..193b0e22d92 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/browser-session-tab-target.ts @@ -0,0 +1,25 @@ +import type { AppState } from '../../store/types' + +export type BrowserSessionTabTarget = + | { kind: 'unified-browser'; unifiedTabId: string; workspaceId: string; groupId: string } + | { kind: 'fallback-browser'; workspaceId: string } + +export function resolveBrowserSessionTabTarget( + state: Pick, + 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 +} diff --git a/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts new file mode 100644 index 00000000000..0920bf9ecec --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/browser-state-ipc-bridge.ts @@ -0,0 +1,83 @@ +import { rememberLiveBrowserUrl } from '@/components/browser-pane/describe-page/live-browser-url-registry' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { redactKagiSessionToken } from '../../../../shared/browser-url' +import { useAppStore } from '../../store' +import { acquireBrowserAutomationBootstrapLease } from './browser-automation-bootstrap-lease' + +export function registerBrowserStateIpcBridge( + unsubs: (() => void)[], + isRuntimeEnvironmentActive: () => boolean +): void { + 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) + } + unsubs.push( + window.api.browser.onNavigationUpdate(({ browserPageId, url, title }) => { + if (isRuntimeEnvironmentActive()) { + return + } + const store = useAppStore.getState() + // The redacted live registry must precede the raw persisted store update. + rememberLiveBrowserUrl(browserPageId, redactKagiSessionToken(url)) + store.setBrowserPageUrl(browserPageId, url) + store.updateBrowserPageState(browserPageId, { title, loading: false }) + }) + ) + unsubs.push( + window.api.browser.onActivateView(({ worktreeId, browserPageId }) => { + if (!isRuntimeEnvironmentActive()) { + acquireBrowserAutomationBootstrapLease(worktreeId, browserPageId) + } + }) + ) + unsubs.push( + window.api.browser.onPaneFocus(({ worktreeId, browserPageId }) => { + if (isRuntimeEnvironmentActive()) { + return + } + const store = useAppStore.getState() + const targetWorktreeId = worktreeId ?? store.activeWorktreeId + if (targetWorktreeId) { + store.focusBrowserTabInWorktree(targetWorktreeId, 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 || getRuntimeEnvironmentIdForWorktree(store, sourcePage.worktreeId)) { + return + } + store.createBrowserTab(sourcePage.worktreeId, url, { title: url }) + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/content-creation-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/content-creation-ipc-bridge.ts new file mode 100644 index 00000000000..973a87b08ef --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/content-creation-ipc-bridge.ts @@ -0,0 +1,133 @@ +import { ensureSimulatorTab } from '@/lib/ensure-simulator-tab' +import { openMobileEmulatorTab } from '@/lib/open-mobile-emulator-tab' +import { + isManualSimulatorLaunchPending, + rememberPrelaunchedSimulatorSession +} from '@/lib/simulator-launch-coordination' +import { + createFloatingWorkspaceBrowserTab, + createFloatingWorkspaceMarkdownTab, + isFloatingWorkspacePanelFocused +} from '@/lib/floating-workspace-terminal-actions' +import { translate } from '@/i18n/i18n' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { toast } from 'sonner' +import { useAppStore } from '../../store' + +export function registerContentCreationIpcBridge( + unsubs: (() => void)[], + isRuntimeEnvironmentActive: () => boolean +): void { + unsubs.push( + window.api.ui.onNewBrowserTab(() => { + const store = useAppStore.getState() + if (isFloatingWorkspacePanelFocused()) { + void createFloatingWorkspaceBrowserTab(store).catch((error) => { + toast.error(error instanceof Error ? error.message : String(error)) + }) + return + } + const worktreeId = store.activeWorktreeId + if (!worktreeId) { + return + } + const targetGroupId = + store.activeGroupIdByWorktree[worktreeId] ?? store.groupsByWorktree[worktreeId]?.[0]?.id + if (!targetGroupId) { + return + } + void store.openNewBrowserTabInActiveWorkspace(targetGroupId).catch((error) => { + toast.error(error instanceof Error ? error.message : String(error)) + }) + }) + ) + + 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' }).catch((error) => { + toast.error(error instanceof Error ? error.message : String(error)) + }) + }) + 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, + executionHostId: LOCAL_EXECUTION_HOST_ID + }) + // 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, + executionHostId: LOCAL_EXECUTION_HOST_ID + }) + }) + if (unsubscribeEmulatorPaneFocus) { + unsubs.push(unsubscribeEmulatorPaneFocus) + } + + // Why: reply with the page ID so main can await registerGuest before returning to the CLI. +} diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-bridge-runtime.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-bridge-runtime.ts new file mode 100644 index 00000000000..3844d32ec0c --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-bridge-runtime.ts @@ -0,0 +1,199 @@ +import { createDirectSshReconnectProductTelemetryAdapter } from '@/lib/direct-ssh-reconnect-product-telemetry' +import { acquireDirectSshDetectedWorktreeRefresh } from '@/store/slices/worktrees' +import { toSshExecutionHostId } from '../../../../shared/execution-host' +import type { DirectSshAuthority } from '../../../../shared/ssh-types' +import { useAppStore } from '../../store' +import { + createDirectSshHostHydration, + type DirectSshHostHydration +} from '../direct-ssh-host-hydration' +import { + createDirectSshReconnectCoordinator, + type DirectSshPreparationInput, + type DirectSshPreparationReason, + type DirectSshReconnectCoordinator +} from '../direct-ssh-reconnect-coordinator' +import { directSshAuthoritiesEqual } from '../direct-ssh-reconnect-tokens' +import { createDirectSshWorktreeRefreshScheduler } from '../direct-ssh-worktree-refresh-scheduler' +import { + createRemoteWorkspaceTargetSync, + type RemoteWorkspaceTargetSync +} from '../remote-workspace-target-sync' +import type { AppState } from '../../store/types' + +type DirectSshTerminalActions = Partial< + Pick +> +type AuthorityDeadline = { timer: ReturnType; settle: () => void } + +export type DirectSshBridgeRuntime = { + reconnectAuthorityByTarget: Map + reconnectCoordinator: DirectSshReconnectCoordinator + hostHydration: DirectSshHostHydration + remoteWorkspaceTargetSync: RemoteWorkspaceTargetSync | null + currentAuthority: (targetId: string) => DirectSshAuthority | null + terminalActions: () => DirectSshTerminalActions + prepareAndSync: ( + authority: DirectSshAuthority, + reason: DirectSshPreparationReason, + options?: { authorityAlreadyReplaced?: boolean } + ) => Promise + isStopped: () => boolean + addDeadline: (deadline: AuthorityDeadline) => void + removeDeadline: (deadline: AuthorityDeadline) => void + stop: () => void +} + +export function createDirectSshBridgeRuntime(): DirectSshBridgeRuntime { + const reconnectAuthorityByTarget = new Map() + const deadlines = new Set() + let stopped = false + const currentAuthority = (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(currentAuthority(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 }) + ) + } + }) + const terminalActions = (): DirectSshTerminalActions => + useAppStore.getState() as DirectSshTerminalActions + let remoteWorkspaceTargetSync: RemoteWorkspaceTargetSync | null = null + const reconnectCoordinator = createDirectSshReconnectCoordinator({ + scheduler, + isCurrentConnectedAuthority: (authority) => + directSshAuthoritiesEqual(currentAuthority(authority.targetId), authority), + capturePreparationInput: hostHydration.capturePreparationInput, + readHostScopedLineage: hostHydration.readHostScopedLineage, + invalidateStaleTerminalBindings: (authority) => + terminalActions().invalidateStaleDirectSshTargetPtyBindings?.(authority) ?? 0, + retryTargetPanes: (authority) => terminalActions().retryDirectSshTargetPanes?.(authority) ?? 0, + finalizeHydratedTerminalPanes: (authority) => + terminalActions().retryDirectSshTargetPanes?.(authority) ?? 0, + correctUnboundTerminalPanes: (authority) => + terminalActions().retryDirectSshTargetPanes?.(authority) ?? 0, + syncRemoteWorkspaceAfterConnect: (token) => remoteWorkspaceTargetSync?.syncAfterConnect(token), + onTelemetry: createDirectSshReconnectProductTelemetryAdapter() + }) + const remoteWorkspaceApi = window.api.remoteWorkspace + if (remoteWorkspaceApi) { + remoteWorkspaceTargetSync = createRemoteWorkspaceTargetSync({ + store: useAppStore, + remoteWorkspace: remoteWorkspaceApi, + getCurrentAuthority: currentAuthority, + isPreparationTokenCurrent: hostHydration.isPreparationTokenCurrent, + capturePreparationInput: (authority, reason, revision) => + hostHydration.capturePreparationInput(authority, reason, revision), + prepareOnly: reconnectCoordinator.prepareOnly, + finalizeHydratedTerminals: (authority) => + directSshAuthoritiesEqual(reconnectAuthorityByTarget.get(authority.targetId), authority) + ? reconnectCoordinator.finalizeHydratedTerminals(authority) + : 0 + }) + } + const prepareAndSync: DirectSshBridgeRuntime['prepareAndSync'] = async ( + authority, + reason, + options + ) => { + 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(currentAuthority(authority.targetId), authority)) { + useAppStore.getState().setRemoteWorkspaceSyncStatus(authority.targetId, { + phase: 'error', + message: error instanceof Error ? error.message : 'Workspace sync failed' + }) + } + } + } + return { + reconnectAuthorityByTarget, + reconnectCoordinator, + hostHydration, + remoteWorkspaceTargetSync, + currentAuthority, + terminalActions, + prepareAndSync, + isStopped: () => stopped, + addDeadline: (deadline) => deadlines.add(deadline), + removeDeadline: (deadline) => deadlines.delete(deadline), + stop: () => { + stopped = true + for (const deadline of deadlines) { + clearTimeout(deadline.timer) + deadline.settle() + } + deadlines.clear() + remoteWorkspaceTargetSync?.stop() + hostHydration.stop() + reconnectCoordinator.stop() + reconnectAuthorityByTarget.clear() + } + } +} diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts new file mode 100644 index 00000000000..c29cb8ec19e --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts @@ -0,0 +1,296 @@ +import { canConnectSshStatus } from '@/ssh/ssh-connection-recoverability' +import type { DirectSshAuthority, SshConnectionState } from '../../../../shared/ssh-types' +import { useAppStore } from '../../store' +import { isDirectSshReconnectCoordinatorRoutingEnabled } from '../direct-ssh-reconnect-rollout' +import { directSshAuthoritiesEqual } from '../direct-ssh-reconnect-tokens' +import { + registerDirectSshWakeRouting, + routeDirectSshConnectedState, + type DirectSshConnectedStateOrigin +} from '../direct-ssh-state-routing' +import type { DirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' +export function registerDirectSshStateIpcBridge( + unsubs: (() => void)[], + runtime: DirectSshBridgeRuntime +): void { + const { + reconnectAuthorityByTarget, + reconnectCoordinator, + currentAuthority, + terminalActions, + prepareAndSync + } = runtime + const sshStateWatermarkByTargetId = new Map() + 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 => + !runtime.isStopped() && directSshAuthoritiesEqual(currentAuthority(targetId), authority) + const forwardHydration = window.api.ssh.listPortForwards({ targetId }).then((forwards) => { + 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) + } + }) + void Promise.allSettled([forwardHydration, detectedHydration]).then(() => { + if (pendingPortHydrationByTargetId.get(targetId) === pendingPortHydration) { + pendingPortHydrationByTargetId.delete(targetId) + } + }) + } + let applySshConnectionStateChange!: ( + targetId: string, + state: SshConnectionState, + origin: DirectSshConnectedStateOrigin + ) => void + void (async () => { + try { + const targets = await window.api.ssh.listTargets() + if (runtime.isStopped()) { + return + } + useAppStore.getState().setSshTargetsMetadata(targets) + try { + const removedLabels = await window.api.ssh.listRemovedTargetLabels() + if (runtime.isStopped()) { + return + } + useAppStore.getState().setRemovedSshTargetLabels(removedLabels) + } catch {} + for (const target of targets) { + const hydrationWatermark = sshStateWatermarkByTargetId.get(target.id) ?? 0 + const state = await window.api.ssh.getState({ targetId: target.id }) + if ( + !runtime.isStopped() && + state && + (sshStateWatermarkByTargetId.get(target.id) ?? 0) === hydrationWatermark + ) { + applySshConnectionStateChange(target.id, state as SshConnectionState, 'initial-hydration') + } + } + } catch {} + })() + 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; settle: () => void } | undefined + const deadline = new Promise((resolve) => { + const settle = (): void => resolve(null) + const timer = setTimeout(settle, 5_000) + pendingDeadline = { timer, settle } + runtime.addDeadline(pendingDeadline) + }) + void Promise.race([window.api.ssh.getState({ targetId }).catch(() => null), deadline]) + .then((latest) => { + if ( + runtime.isStopped() || + 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) + runtime.removeDeadline(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) + store.clearRemoteDetectedAgents(targetId) + + store.clearPortForwards(targetId) + store.setDetectedPorts(targetId, []) + + store.clearDirectSshTargetPtyBindings(targetId) + return + } + + if (state.status !== 'connected') { + return + } + const authority = currentAuthority(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) => + terminalActions().invalidateStaleDirectSshTargetPtyBindings?.(nextAuthority) ?? 0, + retryTargetPanes: (nextAuthority) => + terminalActions().retryDirectSshTargetPanes?.(nextAuthority) ?? 0, + prepareAndSync: prepareAndSync, + rememberReconnectAuthority: (nextAuthority) => { + if (nextAuthority) { + reconnectAuthorityByTarget.set(targetId, nextAuthority) + } else { + reconnectAuthorityByTarget.delete(targetId) + } + } + }, + { authority, previousAuthority, origin } + ) + if (origin === 'initial-hydration') { + hydrateSshPorts(targetId, authority) + } + } + + let sshTargetStateEventId = 0 + const latestSshTargetStateEventByTargetId = new Map() + + const 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)) { + window.api.ssh + .listTargets() + .catch(() => window.api.ssh.listTargets()) + .then((targets) => { + if (latestSshTargetStateEventByTargetId.get(data.targetId) !== stateEventId) { + return + } + latestSshTargetStateEventByTargetId.delete(data.targetId) + if (runtime.isStopped()) { + return + } + const latestStore = useAppStore.getState() + if (!targets.some((target) => target.id === data.targetId)) { + latestStore.clearRemovedSshTargetState(data.targetId) + return + } + latestStore.setSshTargetsMetadata(targets) + applySshConnectionStateChange(data.targetId, state, 'push') + }) + .catch(() => { + if ( + !runtime.isStopped() && + 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 prepareAndSync(authority, 'wake-refresh') + }, + ...(typeof window.api.ui.onSystemResumed === 'function' + ? { onSystemResumed: (callback: () => void) => window.api.ui.onSystemResumed(callback) } + : {}) + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/mobile-driver-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/mobile-driver-ipc-bridge.ts new file mode 100644 index 00000000000..1796afa8a89 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/mobile-driver-ipc-bridge.ts @@ -0,0 +1,139 @@ +import { + hydrateBrowserDrivers, + setDriverForBrowserPage +} from '@/lib/pane-manager/browser-mobile-driver-state' +import { setDriverForPty, hydrateDrivers } from '@/lib/pane-manager/mobile-driver-state' +import { setFitOverride, hydrateOverrides } from '@/lib/pane-manager/mobile-fit-overrides' +import { applyNativeChatLaunchDraftResolved } from '@/runtime/native-chat-launch-draft-runtime-resolution' +import type { + RuntimeBrowserDriverState, + RuntimeTerminalDriverState +} from '../../../../shared/runtime-types' +import { useAppStore } from '../../store' + +const MAX_PENDING_MOBILE_STATE_EVENTS = 300 + +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 } + } + +export function registerMobileDriverIpcBridge( + unsubs: (() => void)[], + isRuntimeEnvironmentActive: () => boolean +): () => void { + let mobileStateHydrated = isRuntimeEnvironmentActive() + const pendingMobileStateEvents: PendingMobileStateEvent[] = [] + let disposed = 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 enqueue = (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) { + enqueue({ kind: 'fit', event }) + return + } + setFitOverride(event.ptyId, event.mode, event.cols, event.rows) + }) + ) + unsubs.push( + window.api.runtime.onTerminalDriverChanged((event) => { + if (isRuntimeEnvironmentActive()) { + return + } + if (!mobileStateHydrated) { + enqueue({ 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) { + enqueue({ kind: 'browser-driver', event }) + return + } + setDriverForBrowserPage(event.browserPageId, event.driver) + }) + ) + + // Subscribe before snapshots; queued pushes replay in arrival order after all three hydrate. + if (!isRuntimeEnvironmentActive()) { + void Promise.all([ + window.api.runtime.getTerminalFitOverrides(), + window.api.runtime.getTerminalDrivers(), + window.api.runtime.getBrowserDrivers() + ]) + .then(([overrides, drivers, browserDrivers]) => { + if (disposed) { + return + } + hydrateOverrides(overrides) + hydrateDrivers(drivers) + hydrateBrowserDrivers(browserDrivers) + mobileStateHydrated = true + applyPendingMobileStateEvents() + }) + .catch((error: unknown) => { + if (disposed) { + return + } + console.error('Failed to hydrate mobile terminal state:', error) + mobileStateHydrated = true + applyPendingMobileStateEvents() + }) + } + + return () => { + disposed = true + pendingMobileStateEvents.length = 0 + } +} diff --git a/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts new file mode 100644 index 00000000000..8d58bcad3ea --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts @@ -0,0 +1,116 @@ +import { CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal' +import type { CloseTerminalPaneDetail } from '@/constants/terminal' +import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions' +import { detectLanguage } from '@/lib/language-detect' +import { runSleepWorktree } from '@/components/sidebar/sleep-worktree-flow' +import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { persistWorkspaceSessionByHost } from '@/lib/workspace-session-host-persistence' +import { useAppStore } from '../../store' + +export function registerMobileAndTerminalCloseIpcBridge( + unsubs: (() => void)[], + requestSleepingAgentWake: (worktreeId: string) => void +): void { + 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, localPtyTeardownOwnedExternally }) => { + let responded = false + const respond = (error?: string): void => { + if (responded) { + return + } + responded = true + window.api.ui.respondTerminalTabClose({ requestId, ...(error ? { error } : {}) }) + } + closeTerminalTab(tabId, { + rejectPinned: true, + ...(localPtyTeardownOwnedExternally ? { localPtyTeardownOwnedExternally: 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. + requestSleepingAgentWake(worktreeId) + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/new-workspace-command.ts b/src/renderer/src/hooks/ipc-events/new-workspace-command.ts new file mode 100644 index 00000000000..98dd2b045c2 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/new-workspace-command.ts @@ -0,0 +1,36 @@ +import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item' +import type { LinkedWorkItemSummary } from '@/lib/new-workspace' +import { getLinearIssueWorkspaceName } from '../../../../shared/workspace-name' +import type { AppState } from '../../store/types' + +type NewWorkspaceShortcutModalData = { + telemetrySource: 'shortcut' + prefilledName?: string + linkedWorkItem?: LinkedWorkItemSummary +} + +export function buildNewWorkspaceShortcutModalData( + state: Pick +): NewWorkspaceShortcutModalData { + const linearIssue = + state.activeView === 'tasks' ? (state.taskPageData.openLinearIssue ?? null) : null + if (!linearIssue) { + return { telemetrySource: 'shortcut' } + } + + return { + telemetrySource: 'shortcut', + prefilledName: getLinearIssueWorkspaceName(linearIssue), + // Cmd+N from a Linear issue mirrors its Start-workspace action with source context. + linkedWorkItem: buildLinearIssueLinkedWorkItem(linearIssue) + } +} + +export function openNewWorkspaceFromShortcut( + state: Pick +): void { + if (state.activeModal === 'new-workspace-composer') { + return + } + state.openModal('new-workspace-composer', buildNewWorkspaceShortcutModalData(state)) +} diff --git a/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts b/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts new file mode 100644 index 00000000000..d9c92610bbd --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts @@ -0,0 +1,25 @@ +import { + normalizeAgentStatusPayload, + type AgentStatusIpcPayload, + type ParsedAgentStatusPayload +} from '../../../../shared/agent-status-types' + +export function normalizeAgentStatusEvent( + data: AgentStatusIpcPayload +): ParsedAgentStatusPayload | null { + return normalizeAgentStatusPayload({ + state: data.state, + workingMode: data.workingMode, + prompt: data.prompt, + agentType: data.agentType, + model: data.model, + toolName: data.toolName, + toolInput: data.toolInput, + interactivePrompt: data.interactivePrompt, + lastAssistantMessage: data.lastAssistantMessage, + interrupted: data.interrupted, + sessionBoundary: data.sessionBoundary, + turnCompletedAt: data.turnCompletedAt, + subagents: data.subagents + }) +} diff --git a/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts new file mode 100644 index 00000000000..6c75e473725 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts @@ -0,0 +1,101 @@ +import { applyWorktreeHeadIdentities } from '../worktree-head-identity-apply' +import type { WorktreeChangeRefreshQueue } from '../worktree-change-refresh-queue' +import { useAppStore } from '../../store' + +export function registerProjectCatalogIpcBridge( + unsubs: (() => void)[], + worktreeChangeRefreshQueue: WorktreeChangeRefreshQueue, + isRuntimeEnvironmentActive: () => boolean, + remountTerminalTabsAwaitingHostHydration: () => void +): void { + 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 () => { + const localOwner = { runtimeEnvironmentId: null } + await state.fetchRepos(localOwner) + await state.fetchProjectGroups(localOwner) + await state.fetchFolderWorkspaces(localOwner) + 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) + }) + ) + } +} diff --git a/src/renderer/src/hooks/ipc-events/rate-limit-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/rate-limit-ipc-bridge.ts new file mode 100644 index 00000000000..48fb94c8304 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/rate-limit-ipc-bridge.ts @@ -0,0 +1,30 @@ +import type { RateLimitState } from '../../../../shared/rate-limit-types' +import { useAppStore } from '../../store' + +export function registerRateLimitIpcBridge(unsubs: (() => void)[]): void { + let initialSnapshotPending = true + let receivedPushBeforeInitialSnapshot = false + unsubs.push( + window.api.rateLimits.onUpdate((state) => { + if (initialSnapshotPending) { + receivedPushBeforeInitialSnapshot = true + } + useAppStore.getState().setRateLimitsFromPush(state as RateLimitState) + }) + ) + // The startup get is a fallback: a push before resolution permanently wins. + window.api.rateLimits.get().then((state) => { + initialSnapshotPending = false + if (receivedPushBeforeInitialSnapshot) { + return + } + useAppStore.getState().setRateLimitsFromPush(state as RateLimitState) + }) + + const unsubscribeWorkspaceSpaceProgress = window.api.workspaceSpace?.onProgress?.((progress) => { + useAppStore.getState().applyWorkspaceSpaceProgress(progress) + }) + if (unsubscribeWorkspaceSpaceProgress) { + unsubs.push(unsubscribeWorkspaceSpaceProgress) + } +} diff --git a/src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts new file mode 100644 index 00000000000..161d6eaf576 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/remote-workspace-ipc-bridge.ts @@ -0,0 +1,50 @@ +import { useAppStore } from '../../store' +import type { DirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' + +export function registerRemoteWorkspaceIpcBridge( + unsubs: (() => void)[], + runtime: DirectSshBridgeRuntime +): void { + let clientId: string | null = null + let clientIdPromise: Promise | null = null + const getClientId = (): Promise => { + const remoteWorkspace = window.api.remoteWorkspace + if (!remoteWorkspace) { + return Promise.resolve(null) + } + if (clientId) { + return Promise.resolve(clientId) + } + clientIdPromise ??= remoteWorkspace + .clientId() + .then((id) => { + clientId = id + return id + }) + .catch(() => null) + return clientIdPromise + } + if (!window.api.remoteWorkspace) { + return + } + void getClientId() + unsubs.push( + window.api.remoteWorkspace.onChanged((event) => { + void (async () => { + const currentClientId = await getClientId() + if (event.sourceClientId && currentClientId && event.sourceClientId === currentClientId) { + return + } + await runtime.remoteWorkspaceTargetSync + ?.applyUnsolicitedSnapshot(event.targetId, event.snapshot) + .catch((error) => { + useAppStore.getState().setRemoteWorkspaceSyncStatus(event.targetId, { + phase: 'error', + revision: event.snapshot.revision, + message: error instanceof Error ? error.message : 'Failed to apply remote workspace' + }) + }) + })() + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts new file mode 100644 index 00000000000..a4218c65763 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts @@ -0,0 +1,199 @@ +import { applyHostWorktreeTerminalSleepState } from '@/components/terminal-pane/pty-shutdown-exit-deferral' +import { dispatchTerminalSideEffectBatch } from '@/components/terminal-pane/terminal-side-effect-facts-handler' +import { applyNativeChatLaunchDraftResolved } from '@/runtime/native-chat-launch-draft-runtime-resolution' +import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' +import { + applyRuntimeEnvironmentSshStateChanged, + hydrateRuntimeEnvironmentSshState, + refreshRuntimeEnvironmentSshTargetMetadata +} from '@/runtime/runtime-environment-ssh-state' +import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events' +import { toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { getEnvironmentSshStateGeneration } from '@/store/slices/runtime-environment-ssh' +import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' +import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import type { RuntimeClientEvent } from '../../../../shared/runtime-client-events' +import { useAppStore } from '../../store' +import { createRuntimeClientEventsSync } from '../runtime-client-events-sync' +import { + createRuntimeProjectRefreshScheduler, + refreshRuntimeProjectWorktreesAndLineage +} from '../runtime-project-refresh-scheduler' +import { + buildRuntimeClientEventEnvironmentKey, + createRuntimeEnvironmentStoreSyncSubscriber, + getReachableRuntimeEnvironmentIds, + getRuntimeClientEventEnvironmentIds, + invalidateRuntimeClientEventReplay +} from './runtime-environment-subscription-selection' +import type { WorktreeEventRuntime } from './worktree-event-runtime' + +export function registerRuntimeClientIpcBridge( + unsubs: (() => void)[], + worktreeRuntime: WorktreeEventRuntime +): () => void { + const { worktreeChangeRefreshQueue, activateNotifiedWorktree } = worktreeRuntime + const ensureRuntimeEventRepoKnown = async ( + environmentId: string, + repoId: string + ): Promise => { + 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) + // Why: the host emits one reposChanged for group/folder-workspace edits too, so those + // catalogs go stale without this; groups first because folder workspaces resolve owners from them. + const runtimeOwner = { runtimeEnvironmentId: environmentId } + // Why: catalogs and worktrees are independent; serializing them put two 15s RPC + // timeouts ahead of worktree/lineage convergence on a wedged host. + await Promise.all([ + (async () => { + await useAppStore.getState().fetchProjectGroups(runtimeOwner) + await useAppStore.getState().fetchFolderWorkspaces(runtimeOwner) + })(), + 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) + } + }) + + 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(useAppStore.getState()), + 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, + () => { + invalidateRuntimeClientEventReplay({ + getSshStateReference: () => useAppStore.getState().sshStateByEnvironment, + requestProjectRefresh: () => runtimeProjectRefreshScheduler.request(environmentId), + markEnvironmentSshStateStale: () => + useAppStore.getState().markEnvironmentSshStateStale(environmentId), + hydrateEnvironmentSshState: () => + hydrateRuntimeEnvironmentSshState(environmentId, { force: true }), + sync: runtimeClientEventsSync.sync + }) + } + ) + }, + onEvent: handleRuntimeClientEvent + }) + + // Why: no on-connect repo fetch (PR #2); seed discovery for connected runtimes or remote projects hide until Add-Project. + const initialRuntimeEnvironmentState = useAppStore.getState() + const runtimeClientEventEnvironmentIds = getRuntimeClientEventEnvironmentIds( + initialRuntimeEnvironmentState + ) + for (const environmentId of runtimeClientEventEnvironmentIds) { + runtimeProjectRefreshScheduler.request(environmentId) + } + const reachableRuntimeEnvironmentIds = getReachableRuntimeEnvironmentIds( + initialRuntimeEnvironmentState + ) + const handleRuntimeEnvironmentStoreWrite = createRuntimeEnvironmentStoreSyncSubscriber({ + initialDesiredEnvironmentIds: runtimeClientEventEnvironmentIds, + initialReachableEnvironmentIds: reachableRuntimeEnvironmentIds, + buildEnvironmentKey: buildRuntimeClientEventEnvironmentKey, + getDesiredEnvironmentIds: getRuntimeClientEventEnvironmentIds, + getReachableEnvironmentIds: getReachableRuntimeEnvironmentIds, + requestProjectRefresh: (environmentId) => + // The scheduler coalesces bursts per environment. + runtimeProjectRefreshScheduler.request(environmentId), + markEnvironmentSshStateStale: (environmentId) => { + // No-op when the environment has no SSH bucket (e.g. web client). + useAppStore.getState().markEnvironmentSshStateStale(environmentId) + }, + sync: runtimeClientEventsSync.sync + }) + const unsubscribeRuntimeEnvironmentStore = useAppStore.subscribe( + handleRuntimeEnvironmentStoreWrite + ) + // Subscribe before the first runtime stream starts: replay invalidation may + // synchronously publish a tracked SSH bucket and relies on this listener to + // replace that subscription exactly once. + runtimeClientEventsSync.sync() + unsubs.push(runtimeClientEventsSync.stop) + unsubs.push(runtimeProjectRefreshScheduler.stop) + + return unsubscribeRuntimeEnvironmentStore +} diff --git a/src/renderer/src/hooks/ipc-events/runtime-environment-subscription-selection.ts b/src/renderer/src/hooks/ipc-events/runtime-environment-subscription-selection.ts new file mode 100644 index 00000000000..b2fee2d646e --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/runtime-environment-subscription-selection.ts @@ -0,0 +1,203 @@ +import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' +import { getEnvironmentSshStateGeneration } from '@/store/slices/runtime-environment-ssh' +import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' +import type { AppState } from '../../store/types' + +export type RuntimeEnvironmentStoreSyncState = Pick< + AppState, + 'runtimeEnvironments' | 'runtimeStatusByEnvironmentId' | 'settings' | 'sshStateByEnvironment' +> + +function getActiveRuntimeEnvironmentId(state: RuntimeEnvironmentStoreSyncState): string | null { + return state.settings?.activeRuntimeEnvironmentId?.trim() || null +} + +export function getRuntimeClientEventEnvironmentIds( + state: RuntimeEnvironmentStoreSyncState +): string[] { + const ids = new Set() + const activeEnvironmentId = getActiveRuntimeEnvironmentId(state) + if (activeEnvironmentId) { + ids.add(activeEnvironmentId) + } + for (const environment of state.runtimeEnvironments ?? []) { + if (state.runtimeStatusByEnvironmentId?.get(environment.id)?.status) { + ids.add(environment.id) + } + } + return [...ids] +} + +export function getReachableRuntimeEnvironmentIds( + state: RuntimeEnvironmentStoreSyncState +): string[] { + const ids: string[] = [] + for (const [environmentId, status] of state.runtimeStatusByEnvironmentId ?? []) { + if (status?.status) { + ids.push(environmentId) + } + } + return ids +} + +export function canSkipRuntimeEnvironmentStoreSync( + state: RuntimeEnvironmentStoreSyncState, + previousState: RuntimeEnvironmentStoreSyncState +): boolean { + return ( + state.runtimeEnvironments === previousState.runtimeEnvironments && + state.runtimeStatusByEnvironmentId === previousState.runtimeStatusByEnvironmentId && + state.sshStateByEnvironment === previousState.sshStateByEnvironment && + getActiveRuntimeEnvironmentId(state) === getActiveRuntimeEnvironmentId(previousState) + ) +} + +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. */ +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) + ]) + ] +} + +type RuntimeEnvironmentStoreSyncSubscriberDeps = { + initialDesiredEnvironmentIds: string[] + initialReachableEnvironmentIds: string[] + buildEnvironmentKey: (environmentIds: string[]) => string + getDesiredEnvironmentIds: (state: RuntimeEnvironmentStoreSyncState) => string[] + getReachableEnvironmentIds: (state: RuntimeEnvironmentStoreSyncState) => string[] + requestProjectRefresh: (environmentId: string) => void + markEnvironmentSshStateStale: (environmentId: string) => void + sync: () => void +} + +export type RuntimeEnvironmentStoreSyncSubscriber = ( + state: RuntimeEnvironmentStoreSyncState, + previousState: RuntimeEnvironmentStoreSyncState +) => void + +/** + * Builds the one renderer-wide runtime subscriber. The reference gate runs + * before either host collection is enumerated; key generation remains the + * second gate for relevant-reference writes whose effective subscription set + * did not change. + */ +export function createRuntimeEnvironmentStoreSyncSubscriber( + deps: RuntimeEnvironmentStoreSyncSubscriberDeps +): RuntimeEnvironmentStoreSyncSubscriber { + let desiredEnvironmentIds = deps.initialDesiredEnvironmentIds + let desiredEnvironmentKey = deps.buildEnvironmentKey(desiredEnvironmentIds) + let reachableEnvironmentIds = deps.initialReachableEnvironmentIds + let reachableEnvironmentKey = deps.buildEnvironmentKey(reachableEnvironmentIds) + let handlingStoreWrite = false + + return (state, previousState) => { + // markEnvironmentSshStateStale can synchronously publish its nested SSH + // bucket. The outer pass incorporates that generation before syncing, so a + // re-entrant pass would only enumerate and sync the same transition twice. + if (handlingStoreWrite || canSkipRuntimeEnvironmentStoreSync(state, previousState)) { + return + } + + handlingStoreWrite = true + try { + const nextDesiredEnvironmentIds = deps.getDesiredEnvironmentIds(state) + const nextReachableEnvironmentIds = deps.getReachableEnvironmentIds(state) + const refreshEnvironmentIds = getRuntimeProjectRefreshEnvironmentIds({ + previousDesired: desiredEnvironmentIds, + nextDesired: nextDesiredEnvironmentIds, + previousReachable: reachableEnvironmentIds, + nextReachable: nextReachableEnvironmentIds + }) + const disconnectedEnvironmentIds = getNewlyDisconnectedRuntimeEnvironmentIds( + reachableEnvironmentIds, + nextReachableEnvironmentIds + ) + + desiredEnvironmentIds = nextDesiredEnvironmentIds + reachableEnvironmentIds = nextReachableEnvironmentIds + for (const environmentId of refreshEnvironmentIds) { + deps.requestProjectRefresh(environmentId) + } + for (const environmentId of disconnectedEnvironmentIds) { + deps.markEnvironmentSshStateStale(environmentId) + } + + // Build after disconnect invalidation: marking a mirrored SSH bucket stale + // advances its generation, and the replacement subscription must capture + // that final generation in this same (single) sync. + const nextDesiredEnvironmentKey = deps.buildEnvironmentKey(desiredEnvironmentIds) + const nextReachableEnvironmentKey = deps.buildEnvironmentKey(reachableEnvironmentIds) + if ( + nextDesiredEnvironmentKey === desiredEnvironmentKey && + nextReachableEnvironmentKey === reachableEnvironmentKey + ) { + return + } + desiredEnvironmentKey = nextDesiredEnvironmentKey + reachableEnvironmentKey = nextReachableEnvironmentKey + deps.sync() + } finally { + handlingStoreWrite = false + } + } +} + +type RuntimeClientEventReplayInvalidationDeps = { + getSshStateReference: () => RuntimeEnvironmentStoreSyncState['sshStateByEnvironment'] + requestProjectRefresh: () => void + markEnvironmentSshStateStale: () => void + hydrateEnvironmentSshState: () => Promise + sync: () => void +} + +/** + * Invalidates a replay after the runtime event stream reports a transport gap. + * A tracked SSH bucket publishes synchronously and lets the store subscriber + * sync it; an empty/already-stale bucket has no reference publication, so this + * path must explicitly sync the advanced module-level SSH generation. + */ +export function invalidateRuntimeClientEventReplay( + deps: RuntimeClientEventReplayInvalidationDeps +): void { + deps.requestProjectRefresh() + const previousSshStateReference = deps.getSshStateReference() + deps.markEnvironmentSshStateStale() + if (deps.getSshStateReference() === previousSshStateReference) { + deps.sync() + } + void deps.hydrateEnvironmentSshState().catch(() => {}) +} diff --git a/src/renderer/src/hooks/ipc-events/session-tab-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/session-tab-ipc-bridge.ts new file mode 100644 index 00000000000..b0b284fbd79 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/session-tab-ipc-bridge.ts @@ -0,0 +1,108 @@ +import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close' +import { + SESSION_TAB_CLOSE_CANCELED_ERROR, + SESSION_TAB_CLOSE_FAILED_ERROR, + SESSION_TAB_NOT_FOUND_ERROR, + SESSION_TAB_CLOSE_TIMEOUT_ERROR +} from '../../../../shared/session-tab-close' +import { + guardPinnedTabClose, + isUnifiedTabPinned, + resolvePinnedTabLabel +} from '../../store/pinned-tab-close-guard' +import { useAppStore } from '../../store' +import { resolveBrowserSessionTabTarget } from './browser-session-tab-target' + +export function registerSessionTabIpcBridge(unsubs: (() => void)[]): void { + unsubs.push( + window.api.ui.onCloseSessionTab(({ tabId, worktreeId }) => { + const store = useAppStore.getState() + const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, tabId) + if (browserTarget) { + guardPinnedTabClose({ + isPinned: isUnifiedTabPinned(store, worktreeId, browserTarget.workspaceId), + tabLabel: resolvePinnedTabLabel(store, worktreeId, browserTarget.workspaceId), + onClose: () => useAppStore.getState().closeBrowserTab(browserTarget.workspaceId) + }) + return + } + guardPinnedTabClose({ + isPinned: isUnifiedTabPinned(store, worktreeId, tabId), + tabLabel: resolvePinnedTabLabel(store, worktreeId, tabId), + onClose: () => { + const currentStore = useAppStore.getState() + closeMobileSessionTabInStore(currentStore, worktreeId, tabId) + } + }) + }) + ) + + unsubs.push( + window.api.ui.onSessionTabCloseRequest(({ requestId, tabId, worktreeId, expiresAt }) => { + const store = useAppStore.getState() + const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, tabId) + let cancelConfirmation: (() => void) | undefined + let timeout: ReturnType | undefined + let settled = false + const respond = (error?: string): void => { + if (settled) { + return + } + settled = true + if (timeout !== undefined) { + clearTimeout(timeout) + } + window.api.ui.respondSessionTabClose({ requestId, ...(error ? { error } : {}) }) + } + if (expiresAt !== undefined) { + timeout = setTimeout( + () => { + cancelConfirmation?.() + respond(SESSION_TAB_CLOSE_TIMEOUT_ERROR) + }, + Math.max(0, expiresAt - Date.now()) + ) + } + const closeAndRespond = (): void => { + if (expiresAt !== undefined && Date.now() >= expiresAt) { + respond(SESSION_TAB_CLOSE_TIMEOUT_ERROR) + return + } + try { + if (browserTarget) { + useAppStore.getState().closeBrowserTab(browserTarget.workspaceId) + respond() + return + } + const closed = closeMobileSessionTabInStore(useAppStore.getState(), worktreeId, tabId) + respond(closed ? undefined : SESSION_TAB_NOT_FOUND_ERROR) + } catch (error) { + respond(error instanceof Error ? error.message : SESSION_TAB_CLOSE_FAILED_ERROR) + } + } + const visibleId = browserTarget?.workspaceId ?? tabId + cancelConfirmation = guardPinnedTabClose({ + isPinned: isUnifiedTabPinned(store, worktreeId, visibleId), + tabLabel: resolvePinnedTabLabel(store, worktreeId, visibleId), + onClose: closeAndRespond, + onCancel: () => respond(SESSION_TAB_CLOSE_CANCELED_ERROR) + }) + }) + ) + + 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 } : {}) + }) + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/settings-sidebar-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/settings-sidebar-ipc-bridge.ts new file mode 100644 index 00000000000..bf3d74dd3a9 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/settings-sidebar-ipc-bridge.ts @@ -0,0 +1,190 @@ +import { canShowRightSidebarForView } from '@/lib/right-sidebar-visibility' +import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' +import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' +import { subscribeToUnpairedDeviceAuthNotification } from '../unpaired-device-auth-notification' +import { translate } from '@/i18n/i18n' +import { toast } from 'sonner' +import { useAppStore } from '../../store' + +function getShortcutPlatform(): NodeJS.Platform { + if (navigator.userAgent.includes('Mac')) { + return 'darwin' + } + if (navigator.userAgent.includes('Windows')) { + return 'win32' + } + return 'linux' +} + +export function registerSettingsAndSidebarIpcBridge(unsubs: (() => void)[]): void { + unsubs.push( + window.api.ui.onOpenSettings(() => { + useAppStore.getState().openSettingsPage() + }) + ) + + const unsubscribeOpenSkillShare = window.api.ui.onOpenSkillShare?.((shareId) => { + useAppStore.getState().openSkillShare(shareId) + }) + if (unsubscribeOpenSkillShare) { + unsubs.push(unsubscribeOpenSkillShare) + } + + // 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(() => {}) + + const pendingSkillShare = window.api.ui.consumePendingSkillShare?.() + if (pendingSkillShare && typeof pendingSkillShare.then === 'function') { + void pendingSkillShare + .then((shareId) => { + if (shareId) { + useAppStore.getState().openSkillShare(shareId) + } + }) + .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 + } + const { worktreeVisibilityDefaults, ...activeOwnerUpdates } = updates + const settingsUpdates = store.settings.activeRuntimeEnvironmentId + ? activeOwnerUpdates + : updates + useAppStore.setState({ + settings: { + ...store.settings, + ...settingsUpdates, + notifications: { + ...store.settings.notifications, + ...updates.notifications + } + }, + ...(worktreeVisibilityDefaults + ? { + worktreeVisibilityDefaultsByHost: { + ...store.worktreeVisibilityDefaultsByHost, + local: worktreeVisibilityDefaults + } + } + : {}) + }) + if ('worktreeVisibilityDefaults' in updates) { + void store.fetchAllWorktrees({ visibilityOwnerHostId: 'local' }) + } + }) + ) + + // 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 + }) + }) + ) + } +} diff --git a/src/renderer/src/hooks/ipc-events/tab-lifecycle-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/tab-lifecycle-ipc-bridge.ts new file mode 100644 index 00000000000..d585e721ff6 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/tab-lifecycle-ipc-bridge.ts @@ -0,0 +1,180 @@ +import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + closeWebRuntimeSessionTab, + createWebRuntimeSessionTerminal, + isWebRuntimeSessionActive +} from '@/runtime/web-runtime-session' +import { + guardPinnedTabClose, + isUnifiedTabPinned, + resolvePinnedTabLabel +} from '../../store/pinned-tab-close-guard' +import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' +import { + createFloatingWorkspaceTerminalTab, + isEmptyFloatingWorkspacePanelVisible, + isFloatingWorkspacePanelFocused, + resolveFloatingWorkspaceBrowserWorkspaceId, + switchFloatingWorkspaceTab +} from '@/lib/floating-workspace-terminal-actions' +import { + dispatchFloatingWorkspaceGuestClose, + dispatchFloatingWorkspaceGuestSelectIndex +} from '@/lib/floating-workspace-guest-bridge' + +import { useAppStore } from '../../store' +import { + handleSwitchRecentTab, + handleSwitchTab, + handleSwitchTabAcrossAllTypes, + handleSwitchTerminalTab +} from '../ipc-tab-switch' +function getWorktreeRuntimeEnvironmentId(worktreeId: string | null | undefined): string | null { + return getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId) +} + +export function registerTabLifecycleIpcBridge(unsubs: (() => void)[]): void { + 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 && isUnifiedTabPinned(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) + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/terminal-command-state.ts b/src/renderer/src/hooks/ipc-events/terminal-command-state.ts new file mode 100644 index 00000000000..3e6c61b506f --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/terminal-command-state.ts @@ -0,0 +1,132 @@ +import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization' +import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { focusRuntimeTerminalSurface } from '@/runtime/sync-runtime-graph' +import type { RuntimeTerminalPresentation } from '../../../../shared/runtime-types' +import type { + TerminalLayoutSnapshot, + TerminalPaneLayoutNode +} from '../../../../shared/terminal-tab-types' +import type { AppState } from '../../store/types' + +export 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 +} + +export function focusTerminalInitiatedTab(tabId: string, leafId?: string | null): void { + if (!focusRuntimeTerminalSurface(tabId, leafId)) { + focusTerminalTabSurface(tabId, leafId) + } +} + +export function activateTerminalInitiatedWorktree(store: AppState, worktreeId: string): void { + store.setActiveView('terminal') + store.setActiveWorktree(worktreeId) + 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) + return second.inserted + ? { node: { ...node, second: second.node }, inserted: true } + : { node, inserted: false } +} + +export 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 } } : {}) + } +} + +export 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 } } : {}) + } +} diff --git a/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts new file mode 100644 index 00000000000..0be417cf3e3 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts @@ -0,0 +1,272 @@ +import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' +import { hasRegisteredRuntimeTerminalTab } from '@/runtime/sync-runtime-graph' +import { planMobileTerminalTabMount } from '@/lib/mobile-terminal-tab-mount' +import { resolveTerminalTabPtyOwnership } from '@/lib/terminal-tab-for-pty-id' +import { SPLIT_TERMINAL_PANE_EVENT } from '@/constants/terminal' +import type { SplitTerminalPaneDetail } from '@/constants/terminal' +import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' +import { verifyTerminalRevealIdentity } from '@/lib/terminal-reveal-identity' +import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' +import { getConnectionIdFromState } from '@/lib/connection-context' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' +import { tryMakePaneKey } from './agent-status-routing' +import { useAppStore } from '../../store' +import { + activateExistingLeafInLayout, + activateTerminalInitiatedWorktree, + addSplitLeafToLayout, + focusTerminalInitiatedTab, + resolveTerminalPresentation +} from './terminal-command-state' + +export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): void { + 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(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. +} diff --git a/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts new file mode 100644 index 00000000000..ef8540550cd --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts @@ -0,0 +1,152 @@ +import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' +import { getConnectionIdFromState } from '@/lib/connection-context' +import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' +import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '../../store' +import { + activateTerminalInitiatedWorktree, + focusTerminalInitiatedTab, + resolveTerminalPresentation +} from './terminal-command-state' + +export function registerTerminalRequestIpcBridge(unsubs: (() => void)[]): void { + 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' + }) + } + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts new file mode 100644 index 00000000000..4a7269fe2e7 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts @@ -0,0 +1,95 @@ +import { SPLIT_TERMINAL_PANE_EVENT } from '@/constants/terminal' +import type { SplitTerminalPaneDetail } from '@/constants/terminal' +import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' +import { useAppStore } from '../../store' +import { resolveBrowserSessionTabTarget } from './browser-session-tab-target' +import { + activateTerminalInitiatedWorktree, + focusTerminalInitiatedTab +} from './terminal-command-state' + +export function registerTerminalUiRoutingIpcBridge(unsubs: (() => void)[]): void { + 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) + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.test.ts b/src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.test.ts new file mode 100644 index 00000000000..761ad4ca6db --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { registerUpdaterStatusIpcBridge } from './updater-status-ipc-bridge' + +const mocks = vi.hoisted(() => ({ setUpdateStatus: vi.fn() })) + +vi.mock('../../store', () => ({ + useAppStore: { + getState: () => ({ + setUpdateStatus: mocks.setUpdateStatus, + clearDismissedUpdateVersion: vi.fn() + }) + } +})) + +describe('registerUpdaterStatusIpcBridge', () => { + beforeEach(() => { + vi.resetAllMocks() + vi.unstubAllGlobals() + }) + + it('registers after requesting the snapshot and preserves the current late snapshot overwrite', async () => { + const order: string[] = [] + let resolveSnapshot: ((status: { state: string }) => void) | undefined + let statusListener: ((status: { state: string }) => void) | undefined + const statusCleanup = vi.fn() + const dismissalCleanup = vi.fn() + vi.stubGlobal('window', { + api: { + updater: { + getStatus: () => { + order.push('snapshot') + return new Promise<{ state: string }>((resolve) => { + resolveSnapshot = resolve + }) + }, + onStatus: (listener: (status: { state: string }) => void) => { + order.push('listener') + statusListener = listener + return statusCleanup + }, + onClearDismissal: () => dismissalCleanup + } + } + }) + + const unsubs: (() => void)[] = [] + registerUpdaterStatusIpcBridge(unsubs) + + expect(order).toEqual(['snapshot', 'listener']) + statusListener?.({ state: 'available' }) + resolveSnapshot?.({ state: 'idle' }) + await Promise.resolve() + expect(mocks.setUpdateStatus.mock.calls).toEqual([ + [{ state: 'available' }], + [{ state: 'idle' }] + ]) + + unsubs.forEach((unsubscribe) => unsubscribe()) + expect(statusCleanup).toHaveBeenCalledOnce() + expect(dismissalCleanup).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.ts new file mode 100644 index 00000000000..6cbd6809123 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/updater-status-ipc-bridge.ts @@ -0,0 +1,21 @@ +import type { UpdateStatus } from '../../../../shared/update-status-types' +import { useAppStore } from '../../store' + +/** Installs updater listeners in their historical snapshot-before-push order. */ +export function registerUpdaterStatusIpcBridge(unsubs: (() => void)[]): void { + // Current behavior intentionally permits the initial snapshot to overwrite an earlier push. + window.api.updater.getStatus().then((status) => { + useAppStore.getState().setUpdateStatus(status as UpdateStatus) + }) + + unsubs.push( + window.api.updater.onStatus((raw) => { + useAppStore.getState().setUpdateStatus(raw as UpdateStatus) + }) + ) + unsubs.push( + window.api.updater.onClearDismissal(() => { + useAppStore.getState().clearDismissedUpdateVersion() + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/workspace-shortcut-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/workspace-shortcut-ipc-bridge.ts new file mode 100644 index 00000000000..9e78916fef3 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/workspace-shortcut-ipc-bridge.ts @@ -0,0 +1,136 @@ +import { TOGGLE_QUICK_COMMANDS_MENU_EVENT } from '@/lib/quick-commands-menu-events' +import { TOGGLE_WORKSPACE_BOARD_EVENT } from '@/components/sidebar/useWorkspaceBoardPanel' +import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts' +import { emitCmdJRowIndexJump } from '@/lib/cmd-j-row-index-jump' +import { getVisibleWorktreeShortcutTargets } from '@/components/sidebar/visible-worktrees' +import { activateAndRevealWorkspace } from '@/lib/worktree-activation' +import { deleteHoveredWorkspaceImmediately } from '@/components/sidebar/hovered-workspace-delete' +import { isFloatingWorkspacePanelFocused } from '@/lib/floating-workspace-terminal-actions' +import { isGitRepoKind } from '../../../../shared/repo-kind' +import { useAppStore } from '../../store' +import { toggleAgentDashboardFromShortcut } from './agent-dashboard-command' +import { openNewWorkspaceFromShortcut } from './new-workspace-command' + +export function registerWorkspaceShortcutIpcBridge(unsubs: (() => void)[]): void { + 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(() => { + if (isFloatingWorkspacePanelFocused()) { + return + } + deleteHoveredWorkspaceImmediately(useAppStore.getState()) + }) + ) + } + + 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(TOGGLE_WORKSPACE_BOARD_EVENT)) + }) + ) + } + + if (window.api.ui.onToggleAgentDashboard) { + unsubs.push( + window.api.ui.onToggleAgentDashboard(() => { + toggleAgentDashboardFromShortcut(useAppStore.getState(), () => { + void window.api.dashboard.openPopout() + }) + }) + ) + } + + 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 visibleTargets = getVisibleWorktreeShortcutTargets() + const target = visibleTargets[index] + if (target) { + if (target.executionHostId) { + activateAndRevealWorkspace(target.id, { executionHostId: target.executionHostId }) + } else { + activateAndRevealWorkspace(target.id) + } + } + }) + ) + + 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) + }) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts b/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts new file mode 100644 index 00000000000..d5846c28f69 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts @@ -0,0 +1,165 @@ +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' +import type { RuntimeClientEvent } from '../../../../shared/runtime-client-events' +import type { AppState } from '../../store/types' +import { useAppStore } from '../../store' +import { + createWorktreeChangeRefreshQueue, + type WorktreeChangeRefreshQueue +} from '../worktree-change-refresh-queue' + +const WORKTREE_RENAME_PURGE_GRACE_MS = 20_000 +const recentlyRenamedWorktreeIdExpiry = new Map() + +function getAuthoritativeDetectedWorktreeIds(state: AppState, repoId: string): Set | null { + const detected = state.detectedWorktreesByRepo[repoId] + return detected?.authoritative === true + ? new Set(detected.worktrees.map((worktree) => worktree.id)) + : null +} +function getVisibleWorktreeIdsForRepo(state: AppState, repoId: string): Set { + return new Set((state.worktreesByRepo[repoId] ?? []).map((worktree) => worktree.id)) +} + +export type WorktreeEventRuntime = { + worktreeChangeRefreshQueue: WorktreeChangeRefreshQueue + activateNotifiedWorktree: ( + event: Extract, + options: { allowRuntimeEnvironment: boolean } + ) => Promise +} + +export function createWorktreeEventRuntime( + unsubs: (() => void)[], + isRuntimeEnvironmentActive: () => boolean +): WorktreeEventRuntime { + const handleWorktreesChanged = async ( + repoId: string, + renamed?: { oldWorktreeId: string; newWorktreeId: string }, + options?: { forceLocalOwner?: boolean; executionHostId?: ExecutionHostId } + ): Promise => { + 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 + ) + const purgeHostId = + options?.executionHostId ?? + // A forced-local refresh owns only the local host's panes. + (options?.forceLocalOwner ? LOCAL_EXECUTION_HOST_ID : undefined) + afterState.purgeWorktreeTerminalState( + purgeHostId ? removed.map((id) => ({ id, hostId: purgeHostId })) : removed + ) + afterState.removeWorkspaceSpaceWorktrees(removed) + } + } + const worktreeChangeRefreshQueue = createWorktreeChangeRefreshQueue(handleWorktreesChanged) + unsubs.push(worktreeChangeRefreshQueue.dispose) + + const activateNotifiedWorktree = async ( + { + repoId, + worktreeId, + setup, + startup, + defaultTabs + }: Extract, + options: { allowRuntimeEnvironment: boolean } + ): Promise => { + 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 + }) + } + + return { worktreeChangeRefreshQueue, activateNotifiedWorktree } +} diff --git a/src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts new file mode 100644 index 00000000000..584045b8c12 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts @@ -0,0 +1,48 @@ +import { applyUIZoom } from '@/lib/ui-zoom' +import { computeEditorFontSize, nextEditorFontZoomLevel } from '@/lib/editor-font-zoom' +import { zoomLevelToPercent } from '@/components/settings/SettingsConstants' +import { dispatchZoomLevelChanged } from '@/lib/zoom-events' +import { stepUIZoomLevel } from '../../../../shared/ui-zoom-level' +import { useAppStore } from '../../store' +import { resolveZoomTarget } from '../resolve-zoom-target' + +export function registerZoomIpcBridge(unsubs: (() => void)[]): void { + // 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. +} diff --git a/src/renderer/src/hooks/remote-workspace-target-sync.ts b/src/renderer/src/hooks/remote-workspace-target-sync.ts index 836fd763fc6..b60c26b2ddc 100644 --- a/src/renderer/src/hooks/remote-workspace-target-sync.ts +++ b/src/renderer/src/hooks/remote-workspace-target-sync.ts @@ -16,7 +16,6 @@ import type { import { buildDirectSshSnapshotApplyToken } from './direct-ssh-reconnect-coordinator' import { resolveDirectSshTargetScope } from '../lib/direct-ssh-target-scope' import { applyDirectSshRemoteWorkspaceSnapshot } from './remote-workspace-snapshot-apply' -export { isDirectSshRemoteWorkspaceApplyInProgress } from './remote-workspace-snapshot-apply' const WORKSPACE_HYDRATION_TIMEOUT_MS = 10_000 diff --git a/src/renderer/src/hooks/useIpcEvents-agent-dashboard-shortcut.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-dashboard-shortcut.test.ts index 56d9823d652..11c95866ed2 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-dashboard-shortcut.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-dashboard-shortcut.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { toggleAgentDashboardFromShortcut } from './useIpcEvents' +import { toggleAgentDashboardFromShortcut } from './ipc-events/agent-dashboard-command' function makeState( overrides: { diff --git a/src/renderer/src/hooks/useIpcEvents-browser-navigation.test.ts b/src/renderer/src/hooks/useIpcEvents-browser-navigation.test.ts index f4d274a3838..6854f93161f 100644 --- a/src/renderer/src/hooks/useIpcEvents-browser-navigation.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-browser-navigation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { resolveBrowserSessionTabTarget } from './useIpcEvents' +import { resolveBrowserSessionTabTarget } from './ipc-events/browser-session-tab-target' import { createHarnessStoreState, loadIpcEventsHarness } from './ipc-events-test-harness' describe('browser navigation updates', () => { diff --git a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts new file mode 100644 index 00000000000..bd80c6aa726 --- /dev/null +++ b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts @@ -0,0 +1,502 @@ +import type * as ReactModule from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Mock } from 'vitest' +import { createHarnessStoreState } from './ipc-events-test-harness' +const EXPECTED_DIRECT_CALLBACK_METHODS = [ + 'agentStatus.onClear', + 'agentStatus.onLegacyWorkerTerminalRecovery', + 'agentStatus.onMigrationUnsupported', + 'agentStatus.onMigrationUnsupportedClear', + 'agentStatus.onSet', + 'browser.onActivateView', + 'browser.onCertificateFailureChanged', + 'browser.onGuestLoadFailed', + 'browser.onNavigationUpdate', + 'browser.onOpenLinkInOrcaTab', + 'browser.onPaneFocus', + 'emulator.onAutoAttach', + 'emulator.onPaneFocus', + 'gh.onPRRefreshEvent', + 'keybindings.onChanged', + 'rateLimits.onUpdate', + 'remoteWorkspace.onChanged', + 'repos.onChanged', + 'runtime.onBrowserDriverChanged', + 'runtime.onNativeChatLaunchDraftResolved', + 'runtime.onTerminalDriverChanged', + 'runtime.onTerminalFitOverrideChanged', + 'settings.onChanged', + 'ssh.onCredentialRequest', + 'ssh.onCredentialResolved', + 'ssh.onDetectedPortsChanged', + 'ssh.onPortForwardsChanged', + 'ssh.onStateChanged', + 'ui.onActivateWorktree', + 'ui.onCloseActiveTab', + 'ui.onCloseFloatingItem', + 'ui.onCloseSessionTab', + 'ui.onCloseTerminal', + 'ui.onCreateTerminal', + 'ui.onDeleteCurrentWorkspace', + 'ui.onFocusEditorTab', + 'ui.onFocusTerminal', + 'ui.onFullscreenChanged', + 'ui.onJumpToTabIndex', + 'ui.onJumpToWorktreeIndex', + 'ui.onMoveSessionTab', + 'ui.onNewBrowserTab', + 'ui.onNewMarkdownTab', + 'ui.onNewSimulatorTab', + 'ui.onNewTerminalTab', + 'ui.onOpenDiffFromMobile', + 'ui.onOpenFeatureTour', + 'ui.onOpenFileFromMobile', + 'ui.onOpenNewWorkspace', + 'ui.onOpenQuickOpen', + 'ui.onOpenSettings', + 'ui.onOpenSetupGuide', + 'ui.onOpenSkillShare', + 'ui.onOpenTasks', + 'ui.onOpenWorkspaceBoard', + 'ui.onRenameTerminal', + 'ui.onRequestTabClose', + 'ui.onRequestTabCreate', + 'ui.onRequestTabSetProfile', + 'ui.onRequestTerminalCreate', + 'ui.onRequestTerminalTabMount', + 'ui.onResumeSleepingAgents', + 'ui.onSelectFloatingIndex', + 'ui.onSessionTabCloseRequest', + 'ui.onSleepWorktree', + 'ui.onSplitTerminal', + 'ui.onStateChanged', + 'ui.onSwitchRecentTab', + 'ui.onSwitchTab', + 'ui.onSwitchTabAcrossAllTypes', + 'ui.onSwitchTerminalTab', + 'ui.onSystemResumed', + 'ui.onTerminalShortcutCaptured', + 'ui.onTerminalTabCloseRequest', + 'ui.onTerminalZoom', + 'ui.onToggleAgentDashboard', + 'ui.onToggleFloatingTerminal', + 'ui.onToggleLeftSidebar', + 'ui.onToggleQuickCommandsMenu', + 'ui.onToggleRightSidebar', + 'ui.onToggleStatusBar', + 'ui.onToggleWorktreePalette', + 'ui.onWorktreeHistoryNavigate', + 'updater.onClearDismissal', + 'updater.onStatus', + 'workspaceSpace.onProgress', + 'worktrees.onBaseStatus', + 'worktrees.onChanged', + 'worktrees.onCreateProgress', + 'worktrees.onHeadIdentitiesChanged', + 'worktrees.onRemoteBranchConflict' +] as const + +const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [ + 'ui.onMobileMarkdownRequest', + 'repos.onChanged', + 'worktrees.onChanged', + 'worktrees.onHeadIdentitiesChanged', + 'worktrees.onBaseStatus', + 'worktrees.onRemoteBranchConflict', + 'worktrees.onCreateProgress', + 'gh.onPRRefreshEvent', + 'ui.onOpenSettings', + 'ui.onOpenSkillShare', + 'ui.onOpenSetupGuide', + 'mobile.onUnpairedDeviceAuthFailure', + 'ui.onOpenFeatureTour', + 'settings.onChanged', + 'ui.onStateChanged', + 'keybindings.onChanged', + 'ui.onToggleLeftSidebar', + 'ui.onToggleRightSidebar', + 'ui.onToggleWorktreePalette', + 'ui.onToggleFloatingTerminal', + 'ui.onTerminalShortcutCaptured', + 'ui.onOpenQuickOpen', + 'ui.onToggleQuickCommandsMenu', + 'ui.onOpenNewWorkspace', + 'ui.onDeleteCurrentWorkspace', + 'ui.onOpenWorkspaceBoard', + 'ui.onToggleAgentDashboard', + 'ui.onOpenTasks', + 'ui.onJumpToWorktreeIndex', + 'ui.onJumpToTabIndex', + 'ui.onWorktreeHistoryNavigate', + 'ui.onToggleStatusBar', + 'ui.onActivateWorktree', + 'ui.onCreateTerminal', + 'ui.onRequestTerminalTabMount', + 'ui.onRequestTerminalCreate', + 'ui.onSplitTerminal', + 'ui.onRenameTerminal', + 'ui.onFocusTerminal', + 'ui.onFocusEditorTab', + 'ui.onCloseSessionTab', + 'ui.onSessionTabCloseRequest', + 'ui.onMoveSessionTab', + 'ui.onOpenFileFromMobile', + 'ui.onOpenDiffFromMobile', + 'ui.onCloseTerminal', + 'ui.onTerminalTabCloseRequest', + 'ui.onSleepWorktree', + 'ui.onResumeSleepingAgents', + 'updater.onStatus', + 'updater.onClearDismissal', + 'ui.onFullscreenChanged', + 'browser.onGuestLoadFailed', + 'browser.onCertificateFailureChanged', + 'browser.onNavigationUpdate', + 'browser.onActivateView', + 'browser.onPaneFocus', + 'browser.onOpenLinkInOrcaTab', + 'ui.onNewBrowserTab', + 'ui.onNewMarkdownTab', + 'ui.onNewSimulatorTab', + 'emulator.onAutoAttach', + 'emulator.onPaneFocus', + 'ui.onRequestTabCreate', + 'ui.onRequestTabSetProfile', + 'ui.onRequestTabClose', + 'ui.onNewTerminalTab', + 'ui.onCloseActiveTab', + 'ui.onCloseFloatingItem', + 'ui.onSelectFloatingIndex', + 'ui.onSwitchTab', + 'ui.onSwitchTabAcrossAllTypes', + 'ui.onSwitchRecentTab', + 'ui.onSwitchTerminalTab', + 'rateLimits.onUpdate', + 'workspaceSpace.onProgress', + 'ssh.onCredentialRequest', + 'ssh.onCredentialResolved', + 'ssh.onPortForwardsChanged', + 'ssh.onDetectedPortsChanged', + 'ssh.onStateChanged', + 'ui.onSystemResumed', + 'remoteWorkspace.onChanged', + 'ui.onTerminalZoom', + 'agentStatus.onSet', + 'agentStatus.onClear', + 'agentStatus.onMigrationUnsupported', + 'agentStatus.onMigrationUnsupportedClear', + 'agentStatus.onLegacyWorkerTerminalRecovery', + 'runtime.onTerminalFitOverrideChanged', + 'runtime.onTerminalDriverChanged', + 'runtime.onNativeChatLaunchDraftResolved', + 'runtime.onBrowserDriverChanged' +] as const + +type ListenerRecord = { + callback: (...args: unknown[]) => void + active: boolean + cleanup: Mock +} + +describe('useIpcEvents App-lifetime lifecycle', () => { + beforeEach(() => { + vi.resetModules() + vi.unstubAllGlobals() + vi.doUnmock('./ipc-events/app-lifetime-ipc-bridge') + }) + + it('owns one empty-dependency React effect', async () => { + let dependencies: readonly unknown[] | undefined + vi.doMock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useEffect: (_effect: () => void | (() => void), nextDependencies?: readonly unknown[]) => { + dependencies = nextDependencies + } + } + }) + vi.doMock('./ipc-events/app-lifetime-ipc-bridge', () => ({ + installAppLifetimeIpcEvents: vi.fn(() => vi.fn()) + })) + + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + + expect(dependencies).toEqual([]) + }) + + it('leaves exactly one listener per channel across a StrictMode cleanup-remount cycle', async () => { + const registrationOrder: string[] = [] + const cleanupOrder: string[] = [] + const listeners = new Map() + const storeSubscriptions: { active: boolean; cleanup: Mock }[] = [] + const setUpdateStatus = vi.fn() + const storeState = new Proxy( + createHarnessStoreState({ + tabsByWorktree: { 'wt-1': [] }, + setUpdateStatus, + workspaceSessionReady: true, + runtimeEnvironments: [{ id: 'runtime-1' }], + runtimeStatusByEnvironmentId: new Map([['runtime-1', { status: 'connected' }]]) + }), + { + get: (target, property: string) => + property in target ? target[property] : property.startsWith('set') ? vi.fn() : undefined + } + ) + + vi.doMock('../store', () => ({ + useAppStore: { + getState: () => storeState, + subscribe: vi.fn(() => { + const record = { active: true, cleanup: vi.fn() } + const subscriptionIndex = storeSubscriptions.length + storeSubscriptions.push(record) + return () => { + cleanupOrder.push(`store.unsubscribe.${subscriptionIndex}`) + record.active = false + record.cleanup() + } + }) + } + })) + + const namespace = (name: string): Record => + new Proxy( + {}, + { + get: (_target, property: string) => { + if (name === 'runtimeEnvironments' && property === 'subscribe') { + return async () => { + registrationOrder.push('runtimeEnvironments.subscribe') + return { + unsubscribe: () => cleanupOrder.push('runtimeEnvironment.unsubscribe') + } + } + } + if (property.startsWith('on')) { + return (callback: (...args: unknown[]) => void) => { + registrationOrder.push(`${name}.${property}`) + const record: ListenerRecord = { callback, active: true, cleanup: vi.fn() } + const records = listeners.get(`${name}.${property}`) ?? [] + records.push(record) + listeners.set(`${name}.${property}`, records) + return () => { + cleanupOrder.push(`ipc.${name}.${property}`) + record.active = false + record.cleanup() + } + } + } + if (property === 'getStatus') { + return () => { + registrationOrder.push(`${name}.${property}`) + return Promise.resolve({ state: 'idle' }) + } + } + if (property === 'get') { + return () => { + registrationOrder.push(`${name}.${property}`) + return Promise.resolve({ limits: {}, lastUpdatedAt: 0 }) + } + } + if (property === 'getState') { + return () => { + registrationOrder.push(`${name}.${property}`) + return Promise.resolve(null) + } + } + if (property === 'clientId') { + return () => { + registrationOrder.push(`${name}.${property}`) + return Promise.resolve(null) + } + } + if (property.startsWith('get') || property.startsWith('list')) { + return () => { + registrationOrder.push(`${name}.${property}`) + return Promise.resolve([]) + } + } + if (property.startsWith('consumePending')) { + return () => { + registrationOrder.push(`${name}.${property}`) + return Promise.resolve(null) + } + } + return vi.fn() + } + } + ) + const api = new Proxy( + {}, + { get: (_target, property: string) => namespace(property) } + ) as unknown + vi.stubGlobal('window', { + api, + dispatchEvent: vi.fn(), + setTimeout, + clearTimeout + }) + + const { installAppLifetimeIpcEvents } = await import('./ipc-events/app-lifetime-ipc-bridge') + const recordCleanupPhase = (phase: string): void => { + cleanupOrder.push(phase) + } + const firstCleanup = installAppLifetimeIpcEvents(recordCleanupPhase) + await Promise.resolve() + await Promise.resolve() + const directCallbackMethods = [...listeners.keys()] + .filter( + (method) => + method !== 'mobile.onUnpairedDeviceAuthFailure' && method !== 'ui.onMobileMarkdownRequest' + ) + .sort() + expect(directCallbackMethods).toEqual(EXPECTED_DIRECT_CALLBACK_METHODS) + expect([...listeners.keys()].sort()).toEqual( + [...EXPECTED_CALLBACK_REGISTRATION_SEQUENCE].sort() + ) + expect( + registrationOrder.filter( + (entry) => + entry === 'runtimeEnvironments.subscribe' || + EXPECTED_CALLBACK_REGISTRATION_SEQUENCE.includes( + entry as (typeof EXPECTED_CALLBACK_REGISTRATION_SEQUENCE)[number] + ) + ) + ).toEqual([ + 'ui.onMobileMarkdownRequest', + 'runtimeEnvironments.subscribe', + ...EXPECTED_CALLBACK_REGISTRATION_SEQUENCE.slice(1) + ]) + const groupOrder = (names: readonly string[]): string[] => + registrationOrder.filter((entry) => names.includes(entry)) + expect( + groupOrder([ + 'ui.onOpenSettings', + 'ui.onOpenSkillShare', + 'ui.consumePendingOpenSettings', + 'ui.consumePendingSkillShare' + ]) + ).toEqual([ + 'ui.onOpenSettings', + 'ui.onOpenSkillShare', + 'ui.consumePendingOpenSettings', + 'ui.consumePendingSkillShare' + ]) + expect( + groupOrder([ + 'updater.getStatus', + 'updater.onStatus', + 'updater.onClearDismissal', + 'rateLimits.onUpdate', + 'rateLimits.get' + ]) + ).toEqual([ + 'updater.getStatus', + 'updater.onStatus', + 'updater.onClearDismissal', + 'rateLimits.onUpdate', + 'rateLimits.get' + ]) + expect( + groupOrder([ + 'agentStatus.onSet', + 'agentStatus.onClear', + 'agentStatus.onMigrationUnsupported', + 'agentStatus.onMigrationUnsupportedClear', + 'agentStatus.onLegacyWorkerTerminalRecovery', + 'agentStatus.getSnapshot' + ]) + ).toEqual([ + 'agentStatus.onSet', + 'agentStatus.onClear', + 'agentStatus.onMigrationUnsupported', + 'agentStatus.onMigrationUnsupportedClear', + 'agentStatus.onLegacyWorkerTerminalRecovery', + 'agentStatus.getSnapshot' + ]) + expect( + groupOrder([ + 'runtime.onTerminalFitOverrideChanged', + 'runtime.onTerminalDriverChanged', + 'runtime.onNativeChatLaunchDraftResolved', + 'runtime.onBrowserDriverChanged', + 'runtime.getTerminalFitOverrides', + 'runtime.getTerminalDrivers', + 'runtime.getBrowserDrivers' + ]) + ).toEqual([ + 'runtime.onTerminalFitOverrideChanged', + 'runtime.onTerminalDriverChanged', + 'runtime.onNativeChatLaunchDraftResolved', + 'runtime.onBrowserDriverChanged', + 'runtime.getTerminalFitOverrides', + 'runtime.getTerminalDrivers', + 'runtime.getBrowserDrivers' + ]) + expect( + [...listeners.values()].every((records) => records.filter((item) => item.active).length === 1) + ).toBe(true) + expect(storeSubscriptions.filter((item) => item.active)).toHaveLength(2) + + firstCleanup() + const ipcCleanupOrder = cleanupOrder + .filter((entry) => entry.startsWith('ipc.') && entry !== 'ipc.dispose') + .map((entry) => entry.slice('ipc.'.length)) + expect(ipcCleanupOrder).toEqual(EXPECTED_CALLBACK_REGISTRATION_SEQUENCE) + expect(cleanupOrder.slice(0, 6)).toEqual([ + 'agent.disposeAsyncState', + 'mobile.disposeHydration', + 'store.unsubscribe.0', + 'runtimeStore.unsubscribe', + 'store.unsubscribe.1', + 'agentStore.unsubscribe' + ]) + expect(cleanupOrder.indexOf('runtimeEnvironment.unsubscribe')).toBeGreaterThan( + cleanupOrder.indexOf('ipc.ui.onMobileMarkdownRequest') + ) + expect(cleanupOrder.indexOf('runtimeEnvironment.unsubscribe')).toBeLessThan( + cleanupOrder.indexOf('ipc.repos.onChanged') + ) + expect(cleanupOrder.indexOf('directSsh.stop')).toBeGreaterThan( + cleanupOrder.lastIndexOf('ipc.runtime.onBrowserDriverChanged') + ) + expect(cleanupOrder.at(-1)).toBe('notifications.reset') + expect([...listeners.values()].every((records) => records.every((item) => !item.active))).toBe( + true + ) + expect(storeSubscriptions.filter((item) => item.active)).toHaveLength(0) + expect( + [...listeners.values()].every((records) => + records.every((item) => item.cleanup.mock.calls.length === 1) + ) + ).toBe(true) + + const statusWritesBeforePostUnmountEvent = setUpdateStatus.mock.calls.length + for (const record of listeners.get('updater.onStatus') ?? []) { + if (record.active) { + record.callback({ state: 'available' }) + } + } + expect(setUpdateStatus).toHaveBeenCalledTimes(statusWritesBeforePostUnmountEvent) + + const secondCleanup = installAppLifetimeIpcEvents(recordCleanupPhase) + expect( + [...listeners.values()].every((records) => records.filter((item) => item.active).length === 1) + ).toBe(true) + expect(storeSubscriptions.filter((item) => item.active)).toHaveLength(2) + + secondCleanup() + expect([...listeners.values()].every((records) => records.every((item) => !item.active))).toBe( + true + ) + expect(storeSubscriptions.filter((item) => item.active)).toHaveLength(0) + expect( + [...listeners.values()].every((records) => + records.every((item) => item.cleanup.mock.calls.length === 1) + ) + ).toBe(true) + }) +}) diff --git a/src/renderer/src/hooks/useIpcEvents-new-workspace-shortcut.test.ts b/src/renderer/src/hooks/useIpcEvents-new-workspace-shortcut.test.ts index 7ce571b73b9..a72216cc9b6 100644 --- a/src/renderer/src/hooks/useIpcEvents-new-workspace-shortcut.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-new-workspace-shortcut.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from 'vitest' -import { buildNewWorkspaceShortcutModalData, openNewWorkspaceFromShortcut } from './useIpcEvents' +import { + buildNewWorkspaceShortcutModalData, + openNewWorkspaceFromShortcut +} from './ipc-events/new-workspace-command' describe('buildNewWorkspaceShortcutModalData', () => { it('carries the active Linear issue into the Cmd+N composer', () => { diff --git a/src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts b/src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts index ad1d318f332..c47487dd57e 100644 --- a/src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts @@ -8,11 +8,11 @@ import { getRuntimeClientEventEnvironmentIds, getRuntimeProjectRefreshEnvironmentIds, invalidateRuntimeClientEventReplay -} from './useIpcEvents' +} from './ipc-events/runtime-environment-subscription-selection' import type { RuntimeEnvironmentStoreSyncState, RuntimeEnvironmentStoreSyncSubscriber -} from './useIpcEvents' +} from './ipc-events/runtime-environment-subscription-selection' describe('buildRuntimeClientEventEnvironmentKey', () => { it('treats runtime environment ids as a stable set', () => { diff --git a/src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts b/src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts index 9a9ec54974f..be11678c1fd 100644 --- a/src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts @@ -1,6 +1,6 @@ import type * as ReactModule from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { resolveZoomTarget } from './useIpcEvents' +import { resolveZoomTarget } from './resolve-zoom-target' function makeTarget(args: { hasXtermClass?: boolean; editorClosest?: boolean }): { classList: { contains: (token: string) => boolean } @@ -91,6 +91,16 @@ describe('useIpcEvents zoom routing', () => { beforeEach(() => { vi.resetModules() vi.unstubAllGlobals() + // Zoom routing never renders toast UI; keep Sonner's DOM style injector out of this synthetic-document harness. + vi.doMock('sonner', () => ({ + toast: { + dismiss: vi.fn(), + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warning: vi.fn() + } + })) }) it('applies app zoom for an active browser tab', async () => { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 95c8278fc52..e9e6ce4929e 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1,4456 +1,7 @@ -/* 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 { deleteHoveredWorkspaceImmediately } from '@/components/sidebar/hovered-workspace-delete' -import { runSleepWorktree } from '@/components/sidebar/sleep-worktree-flow' -import { createBackgroundSleepingAgentWakeDispatcher } from '@/lib/wake-sleeping-agents-in-background' -import { TOGGLE_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 { getVisibleWorktreeShortcutTargets } 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 -} from '../../../shared/terminal-tab-types' -import type { UpdateStatus } from '../../../shared/update-status-types' -import type { RateLimitState } from '../../../shared/rate-limit-types' -import type { DirectSshAuthority, SshConnectionState } from '../../../shared/ssh-types' -import { - LOCAL_EXECUTION_HOST_ID, - 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/host-guest/webview-registry' -import { rememberLiveBrowserUrl } from '@/components/browser-pane/describe-page/live-browser-url-registry' -import { - acquireBrowserAutomationVisibility, - releaseBrowserAutomationVisibility -} from '@/components/browser-pane/host-guest/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, - isUnifiedTabPinned, - resolvePinnedTabLabel -} from '../store/pinned-tab-close-guard' -import { - closeWebRuntimeSessionTab, - 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 { isDecorativeAgentTitleFrameChange } from '../../../shared/agent-decorative-title-signature' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' -import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority' -import type { - AgentStatusBatchTransaction, - AgentStatusBatchUpdate, - AgentStatusUpdate -} from '@/store/slices/agent-status' -import { translate } from '@/i18n/i18n' -import { redactKagiSessionToken } from '../../../shared/browser-url' -import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions' -import { - SESSION_TAB_CLOSE_CANCELED_ERROR, - SESSION_TAB_CLOSE_FAILED_ERROR, - SESSION_TAB_NOT_FOUND_ERROR, - SESSION_TAB_CLOSE_TIMEOUT_ERROR -} from '../../../shared/session-tab-close' -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() - -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 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() - -function isAgentStatusForRecentlyClosedTab( - store: Pick, - 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 | 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 { - 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 -} - -export function buildNewWorkspaceShortcutModalData( - state: Pick -): 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 -): void { - if (state.activeModal === 'new-workspace-composer') { - return - } - state.openModal('new-workspace-composer', buildNewWorkspaceShortcutModalData(state)) -} - -export function toggleAgentDashboardFromShortcut( - state: Pick< - AppState, - | 'activeView' - | 'settings' - | 'agentDashboardDrawerOpen' - | 'setSidebarOpen' - | 'setAgentDashboardDrawerOpen' - >, - openPopout: () => void -): void { - // Why: mirror the sidebar entry's gate — the chord must stay inert while the - // experiment is off, so a stale binding cannot open a hidden surface. - if ( - state.activeView === 'settings' || - state.settings?.experimentalAgentDashboardPopout !== true - ) { - return - } - if (state.settings.experimentalAgentDashboardMode === 'popout') { - openPopout() - return - } - const nextOpen = !state.agentDashboardDrawerOpen - // Why: the drawer lives beside the sidebar and self-closes when the sidebar - // collapses, so opening it has to reveal the sidebar first. Closing must not, - // or dismissing the drawer would force the sidebar back open. - if (nextOpen) { - state.setSidebarOpen(true) - } - state.setAgentDashboardDrawerOpen(nextOpen) -} - -export function resolveBrowserSessionTabTarget( - state: Pick, - 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) - } -} - -export type RuntimeEnvironmentStoreSyncState = Pick< - AppState, - 'runtimeEnvironments' | 'runtimeStatusByEnvironmentId' | 'settings' | 'sshStateByEnvironment' -> - -function getActiveRuntimeEnvironmentId(state: RuntimeEnvironmentStoreSyncState): string | null { - return state.settings?.activeRuntimeEnvironmentId?.trim() || null -} - -export function getRuntimeClientEventEnvironmentIds( - state: RuntimeEnvironmentStoreSyncState -): string[] { - const ids = new Set() - const activeEnvironmentId = getActiveRuntimeEnvironmentId(state) - 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] -} - -export function getReachableRuntimeEnvironmentIds( - state: RuntimeEnvironmentStoreSyncState -): string[] { - const ids: string[] = [] - for (const [environmentId, status] of state.runtimeStatusByEnvironmentId ?? []) { - if (status?.status) { - ids.push(environmentId) - } - } - return ids -} - -export function canSkipRuntimeEnvironmentStoreSync( - state: RuntimeEnvironmentStoreSyncState, - previousState: RuntimeEnvironmentStoreSyncState -): boolean { - return ( - state.runtimeEnvironments === previousState.runtimeEnvironments && - state.runtimeStatusByEnvironmentId === previousState.runtimeStatusByEnvironmentId && - state.sshStateByEnvironment === previousState.sshStateByEnvironment && - getActiveRuntimeEnvironmentId(state) === getActiveRuntimeEnvironmentId(previousState) - ) -} - -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) - ]) - ] -} - -type RuntimeEnvironmentStoreSyncSubscriberDeps = { - initialDesiredEnvironmentIds: string[] - initialReachableEnvironmentIds: string[] - buildEnvironmentKey: (environmentIds: string[]) => string - getDesiredEnvironmentIds: (state: RuntimeEnvironmentStoreSyncState) => string[] - getReachableEnvironmentIds: (state: RuntimeEnvironmentStoreSyncState) => string[] - requestProjectRefresh: (environmentId: string) => void - markEnvironmentSshStateStale: (environmentId: string) => void - sync: () => void -} - -export type RuntimeEnvironmentStoreSyncSubscriber = ( - state: RuntimeEnvironmentStoreSyncState, - previousState: RuntimeEnvironmentStoreSyncState -) => void - -/** - * Builds the one renderer-wide runtime subscriber. The reference gate runs - * before either host collection is enumerated; key generation remains the - * second gate for relevant-reference writes whose effective subscription set - * did not change. - */ -export function createRuntimeEnvironmentStoreSyncSubscriber( - deps: RuntimeEnvironmentStoreSyncSubscriberDeps -): RuntimeEnvironmentStoreSyncSubscriber { - let desiredEnvironmentIds = deps.initialDesiredEnvironmentIds - let desiredEnvironmentKey = deps.buildEnvironmentKey(desiredEnvironmentIds) - let reachableEnvironmentIds = deps.initialReachableEnvironmentIds - let reachableEnvironmentKey = deps.buildEnvironmentKey(reachableEnvironmentIds) - let handlingStoreWrite = false - - return (state, previousState) => { - // markEnvironmentSshStateStale can synchronously publish its nested SSH - // bucket. The outer pass incorporates that generation before syncing, so a - // re-entrant pass would only enumerate and sync the same transition twice. - if (handlingStoreWrite || canSkipRuntimeEnvironmentStoreSync(state, previousState)) { - return - } - - handlingStoreWrite = true - try { - const nextDesiredEnvironmentIds = deps.getDesiredEnvironmentIds(state) - const nextReachableEnvironmentIds = deps.getReachableEnvironmentIds(state) - const refreshEnvironmentIds = getRuntimeProjectRefreshEnvironmentIds({ - previousDesired: desiredEnvironmentIds, - nextDesired: nextDesiredEnvironmentIds, - previousReachable: reachableEnvironmentIds, - nextReachable: nextReachableEnvironmentIds - }) - const disconnectedEnvironmentIds = getNewlyDisconnectedRuntimeEnvironmentIds( - reachableEnvironmentIds, - nextReachableEnvironmentIds - ) - - desiredEnvironmentIds = nextDesiredEnvironmentIds - reachableEnvironmentIds = nextReachableEnvironmentIds - for (const environmentId of refreshEnvironmentIds) { - deps.requestProjectRefresh(environmentId) - } - for (const environmentId of disconnectedEnvironmentIds) { - deps.markEnvironmentSshStateStale(environmentId) - } - - // Build after disconnect invalidation: marking a mirrored SSH bucket stale - // advances its generation, and the replacement subscription must capture - // that final generation in this same (single) sync. - const nextDesiredEnvironmentKey = deps.buildEnvironmentKey(desiredEnvironmentIds) - const nextReachableEnvironmentKey = deps.buildEnvironmentKey(reachableEnvironmentIds) - if ( - nextDesiredEnvironmentKey === desiredEnvironmentKey && - nextReachableEnvironmentKey === reachableEnvironmentKey - ) { - return - } - desiredEnvironmentKey = nextDesiredEnvironmentKey - reachableEnvironmentKey = nextReachableEnvironmentKey - deps.sync() - } finally { - handlingStoreWrite = false - } - } -} - -type RuntimeClientEventReplayInvalidationDeps = { - getSshStateReference: () => RuntimeEnvironmentStoreSyncState['sshStateByEnvironment'] - requestProjectRefresh: () => void - markEnvironmentSshStateStale: () => void - hydrateEnvironmentSshState: () => Promise - sync: () => void -} - -/** - * Invalidates a replay after the runtime event stream reports a transport gap. - * A tracked SSH bucket publishes synchronously and lets the store subscriber - * sync it; an empty/already-stale bucket has no reference publication, so this - * path must explicitly sync the advanced module-level SSH generation. - */ -export function invalidateRuntimeClientEventReplay( - deps: RuntimeClientEventReplayInvalidationDeps -): void { - deps.requestProjectRefresh() - const previousSshStateReference = deps.getSshStateReference() - deps.markEnvironmentSshStateStale() - if (deps.getSshStateReference() === previousSshStateReference) { - deps.sync() - } - void deps.hydrateEnvironmentSshState().catch(() => {}) -} - -function getWorktreeRuntimeEnvironmentId(worktreeId: string | null | undefined): string | null { - return getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId) -} +import { installAppLifetimeIpcEvents } from './ipc-events/app-lifetime-ipc-bridge' +/** Installs the renderer IPC bridge once for the App lifetime. */ export function useIpcEvents(): void { - useEffect(() => { - const unsubs: (() => void)[] = [] - const reconnectAuthorityByTarget = new Map() - const authorityReconciliationDeadlines = new Set<{ - timer: ReturnType - 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 - > - 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 => { - 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' - type ProjectedAgentTabTitles = { - title: string | undefined - identityTitle: string | undefined - } - type AgentStatusBatchContext = { - transaction: AgentStatusBatchTransaction - routingIndex: AgentStatusPaneRoutingIndex - projectedTitlesByTabId: Map - tabTitlesByTabId: Map - notificationEffects: (() => void)[] - } - type AgentStatusBatchEvent = { - data: AgentStatusIpcPayload - replay?: boolean - retry?: boolean - } - type AgentStatusApplyOptions = { - replay?: boolean - retry?: boolean - batch?: AgentStatusBatchContext - } - const pendingAgentStatusEvents: PendingAgentStatusEvent[] = [] - const transientClearWatermarkByConnectionId = new Map() - let agentStatusEffectDisposed = false - let pendingAgentStatusRetryTimer: ReturnType | 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 | null = null - let lastLiveAgentStatusApplyAt = 0 - - unsubs.push(attachMobileMarkdownBridge()) - - const handleWorktreesChanged = async ( - repoId: string, - renamed?: { oldWorktreeId: string; newWorktreeId: string }, - options?: { forceLocalOwner?: boolean; executionHostId?: ExecutionHostId } - ): Promise => { - 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 - ) - const purgeHostId = - options?.executionHostId ?? - (options?.forceLocalOwner ? LOCAL_EXECUTION_HOST_ID : undefined) - afterState.purgeWorktreeTerminalState( - purgeHostId ? removed.map((id) => ({ id, hostId: purgeHostId })) : removed - ) - afterState.removeWorkspaceSpaceWorktrees(removed) - } - } - const worktreeChangeRefreshQueue = createWorktreeChangeRefreshQueue(handleWorktreesChanged) - unsubs.push(worktreeChangeRefreshQueue.dispose) - - const activateNotifiedWorktree = async ( - { - repoId, - worktreeId, - setup, - startup, - defaultTabs - }: Extract, - options: { allowRuntimeEnvironment: boolean } - ): Promise => { - 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 => { - 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) - // Why: the host emits one reposChanged for group/folder-workspace edits too, so those - // catalogs go stale without this; groups first because folder workspaces resolve owners from them. - const runtimeOwner = { runtimeEnvironmentId: environmentId } - // Why: catalogs and worktrees are independent; serializing them put two 15s RPC - // timeouts ahead of worktree/lineage convergence on a wedged host. - await Promise.all([ - (async () => { - await useAppStore.getState().fetchProjectGroups(runtimeOwner) - await useAppStore.getState().fetchFolderWorkspaces(runtimeOwner) - })(), - 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(useAppStore.getState()), - 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, - () => { - invalidateRuntimeClientEventReplay({ - getSshStateReference: () => useAppStore.getState().sshStateByEnvironment, - requestProjectRefresh: () => runtimeProjectRefreshScheduler.request(environmentId), - markEnvironmentSshStateStale: () => - useAppStore.getState().markEnvironmentSshStateStale(environmentId), - hydrateEnvironmentSshState: () => - hydrateRuntimeEnvironmentSshState(environmentId, { force: true }), - sync: runtimeClientEventsSync.sync - }) - } - ) - }, - onEvent: handleRuntimeClientEvent - }) - - // Why: no on-connect repo fetch (PR #2); seed discovery for connected runtimes or remote projects hide until Add-Project. - const initialRuntimeEnvironmentState = useAppStore.getState() - const runtimeClientEventEnvironmentIds = getRuntimeClientEventEnvironmentIds( - initialRuntimeEnvironmentState - ) - for (const environmentId of runtimeClientEventEnvironmentIds) { - runtimeProjectRefreshScheduler.request(environmentId) - } - const reachableRuntimeEnvironmentIds = getReachableRuntimeEnvironmentIds( - initialRuntimeEnvironmentState - ) - const handleRuntimeEnvironmentStoreWrite = createRuntimeEnvironmentStoreSyncSubscriber({ - initialDesiredEnvironmentIds: runtimeClientEventEnvironmentIds, - initialReachableEnvironmentIds: reachableRuntimeEnvironmentIds, - buildEnvironmentKey: buildRuntimeClientEventEnvironmentKey, - getDesiredEnvironmentIds: getRuntimeClientEventEnvironmentIds, - getReachableEnvironmentIds: getReachableRuntimeEnvironmentIds, - requestProjectRefresh: (environmentId) => - runtimeProjectRefreshScheduler.request(environmentId), - markEnvironmentSshStateStale: (environmentId) => { - // No-op when the environment has no SSH bucket (e.g. web client). - useAppStore.getState().markEnvironmentSshStateStale(environmentId) - }, - sync: runtimeClientEventsSync.sync - }) - const unsubscribeRuntimeEnvironmentStore = useAppStore.subscribe( - handleRuntimeEnvironmentStoreWrite - ) - // Subscribe before the first runtime stream starts: replay invalidation may - // synchronously publish a tracked SSH bucket and relies on this listener to - // replace that subscription exactly once. - 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 () => { - const localOwner = { runtimeEnvironmentId: null } - await state.fetchRepos(localOwner) - await state.fetchProjectGroups(localOwner) - await state.fetchFolderWorkspaces(localOwner) - 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() - }) - ) - - const unsubscribeOpenSkillShare = window.api.ui.onOpenSkillShare?.((shareId) => { - useAppStore.getState().openSkillShare(shareId) - }) - if (unsubscribeOpenSkillShare) { - unsubs.push(unsubscribeOpenSkillShare) - } - - // 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(() => {}) - - const pendingSkillShare = window.api.ui.consumePendingSkillShare?.() - if (pendingSkillShare && typeof pendingSkillShare.then === 'function') { - void pendingSkillShare - .then((shareId) => { - if (shareId) { - useAppStore.getState().openSkillShare(shareId) - } - }) - .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 - } - const { worktreeVisibilityDefaults, ...activeOwnerUpdates } = updates - const settingsUpdates = store.settings.activeRuntimeEnvironmentId - ? activeOwnerUpdates - : updates - useAppStore.setState({ - settings: { - ...store.settings, - ...settingsUpdates, - notifications: { - ...store.settings.notifications, - ...updates.notifications - } - }, - ...(worktreeVisibilityDefaults - ? { - worktreeVisibilityDefaultsByHost: { - ...store.worktreeVisibilityDefaultsByHost, - local: worktreeVisibilityDefaults - } - } - : {}) - }) - if ('worktreeVisibilityDefaults' in updates) { - void store.fetchAllWorktrees({ visibilityOwnerHostId: 'local' }) - } - }) - ) - - // 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(() => { - if (isFloatingWorkspacePanelFocused()) { - return - } - deleteHoveredWorkspaceImmediately(useAppStore.getState()) - }) - ) - } - - 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(TOGGLE_WORKSPACE_BOARD_EVENT)) - }) - ) - } - - if (window.api.ui.onToggleAgentDashboard) { - unsubs.push( - window.api.ui.onToggleAgentDashboard(() => { - toggleAgentDashboardFromShortcut(useAppStore.getState(), () => { - void window.api.dashboard.openPopout() - }) - }) - ) - } - - 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 visibleTargets = getVisibleWorktreeShortcutTargets() - const target = visibleTargets[index] - if (target) { - if (target.executionHostId) { - activateAndRevealWorkspace(target.id, { executionHostId: target.executionHostId }) - } else { - activateAndRevealWorkspace(target.id) - } - } - }) - ) - - 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(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: isUnifiedTabPinned(store, worktreeId, browserTarget.workspaceId), - tabLabel: resolvePinnedTabLabel(store, worktreeId, browserTarget.workspaceId), - onClose: () => useAppStore.getState().closeBrowserTab(browserTarget.workspaceId) - }) - return - } - guardPinnedTabClose({ - isPinned: isUnifiedTabPinned(store, worktreeId, tabId), - tabLabel: resolvePinnedTabLabel(store, worktreeId, tabId), - onClose: () => { - const currentStore = useAppStore.getState() - closeMobileSessionTabInStore(currentStore, worktreeId, tabId) - } - }) - }) - ) - - unsubs.push( - window.api.ui.onSessionTabCloseRequest(({ requestId, tabId, worktreeId, expiresAt }) => { - const store = useAppStore.getState() - const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, tabId) - let cancelConfirmation: (() => void) | undefined - let timeout: ReturnType | undefined - let settled = false - const respond = (error?: string): void => { - if (settled) { - return - } - settled = true - if (timeout !== undefined) { - clearTimeout(timeout) - } - window.api.ui.respondSessionTabClose({ requestId, ...(error ? { error } : {}) }) - } - if (expiresAt !== undefined) { - timeout = setTimeout( - () => { - cancelConfirmation?.() - respond(SESSION_TAB_CLOSE_TIMEOUT_ERROR) - }, - Math.max(0, expiresAt - Date.now()) - ) - } - const closeAndRespond = (): void => { - if (expiresAt !== undefined && Date.now() >= expiresAt) { - respond(SESSION_TAB_CLOSE_TIMEOUT_ERROR) - return - } - try { - if (browserTarget) { - useAppStore.getState().closeBrowserTab(browserTarget.workspaceId) - respond() - return - } - const closed = closeMobileSessionTabInStore(useAppStore.getState(), worktreeId, tabId) - respond(closed ? undefined : SESSION_TAB_NOT_FOUND_ERROR) - } catch (error) { - respond(error instanceof Error ? error.message : SESSION_TAB_CLOSE_FAILED_ERROR) - } - } - const visibleId = browserTarget?.workspaceId ?? tabId - cancelConfirmation = guardPinnedTabClose({ - isPinned: isUnifiedTabPinned(store, worktreeId, visibleId), - tabLabel: resolvePinnedTabLabel(store, worktreeId, visibleId), - onClose: closeAndRespond, - onCancel: () => respond(SESSION_TAB_CLOSE_CANCELED_ERROR) - }) - }) - ) - - 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, localPtyTeardownOwnedExternally }) => { - let responded = false - const respond = (error?: string): void => { - if (responded) { - return - } - responded = true - window.api.ui.respondTerminalTabClose({ requestId, ...(error ? { error } : {}) }) - } - closeTerminalTab(tabId, { - rejectPinned: true, - ...(localPtyTeardownOwnedExternally ? { localPtyTeardownOwnedExternally: 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).catch((error) => { - toast.error(error instanceof Error ? error.message : String(error)) - }) - return - } - const worktreeId = store.activeWorktreeId - if (!worktreeId) { - return - } - const targetGroupId = - store.activeGroupIdByWorktree[worktreeId] ?? store.groupsByWorktree[worktreeId]?.[0]?.id - if (!targetGroupId) { - return - } - void store.openNewBrowserTabInActiveWorkspace(targetGroupId).catch((error) => { - toast.error(error instanceof Error ? error.message : String(error)) - }) - }) - ) - - 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' }).catch((error) => { - toast.error(error instanceof Error ? error.message : String(error)) - }) - }) - 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, - executionHostId: LOCAL_EXECUTION_HOST_ID - }) - // 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, - executionHostId: LOCAL_EXECUTION_HOST_ID - }) - }) - 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, - browserPageId: data.browserPageId, - 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 replyBrowserTabNotFound = (tabId: string): void => { - window.api.ui.replyTabClose({ - requestId: data.requestId, - code: 'browser_tab_not_found', - error: translate( - 'auto.hooks.useIpcEvents.0e3cf53060', - 'Browser tab {{value0}} not found', - { value0: tabId } - ) - }) - } - 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: isUnifiedTabPinned(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 - const owningWorktreeId = - Object.entries(store.browserTabsByWorktree).find(([, tabs]) => - tabs.some((tab) => tab.id === workspaceId) - )?.[0] ?? null - if (data.worktreeId && owningWorktreeId !== data.worktreeId) { - replyBrowserTabNotFound(tabToClose) - return - } - if (pages.length <= 1) { - 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) { - if (data.worktreeId && owningWorktreeId !== data.worktreeId) { - replyBrowserTabNotFound(tabToClose) - return - } - closeBrowserWorkspaceWithReply(owningWorktreeId, tabToClose) - return - } - if (explicitTargetId) { - replyBrowserTabNotFound(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 && isUnifiedTabPinned(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() - 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; settle: () => void } | undefined - const deadline = new Promise((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() - - 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 | null = null - const getRemoteWorkspaceClientId = (): Promise => { - 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 candidates = pendingAgentStatusEvents - .splice(0) - .filter((event) => now - event.firstSeenAt <= PENDING_AGENT_STATUS_TTL_MS) - let results: AgentStatusApplyResult[] - try { - results = applyAgentStatusBatch( - candidates.map((event) => ({ data: event.data, replay: event.replay, retry: true })) - ) - } catch (err) { - // Why: the queue was already spliced, so a throwing fold would drop the whole - // burst and strand every pane in it. Requeue ahead of newer arrivals and retry. - pendingAgentStatusEvents.unshift(...candidates) - throw err - } - for (let index = 0; index < candidates.length; index += 1) { - if (results[index] === 'pending') { - pendingAgentStatusEvents.push(candidates[index]) - } - } - if (pendingAgentStatusEvents.length === 0 && pendingAgentStatusRetryTimer !== null) { - globalThis.clearTimeout(pendingAgentStatusRetryTimer) - pendingAgentStatusRetryTimer = null - } - } finally { - isFlushingAgentStatuses = false - } - schedulePendingAgentStatusFlush() - } - - const applyAgentStatus = ( - data: AgentStatusIpcPayload, - options?: AgentStatusApplyOptions - ): AgentStatusApplyResult => { - const store = options?.batch?.transaction.getState() ?? 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, - workingMode: data.workingMode, - 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, - turnCompletedAt: data.turnCompletedAt, - // 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, - titleUsesTabTitle - } = options?.batch - ? resolvePaneKeyFromRoutingIndex(options.batch.routingIndex, paneKey) - : resolvePaneKey(store, paneKey) - const projectedTitles = - titleUsesTabTitle && ownerTabId - ? options?.batch?.projectedTitlesByTabId.get(ownerTabId) - : undefined - if (projectedTitles) { - title = projectedTitles.title - identityTitle = projectedTitles.identityTitle - } - 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 = options?.batch - ? resolveWorktreeConnectionFromRoutingIndex(options.batch.routingIndex, data.worktreeId) - : 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. - // 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:` 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' - } - const providerSessionUpdate: AgentStatusBatchUpdate = { - kind: 'providerSession', - paneKey, - agent: 'pi', - providerSession: data.providerSession, - timing: { updatedAt: data.receivedAt }, - routing: { - 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 } : {}) - }, - metadata: data.launchToken ? { launchToken: data.launchToken } : undefined - } - if (options?.batch) { - return options.batch.transaction.apply(providerSessionUpdate) ? 'applied' : 'dropped' - } - store.recordAgentProviderSession( - providerSessionUpdate.paneKey, - providerSessionUpdate.agent, - providerSessionUpdate.providerSession, - providerSessionUpdate.timing, - providerSessionUpdate.routing, - providerSessionUpdate.metadata - ) - 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 - // Why: main sequenced this row as the pane authority; carry its stamp rather than - // minting a renderer one, which would claim a second authority for the same observation. - const statusPayloadWithObservation = data.observation - ? { ...statusPayloadWithProvenance, observation: data.observation } - : statusPayloadWithProvenance - 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 - const update: AgentStatusUpdate = { - paneKey, - payload: statusPayloadWithObservation, - terminalTitle, - timing: { - updatedAt: data.receivedAt, - stateStartedAt: data.stateStartedAt - }, - routing: { - tabId: ownerTabId, - worktreeId: statusWorktreeId, - terminalHandle: data.terminalHandle, - ...(ownershipConnectionId !== undefined ? { connectionId: ownershipConnectionId } : {}) - }, - metadata: - data.providerSession || data.launchToken - ? { - ...(data.providerSession ? { providerSession: data.providerSession } : {}), - ...(data.launchToken ? { launchToken: data.launchToken } : {}) - } - : undefined - } - const applyPostCommitNotification = (): void => { - if (statusWorktreeId && (options?.replay !== true || resolvedPayload.state === 'working')) { - // 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, - ...(options?.replay === true ? { seedOnly: true } : {}) - }) - } - } - if (options?.batch) { - if (!options.batch.transaction.apply(update)) { - return 'dropped' - } - options.batch.notificationEffects.push(applyPostCommitNotification) - if ( - terminalTitle && - shouldApplyResolvedAgentTerminalTitleToTab(store, paneKey, title, terminalTitle) - ) { - const tabId = parsePaneKey(paneKey)?.tabId - if (tabId) { - options.batch.tabTitlesByTabId.set(tabId, terminalTitle) - if (titleUsesTabTitle) { - const titleChanges = - !title || !isDecorativeAgentTitleFrameChange(title, terminalTitle) - options.batch.projectedTitlesByTabId.set(tabId, { - title: titleChanges ? terminalTitle : title, - identityTitle: titleChanges ? terminalTitle : identityTitle - }) - } - } - } - } else { - store.setAgentStatus( - update.paneKey, - update.payload, - update.terminalTitle, - update.timing, - update.routing, - update.metadata - ) - applyResolvedAgentTerminalTitleToTab(useAppStore.getState(), paneKey, title, terminalTitle) - applyPostCommitNotification() - } - 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 - } - applyAgentStatusBatch(entries.map((data) => ({ data, 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 - } - const unsupportedRoutingIndex = createAgentStatusPaneRoutingIndex(unsupportedStore) - for (const entry of unsupportedEntries) { - if ( - entry.paneKey && - resolvePaneKeyFromRoutingIndex(unsupportedRoutingIndex, 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 applyAgentStatusBatch( - events: readonly AgentStatusBatchEvent[] - ): AgentStatusApplyResult[] { - if (events.length === 0) { - return [] - } - return useAppStore.getState().transactAgentStatuses((transaction) => { - const batch: AgentStatusBatchContext = { - transaction, - routingIndex: createAgentStatusPaneRoutingIndex(transaction.getState()), - projectedTitlesByTabId: new Map(), - tabTitlesByTabId: new Map(), - notificationEffects: [] - } - const results = events.map(({ data, replay, retry }) => - applyAgentStatus(data, { batch, replay, retry }) - ) - if (batch.tabTitlesByTabId.size > 0) { - transaction.afterCommit(() => { - useAppStore - .getState() - .updateTabTitles( - [...batch.tabTitlesByTabId].map(([tabId, title]) => ({ tabId, title })) - ) - }) - } - for (const effect of batch.notificationEffects) { - transaction.afterCommit(effect) - } - return results - }) - } - - function applyLiveAgentStatusBatch(batch: readonly AgentStatusIpcPayload[]): boolean { - return applyAgentStatusBatch(batch.map((data) => ({ data }))).some( - (result) => result === 'applied' - ) - } - - function flushLiveAgentStatusBurst(): void { - liveAgentStatusBurstTimer = null - lastLiveAgentStatusApplyAt = Date.now() - // Why: splice before publishing — synchronous Zustand subscribers can enqueue the next burst. - const batch = liveAgentStatusBurstQueue.splice(0) - if (!applyLiveAgentStatusBatch(batch)) { - 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) - applyLiveAgentStatusBatch(queuedForPane) - } - - 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, - paneKey: string, - previousTitle: string | undefined, - nextTitle: string | undefined -): void { - if ( - !nextTitle || - !shouldApplyResolvedAgentTerminalTitleToTab(store, paneKey, previousTitle, nextTitle) - ) { - return - } - const parsed = parsePaneKey(paneKey) - if (!parsed) { - 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) -} - -function shouldApplyResolvedAgentTerminalTitleToTab( - store: ReturnType, - paneKey: string, - previousTitle: string | undefined, - nextTitle: string | undefined -): boolean { - if (!nextTitle || nextTitle === previousTitle) { - return false - } - const parsed = parsePaneKey(paneKey) - if (!parsed) { - return false - } - const layout = store.terminalLayoutsByTabId?.[parsed.tabId] - if (layout?.root && layout.activeLeafId && layout.activeLeafId !== parsed.leafId) { - return false - } - return true -} - -type AgentStatusPaneResolution = { - exists: boolean - title: string | undefined - identityTitle: string | undefined - repoConnectionId: string | null - repoConnectionResolved: boolean - owningWorktreeId: string | undefined - titleUsesTabTitle: boolean -} - -type AgentStatusWorktreeConnectionResolution = { - worktreeExists: boolean - repoConnectionId: string | null - repoConnectionResolved: boolean -} - -type IndexedAgentStatusTab = { - title: string | undefined - unifiedLabel: string | undefined - owningWorktreeId: string -} - -type AgentStatusPaneRoutingIndex = { - tabsById: Map - layoutsByTabId: AppState['terminalLayoutsByTabId'] - leafIdsByRoot: WeakMap> - worktreesById: ReturnType - reposById: ReturnType -} - -function createUnifiedTerminalLabelIndex( - entries: AppState['unifiedTabsByWorktree'][string] | undefined -): Map { - const labelsByTabId = new Map() - for (const entry of entries ?? []) { - if (entry.contentType !== 'terminal' || labelsByTabId.has(entry.entityId)) { - continue - } - const rawLabel = entry.label?.trim() - labelsByTabId.set(entry.entityId, rawLabel && rawLabel.length > 0 ? rawLabel : undefined) - } - return labelsByTabId -} - -function createAgentStatusPaneRoutingIndex( - store: ReturnType -): AgentStatusPaneRoutingIndex { - const tabsById = new Map() - for (const [worktreeId, tabs] of Object.entries(store.tabsByWorktree)) { - const unifiedLabelsByTabId = createUnifiedTerminalLabelIndex( - store.unifiedTabsByWorktree?.[worktreeId] - ) - for (const tab of tabs) { - const tabId = tab.id - if (!tabsById.has(tabId)) { - tabsById.set(tabId, { - title: tab.title, - unifiedLabel: unifiedLabelsByTabId.get(tabId), - owningWorktreeId: worktreeId - }) - } - } - } - return { - tabsById, - layoutsByTabId: store.terminalLayoutsByTabId, - leafIdsByRoot: new WeakMap(), - worktreesById: getWorktreeMapFromState(store), - reposById: getRepoMapFromState(store) - } -} - -function resolveWorktreeConnectionFromRoutingIndex( - index: AgentStatusPaneRoutingIndex, - worktreeId: string -): AgentStatusWorktreeConnectionResolution { - const worktree = index.worktreesById.get(worktreeId) - if (!worktree) { - return { worktreeExists: false, repoConnectionId: null, repoConnectionResolved: false } - } - const repo = index.reposById.get(worktree.repoId) - return { - worktreeExists: true, - repoConnectionId: repo?.connectionId ?? null, - repoConnectionResolved: repo !== undefined - } -} - -function resolvePaneKeyFromRoutingIndex( - index: AgentStatusPaneRoutingIndex, - paneKey: string -): AgentStatusPaneResolution { - const parsed = parsePaneKey(paneKey) - if (!parsed) { - return { - exists: false, - title: undefined, - identityTitle: undefined, - repoConnectionId: null, - repoConnectionResolved: false, - owningWorktreeId: undefined, - titleUsesTabTitle: false - } - } - const { tabId, leafId } = parsed - const tab = index.tabsById.get(tabId) - if (!tab) { - return { - exists: false, - title: undefined, - identityTitle: undefined, - repoConnectionId: null, - repoConnectionResolved: false, - owningWorktreeId: undefined, - titleUsesTabTitle: false - } - } - const connection = resolveWorktreeConnectionFromRoutingIndex(index, tab.owningWorktreeId) - const layout = index.layoutsByTabId?.[tabId] - if (layout?.root) { - let leafIds = index.leafIdsByRoot.get(layout.root) - if (!leafIds) { - leafIds = new Set(collectLeafIdsInOrder(layout.root)) - index.leafIdsByRoot.set(layout.root, leafIds) - } - if (!leafIds.has(leafId)) { - return { - exists: false, - title: undefined, - identityTitle: undefined, - repoConnectionId: connection.repoConnectionId, - repoConnectionResolved: connection.repoConnectionResolved, - owningWorktreeId: tab.owningWorktreeId, - titleUsesTabTitle: false - } - } - } - const rawPaneTitle = layout?.titlesByLeafId?.[leafId] - const paneTitle = rawPaneTitle && rawPaneTitle.length > 0 ? rawPaneTitle : undefined - return { - exists: true, - title: paneTitle ?? tab.title, - identityTitle: paneTitle ?? tab.unifiedLabel ?? tab.title, - repoConnectionId: connection.repoConnectionId, - repoConnectionResolved: connection.repoConnectionResolved, - owningWorktreeId: tab.owningWorktreeId, - titleUsesTabTitle: paneTitle === undefined - } -} - -/** 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 (an SSH reconnect retires the - * old connectionId, so events still in flight under it must not land). */ -function resolvePaneKey( - store: ReturnType, - paneKey: string -): { - exists: boolean - title: string | undefined - identityTitle: string | undefined - repoConnectionId: string | null - repoConnectionResolved: boolean - owningWorktreeId: string | undefined - titleUsesTabTitle: boolean -} { - const parsed = parsePaneKey(paneKey) - if (!parsed) { - return { - exists: false, - title: undefined, - identityTitle: undefined, - repoConnectionId: null, - repoConnectionResolved: false, - owningWorktreeId: undefined, - titleUsesTabTitle: false - } - } - 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, - titleUsesTabTitle: false - } - } - // 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, - titleUsesTabTitle: false - } - } - // 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, - titleUsesTabTitle: paneTitle === undefined - } -} - -function resolveWorktreeConnection( - store: ReturnType, - 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' } + useEffect(() => installAppLifetimeIpcEvents(), []) } diff --git a/src/renderer/src/lib/workspace-activation-path-gate.test.ts b/src/renderer/src/lib/workspace-activation-path-gate.test.ts index 430e9a31571..b4b8e2de049 100644 --- a/src/renderer/src/lib/workspace-activation-path-gate.test.ts +++ b/src/renderer/src/lib/workspace-activation-path-gate.test.ts @@ -135,7 +135,10 @@ describe('Cmd/Ctrl+1-9 folder-workspace path gate (#10716)', () => { // Why: the guard only helps if the IPC handler actually calls it. Pin the source // so re-pointing the handler back at the unguarded activateAndRevealWorktree fails. it('wires onJumpToWorktreeIndex to the guarded workspace activator', async () => { - const source = await readFile(new URL('../hooks/useIpcEvents.ts', import.meta.url), 'utf8') + const source = await readFile( + new URL('../hooks/ipc-events/workspace-shortcut-ipc-bridge.ts', import.meta.url), + 'utf8' + ) const handler = source.slice( source.indexOf('onJumpToWorktreeIndex('), source.indexOf('onJumpToTabIndex(')