mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
@@ -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(')
|
||||
|
||||
Reference in New Issue
Block a user