refactor(renderer): split IPC event bridges (#16185)

* refactor(renderer): split IPC event bridges

* test(renderer): follow extracted IPC shortcut bridge
This commit is contained in:
Neil
2026-08-24 20:45:23 -07:00
committed by GitHub
parent f57114a106
commit 2c77f71c4f
48 changed files with 5372 additions and 4463 deletions
-1
View File
@@ -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
@@ -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.
@@ -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)
}
@@ -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<string, ProjectedAgentTabTitles>
tabTitlesByTabId: Map<string, string>
notificationEffects: (() => void)[]
}
export type AgentStatusBatchEvent = {
data: AgentStatusIpcPayload
replay?: boolean
retry?: boolean
}
export type AgentStatusApplyOptions = {
replay?: boolean
retry?: boolean
batch?: AgentStatusBatchContext
}
@@ -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<string, number>
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
}
@@ -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<string, number>()
let disposed = false
let pendingAgentStatusRetryTimer: ReturnType<typeof setTimeout> | null = null
let isFlushingAgentStatuses = false
const liveAgentStatusBurstQueue: AgentStatusIpcPayload[] = []
let liveAgentStatusBurstTimer: ReturnType<typeof setTimeout> | 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
}
}
@@ -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<string, number>
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)
}
}
@@ -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<string, IndexedAgentStatusTab>
layoutsByTabId: AppState['terminalLayoutsByTabId']
leafIdsByRoot: WeakMap<TerminalPaneLayoutNode, Set<string>>
worktreesById: ReturnType<typeof getWorktreeMapFromState>
reposById: ReturnType<typeof getRepoMapFromState>
}
function createUnifiedTerminalLabelIndex(
entries: AppState['unifiedTabsByWorktree'][string] | undefined
): Map<string, string | undefined> {
const labelsByTabId = new Map<string, string | undefined>()
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<string, IndexedAgentStatusTab>()
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
}
}
@@ -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<AppState, 'recentlyClosedAgentStatusTabIds' | 'recentlyRetiredAgentStatusPaneKeys'>,
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<typeof useAppStore.getState>,
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<typeof useAppStore.getState>,
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<typeof useAppStore.getState>,
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<typeof useAppStore.getState>,
worktreeId: string
): {
worktreeExists: boolean
repoConnectionId: string | null
repoConnectionResolved: boolean
} {
const worktree = getWorktreeMapFromState(store).get(worktreeId)
if (!worktree) {
return { worktreeExists: false, repoConnectionId: null, repoConnectionResolved: false }
}
const repo = getRepoMapFromState(store).get(worktree.repoId)
return {
worktreeExists: true,
repoConnectionId: repo?.connectionId ?? null,
repoConnectionResolved: repo !== undefined
}
}
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' }
}
@@ -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')
}
}
@@ -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']])
})
})
@@ -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<string, { token: string; timer: number }>()
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 })
}
@@ -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'
})
}
})
)
}
@@ -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<AppState, 'browserTabsByWorktree' | 'unifiedTabsByWorktree'>,
worktreeId: string,
tabId: string
): BrowserSessionTabTarget | null {
const tab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find((item) => item.id === tabId)
if (tab?.contentType === 'browser') {
return {
kind: 'unified-browser',
unifiedTabId: tab.id,
workspaceId: tab.entityId,
groupId: tab.groupId
}
}
const fallbackBrowser = (state.browserTabsByWorktree[worktreeId] ?? []).find(
(workspace) => workspace.id === tabId
)
return fallbackBrowser ? { kind: 'fallback-browser', workspaceId: fallbackBrowser.id } : null
}
@@ -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 })
})
)
}
@@ -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.
}
@@ -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<AppState, 'invalidateStaleDirectSshTargetPtyBindings' | 'retryDirectSshTargetPanes'>
>
type AuthorityDeadline = { timer: ReturnType<typeof setTimeout>; settle: () => void }
export type DirectSshBridgeRuntime = {
reconnectAuthorityByTarget: Map<string, DirectSshAuthority>
reconnectCoordinator: DirectSshReconnectCoordinator
hostHydration: DirectSshHostHydration
remoteWorkspaceTargetSync: RemoteWorkspaceTargetSync | null
currentAuthority: (targetId: string) => DirectSshAuthority | null
terminalActions: () => DirectSshTerminalActions
prepareAndSync: (
authority: DirectSshAuthority,
reason: DirectSshPreparationReason,
options?: { authorityAlreadyReplaced?: boolean }
) => Promise<void>
isStopped: () => boolean
addDeadline: (deadline: AuthorityDeadline) => void
removeDeadline: (deadline: AuthorityDeadline) => void
stop: () => void
}
export function createDirectSshBridgeRuntime(): DirectSshBridgeRuntime {
const reconnectAuthorityByTarget = new Map<string, DirectSshAuthority>()
const deadlines = new Set<AuthorityDeadline>()
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()
}
}
}
@@ -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<string, number>()
const pendingPortHydrationByTargetId = new Map<
string,
{ receivedForwardPush: boolean; receivedDetectedPush: boolean }
>()
const hydrateSshPorts = (targetId: string, authority: DirectSshAuthority): void => {
const pendingPortHydration = {
receivedForwardPush: false,
receivedDetectedPush: false
}
pendingPortHydrationByTargetId.set(targetId, pendingPortHydration)
const isHydrationAuthorityCurrent = (): boolean =>
!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<typeof setTimeout>; settle: () => void } | undefined
const deadline = new Promise<null>((resolve) => {
const settle = (): void => resolve(null)
const timer = setTimeout(settle, 5_000)
pendingDeadline = { timer, settle }
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<string, number>()
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) }
: {})
})
)
}
@@ -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
}
}
@@ -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)
})
)
}
@@ -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<AppState, 'activeView' | 'taskPageData'>
): NewWorkspaceShortcutModalData {
const linearIssue =
state.activeView === 'tasks' ? (state.taskPageData.openLinearIssue ?? null) : null
if (!linearIssue) {
return { telemetrySource: 'shortcut' }
}
return {
telemetrySource: 'shortcut',
prefilledName: getLinearIssueWorkspaceName(linearIssue),
// Cmd+N from a Linear issue mirrors its Start-workspace action with source context.
linkedWorkItem: buildLinearIssueLinkedWorkItem(linearIssue)
}
}
export function openNewWorkspaceFromShortcut(
state: Pick<AppState, 'activeModal' | 'activeView' | 'taskPageData' | 'openModal'>
): void {
if (state.activeModal === 'new-workspace-composer') {
return
}
state.openModal('new-workspace-composer', buildNewWorkspaceShortcutModalData(state))
}
@@ -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
})
}
@@ -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)
})
)
}
}
@@ -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)
}
}
@@ -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<string | null> | null = null
const getClientId = (): Promise<string | null> => {
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'
})
})
})()
})
)
}
@@ -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<void> => {
if ((useAppStore.getState().repos ?? []).some((repo) => repo.id === repoId)) {
return
}
await useAppStore.getState().fetchRuntimeEnvironmentRepos(environmentId)
}
const runtimeProjectRefreshScheduler = createRuntimeProjectRefreshScheduler({
refresh: async (environmentId) => {
// Why: project events can reveal target CRUD, but known target states already arrive by push.
void refreshRuntimeEnvironmentSshTargetMetadata(environmentId).catch(() => {})
const repos = await useAppStore.getState().fetchRuntimeEnvironmentRepos(environmentId)
// 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
}
@@ -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<string>()
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<unknown>
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(() => {})
}
@@ -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<typeof setTimeout> | 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 } : {})
})
})
)
}
@@ -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
})
})
)
}
}
@@ -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)
})
)
}
@@ -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 } } : {})
}
}
@@ -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<SplitTerminalPaneDetail>(SPLIT_TERMINAL_PANE_EVENT, {
detail: {
tabId: tab.id,
paneRuntimeId: -1,
direction: splitDirection ?? 'horizontal',
sourceLeafId: splitFromLeafId,
sourcePtyId,
telemetrySource: splitTelemetrySource,
newLeafId: leafId,
ptyId
}
})
)
} else {
// Why: CLI/runtime PTYs emit hook events before the tab mounts, so the leaf must exist in layout for paneKey validation.
const existingLayout = reusedTab
? activateExistingLeafInLayout(
store.terminalLayoutsByTabId?.[tab.id],
leafId,
ptyId,
title
)
: null
if (existingLayout) {
store.updateTabPtyId(tab.id, ptyId)
store.setTabLayout(tab.id, existingLayout)
} else {
store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId, ptyId, title))
}
}
}
if (command) {
store.queueTabStartupCommand(tab.id, {
command,
...(env ? { env } : {}),
...(launchConfig ? { launchConfig } : {}),
...(resumeProviderSession ? { resumeProviderSession } : {}),
...(launchToken ? { launchToken } : {}),
...(launchAgent ? { launchAgent } : {})
})
}
if (ptyId && terminalPresentation === 'background') {
requestBackgroundTerminalWorktreeMount({ worktreeId, tabIds: [tab.id] })
}
if (requestId) {
// Why: attest the actual binding; recovery callers compare it with their expected identity.
const identity =
ptyId && tabId && leafId
? verifyTerminalRevealIdentity(useAppStore.getState(), {
worktreeId,
tabId: tab.id,
leafId,
ptyId
})
: undefined
window.api.ui.replyTerminalCreate({
requestId,
tabId: tab.id,
title: title ?? tab.title,
...(identity ? { identity } : {})
})
}
} catch (err) {
if (!requestId) {
throw err
}
window.api.ui.replyTerminalCreate({
requestId,
error: err instanceof Error ? err.message : 'Terminal reveal failed'
})
}
}
)
)
// Why: background-mount a mobile-subscribed tab's PTY without navigating the desktop (STA-1840).
unsubs.push(
window.api.ui.onRequestTerminalTabMount(({ worktreeId, tabId, ptyId }) => {
if (!worktreeId) {
return
}
// Why: synthetic pty handles need persisted-tab resolution; a miss must not mount every saved tab in a hidden worktree.
const mount = planMobileTerminalTabMount(
useAppStore.getState(),
{
worktreeId,
...(tabId ? { tabId } : {}),
...(ptyId ? { ptyId } : {})
},
{
isTabMounted: hasRegisteredRuntimeTerminalTab
}
)
if (mount) {
requestBackgroundTerminalWorktreeMount(mount)
}
})
)
// Why: CLI-driven terminal creation waits for the tabId reply so it can hand the caller a usable handle immediately.
}
@@ -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'
})
}
})
)
}
@@ -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)
})
)
}
@@ -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()
})
})
@@ -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()
})
)
}
@@ -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)
})
)
}
@@ -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<string, number>()
function getAuthoritativeDetectedWorktreeIds(state: AppState, repoId: string): Set<string> | 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<string> {
return new Set((state.worktreesByRepo[repoId] ?? []).map((worktree) => worktree.id))
}
export type WorktreeEventRuntime = {
worktreeChangeRefreshQueue: WorktreeChangeRefreshQueue
activateNotifiedWorktree: (
event: Extract<RuntimeClientEvent, { type: 'activateWorktree' }>,
options: { allowRuntimeEnvironment: boolean }
) => Promise<void>
}
export function createWorktreeEventRuntime(
unsubs: (() => void)[],
isRuntimeEnvironmentActive: () => boolean
): WorktreeEventRuntime {
const handleWorktreesChanged = async (
repoId: string,
renamed?: { oldWorktreeId: string; newWorktreeId: string },
options?: { forceLocalOwner?: boolean; executionHostId?: ExecutionHostId }
): Promise<void> => {
const localRefreshStartedWithRuntime =
options?.forceLocalOwner === true && isRuntimeEnvironmentActive()
// Why: capture active-ness before migration moves the pointer; re-key maps before the diff so a rename isn't a deletion.
const renamedWasActive =
renamed != null && useAppStore.getState().activeWorktreeId === renamed.oldWorktreeId
if (renamed) {
// Shield both ids from the deletion diff across the rename's event burst — the worktree list lags the on-disk move.
const expiry = Date.now() + WORKTREE_RENAME_PURGE_GRACE_MS
recentlyRenamedWorktreeIdExpiry.set(renamed.oldWorktreeId, expiry)
recentlyRenamedWorktreeIdExpiry.set(renamed.newWorktreeId, expiry)
useAppStore.getState().migrateWorktreeIdentity(renamed.oldWorktreeId, renamed.newWorktreeId)
}
// Why: diff before/after fetch to catch out-of-band deletions and purge worktree state, else zombie ptyId entries leak (design §2c, §4.4).
const state = useAppStore.getState()
const before =
getAuthoritativeDetectedWorktreeIds(state, repoId) ??
getVisibleWorktreeIdsForRepo(state, repoId)
await state.fetchWorktrees(
repoId,
options?.forceLocalOwner
? { forceLocalOwner: true }
: options?.executionHostId
? {
executionHostId: options.executionHostId,
suppressRemoteLineageRefresh: true
}
: undefined
)
await useAppStore
.getState()
.fetchWorktreeLineage(
options?.forceLocalOwner
? { forceLocalOwner: true }
: options?.executionHostId
? { executionHostId: options.executionHostId }
: undefined
)
// Why: an id change unmounts the active pane; re-activate so the tab reconciles, else it vanishes until re-select.
if (renamedWasActive && renamed) {
useAppStore.getState().setActiveWorktree(renamed.newWorktreeId)
}
// Sweep expired rename-grace entries before any early return, else forced-local
// (or non-authoritative) events let the map grow for the session.
const now = Date.now()
for (const [id, expiry] of recentlyRenamedWorktreeIdExpiry) {
if (expiry <= now) {
recentlyRenamedWorktreeIdExpiry.delete(id)
}
}
// Why: the deletion diff below is repo-wide, but a forced-local scan overlapping
// a runtime cannot prove remote absence (legacy runtime rows may lack hostId).
// fetchWorktrees still purges removed local rows host-scoped; accepted gap: the
// workspace-space entry survives until the next local-only rescan.
if (
options?.forceLocalOwner &&
(localRefreshStartedWithRuntime || isRuntimeEnvironmentActive())
) {
return
}
const afterState = useAppStore.getState()
const after = getAuthoritativeDetectedWorktreeIds(afterState, repoId)
if (!after) {
return
}
const removed: string[] = []
for (const id of before) {
if (after.has(id)) {
continue
}
// A recently renamed worktree's old/new id isn't a deletion — its state moved to the new id; the list just lags.
const graceExpiry = recentlyRenamedWorktreeIdExpiry.get(id)
if (graceExpiry != null && graceExpiry > now) {
continue
}
removed.push(id)
}
if (removed.length > 0) {
console.warn(
`[worktree-purge] diff-based purge removing state for ${removed.length} worktree(s):`,
removed
)
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<RuntimeClientEvent, { type: 'activateWorktree' }>,
options: { allowRuntimeEnvironment: boolean }
): Promise<void> => {
if (!options.allowRuntimeEnvironment && isRuntimeEnvironmentActive()) {
// Why: local CLI worktree events carry local ids; runtime activation comes via the remote stream, allowed separately.
return
}
const existedBeforeFetch = Boolean(useAppStore.getState().getKnownWorktreeById(worktreeId))
// Why: fetch first so activation can resolve the CLI-created worktree; it arrived from main, not yet in renderer state.
await useAppStore.getState().fetchWorktrees(repoId)
const existsAfterFetch = Boolean(useAppStore.getState().getKnownWorktreeById(worktreeId))
// Why: use the canonical activation path so the CLI switch records a back/forward visit, or the nav buttons ignore it.
activateAndRevealWorktree(worktreeId, {
...(setup ? { setup } : {}),
...(startup ? { startup } : {}),
...(defaultTabs ? { defaultTabs } : {}),
...(!existedBeforeFetch && existsAfterFetch ? { sidebarRevealBehavior: 'auto' } : {}),
// Why: this activation came from the host runtime stream; echoing it back can create a selection loop.
notifyHostRuntime: false
})
}
return { worktreeChangeRefreshQueue, activateNotifiedWorktree }
}
@@ -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.
}
@@ -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
@@ -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: {
@@ -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', () => {
@@ -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<typeof ReactModule>('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<string, ListenerRecord[]>()
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<string, unknown> =>
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)
})
})
@@ -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', () => {
@@ -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', () => {
@@ -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 () => {
File diff suppressed because it is too large Load Diff
@@ -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(')