mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 08:02:43 +00:00
* Fix terminal scroll restore behavior * Fix terminal pane global effects test args
2179 lines
81 KiB
TypeScript
2179 lines
81 KiB
TypeScript
/* oxlint-disable max-lines -- Why: this App-level IPC bridge intentionally keeps the renderer's main-process event contract in one place so shortcut, runtime, updater, and agent-status wiring do not drift across files. */
|
|
import { useEffect } from 'react'
|
|
import { useAppStore } from '../store'
|
|
import { getWorktreeMapFromState, getRepoMapFromState } from '@/store/selectors'
|
|
import { applyUIZoom } from '@/lib/ui-zoom'
|
|
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
|
import { runSleepWorktree } from '@/components/sidebar/sleep-worktree-flow'
|
|
import {
|
|
BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT,
|
|
SPLIT_TERMINAL_PANE_EVENT,
|
|
CLOSE_TERMINAL_PANE_EVENT
|
|
} from '@/constants/terminal'
|
|
import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal'
|
|
import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees'
|
|
import { nextEditorFontZoomLevel, computeEditorFontSize } from '@/lib/editor-font-zoom'
|
|
import type {
|
|
TerminalLayoutSnapshot,
|
|
TerminalPaneLayoutNode,
|
|
UpdateStatus,
|
|
WorkspaceSessionState
|
|
} from '../../../shared/types'
|
|
import type {
|
|
RemoteWorkspacePatchResult,
|
|
RemoteWorkspaceSnapshot
|
|
} from '../../../shared/remote-workspace-types'
|
|
import type { RateLimitState } from '../../../shared/rate-limit-types'
|
|
import type { SshConnectionState } from '../../../shared/ssh-types'
|
|
import type {
|
|
RuntimeBrowserDriverState,
|
|
RuntimeTerminalDriverState
|
|
} from '../../../shared/runtime-types'
|
|
import { importRemoteWorkspaceSession } from '../../../shared/remote-workspace-session-projection'
|
|
import { zoomLevelToPercent, ZOOM_MIN, ZOOM_MAX } from '@/components/settings/SettingsConstants'
|
|
import { dispatchZoomLevelChanged } from '@/lib/zoom-events'
|
|
import { resolveZoomTarget } from './resolve-zoom-target'
|
|
import {
|
|
handleSwitchTab,
|
|
handleSwitchTabAcrossAllTypes,
|
|
handleSwitchTerminalTab
|
|
} from './ipc-tab-switch'
|
|
import {
|
|
normalizeAgentStatusPayload,
|
|
type AgentStatusIpcPayload
|
|
} from '../../../shared/agent-status-types'
|
|
import { isGitRepoKind } from '../../../shared/repo-kind'
|
|
import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal'
|
|
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
|
|
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
|
|
import { focusRuntimeTerminalSurface } from '@/runtime/sync-runtime-graph'
|
|
import { setFitOverride, hydrateOverrides } from '@/lib/pane-manager/mobile-fit-overrides'
|
|
import { setDriverForPty, hydrateDrivers } from '@/lib/pane-manager/mobile-driver-state'
|
|
import {
|
|
hydrateBrowserDrivers,
|
|
setDriverForBrowserPage
|
|
} from '@/lib/pane-manager/browser-mobile-driver-state'
|
|
import { destroyPersistentWebview } from '@/components/browser-pane/webview-registry'
|
|
import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge'
|
|
import { detectLanguage } from '@/lib/language-detect'
|
|
import { parsePaneKey } from '../../../shared/stable-pane-id'
|
|
import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization'
|
|
import { track } from '@/lib/telemetry'
|
|
import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers'
|
|
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
|
|
import type { AppState } from '../store/types'
|
|
import {
|
|
closeWebRuntimeSessionTab,
|
|
createWebRuntimeSessionBrowserTab,
|
|
createWebRuntimeSessionTerminal,
|
|
isWebRuntimeSessionActive
|
|
} from '@/runtime/web-runtime-session'
|
|
import {
|
|
observeAgentHookCompletionForNotification,
|
|
resetAgentHookCompletionNotificationCoordinators,
|
|
syncAgentHookCompletionNotificationSettings
|
|
} from './agent-hook-completion-notifications'
|
|
|
|
export { resolveZoomTarget } from './resolve-zoom-target'
|
|
|
|
const ZOOM_STEP = 0.5
|
|
let remoteWorkspaceSnapshotApplyDepth = 0
|
|
let remoteWorkspaceSnapshotWriteSuppressUntil = 0
|
|
const REMOTE_WORKSPACE_SNAPSHOT_WRITE_SUPPRESS_MS = 1000
|
|
|
|
type TerminalSplitDirection = 'horizontal' | 'vertical'
|
|
|
|
function insertLeafAfterSource(
|
|
node: TerminalPaneLayoutNode,
|
|
sourceLeafId: string,
|
|
newLeafId: string,
|
|
direction: TerminalSplitDirection
|
|
): { node: TerminalPaneLayoutNode; inserted: boolean } {
|
|
if (node.type === 'leaf') {
|
|
if (node.leafId !== sourceLeafId) {
|
|
return { node, inserted: false }
|
|
}
|
|
return {
|
|
node: {
|
|
type: 'split',
|
|
direction,
|
|
first: node,
|
|
second: { type: 'leaf', leafId: newLeafId },
|
|
ratio: 0.5
|
|
},
|
|
inserted: true
|
|
}
|
|
}
|
|
|
|
const first = insertLeafAfterSource(node.first, sourceLeafId, newLeafId, direction)
|
|
if (first.inserted) {
|
|
return { node: { ...node, first: first.node }, inserted: true }
|
|
}
|
|
const second = insertLeafAfterSource(node.second, sourceLeafId, newLeafId, direction)
|
|
if (second.inserted) {
|
|
return { node: { ...node, second: second.node }, inserted: true }
|
|
}
|
|
return { node, inserted: false }
|
|
}
|
|
|
|
function addSplitLeafToLayout(
|
|
layout: TerminalLayoutSnapshot | null | undefined,
|
|
sourceLeafId: string,
|
|
newLeafId: string,
|
|
ptyId: string,
|
|
direction: TerminalSplitDirection,
|
|
title?: string | null
|
|
): TerminalLayoutSnapshot {
|
|
const root = layout?.root ?? { type: 'leaf', leafId: sourceLeafId }
|
|
const existingLeafIds = collectLeafIdsInOrder(root)
|
|
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: newLeafId,
|
|
expandedLeafId: null,
|
|
ptyIdsByLeafId: {
|
|
...layout?.ptyIdsByLeafId,
|
|
[newLeafId]: ptyId
|
|
},
|
|
...(title
|
|
? {
|
|
titlesByLeafId: {
|
|
...layout?.titlesByLeafId,
|
|
[newLeafId]: title
|
|
}
|
|
}
|
|
: {})
|
|
}
|
|
}
|
|
|
|
function activateExistingLeafInLayout(
|
|
layout: TerminalLayoutSnapshot | null | undefined,
|
|
leafId: string,
|
|
ptyId: string,
|
|
title?: string | null
|
|
): TerminalLayoutSnapshot | null {
|
|
if (!layout?.root || !collectLeafIdsInOrder(layout.root).includes(leafId)) {
|
|
return null
|
|
}
|
|
return {
|
|
...layout,
|
|
activeLeafId: leafId,
|
|
expandedLeafId: null,
|
|
ptyIdsByLeafId: {
|
|
...layout.ptyIdsByLeafId,
|
|
[leafId]: ptyId
|
|
},
|
|
...(title
|
|
? {
|
|
titlesByLeafId: {
|
|
...layout.titlesByLeafId,
|
|
[leafId]: title
|
|
}
|
|
}
|
|
: {})
|
|
}
|
|
}
|
|
|
|
export function isRemoteWorkspaceSnapshotApplyInProgress(): boolean {
|
|
return (
|
|
remoteWorkspaceSnapshotApplyDepth > 0 || Date.now() < remoteWorkspaceSnapshotWriteSuppressUntil
|
|
)
|
|
}
|
|
|
|
async function waitForWorkspaceSessionReady(): Promise<boolean> {
|
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
if (useAppStore.getState().workspaceSessionReady) {
|
|
return true
|
|
}
|
|
await new Promise((resolve) => window.setTimeout(resolve, 100))
|
|
}
|
|
return useAppStore.getState().workspaceSessionReady
|
|
}
|
|
|
|
async function prepareRemoteWorkspaceTarget(targetId: string): Promise<boolean> {
|
|
if (!(await waitForWorkspaceSessionReady())) {
|
|
return false
|
|
}
|
|
const store = useAppStore.getState()
|
|
let repos = store.repos.filter((repo) => repo.connectionId === targetId)
|
|
if (repos.length === 0) {
|
|
await store.fetchRepos()
|
|
repos = useAppStore.getState().repos.filter((repo) => repo.connectionId === targetId)
|
|
}
|
|
await Promise.all(repos.map((repo) => useAppStore.getState().fetchWorktrees(repo.id)))
|
|
await useAppStore.getState().fetchWorktreeLineage()
|
|
return true
|
|
}
|
|
|
|
function targetRepoIds(targetId: string): Set<string> {
|
|
return new Set(
|
|
useAppStore
|
|
.getState()
|
|
.repos.filter((repo) => repo.connectionId === targetId)
|
|
.map((repo) => repo.id)
|
|
)
|
|
}
|
|
|
|
function targetWorktreeIds(targetId: string): Set<string> {
|
|
const repoIds = targetRepoIds(targetId)
|
|
return new Set(
|
|
Object.values(useAppStore.getState().worktreesByRepo)
|
|
.flat()
|
|
.filter((worktree) => repoIds.has(worktree.repoId))
|
|
.map((worktree) => worktree.id)
|
|
)
|
|
}
|
|
|
|
function mergeRemoteWorkspaceSession(
|
|
current: WorkspaceSessionState,
|
|
remote: WorkspaceSessionState,
|
|
targetId: string
|
|
): WorkspaceSessionState {
|
|
const replaceWorktreeIds = targetWorktreeIds(targetId)
|
|
const remoteTabIds = new Set(
|
|
Object.values(remote.tabsByWorktree)
|
|
.flat()
|
|
.map((tab) => tab.id)
|
|
)
|
|
const replacedTabIds = new Set([
|
|
...remoteTabIds,
|
|
...Object.entries(current.tabsByWorktree)
|
|
.filter(([worktreeId]) => replaceWorktreeIds.has(worktreeId))
|
|
.flatMap(([, tabs]) => tabs.map((tab) => tab.id))
|
|
])
|
|
const omitTargetWorktrees = <T>(record: Record<string, T> | undefined): Record<string, T> =>
|
|
Object.fromEntries(
|
|
Object.entries(record ?? {}).filter(([worktreeId]) => !replaceWorktreeIds.has(worktreeId))
|
|
)
|
|
|
|
return {
|
|
...current,
|
|
activeRepoId:
|
|
remote.activeRepoId ??
|
|
(current.activeWorktreeId && replaceWorktreeIds.has(current.activeWorktreeId)
|
|
? null
|
|
: current.activeRepoId),
|
|
activeWorktreeId:
|
|
remote.activeWorktreeId ??
|
|
(current.activeWorktreeId && replaceWorktreeIds.has(current.activeWorktreeId)
|
|
? null
|
|
: current.activeWorktreeId),
|
|
activeTabId:
|
|
remote.activeTabId ??
|
|
(current.activeTabId && replacedTabIds.has(current.activeTabId) ? null : current.activeTabId),
|
|
tabsByWorktree: {
|
|
...omitTargetWorktrees(current.tabsByWorktree),
|
|
...remote.tabsByWorktree
|
|
},
|
|
terminalLayoutsByTabId: {
|
|
...Object.fromEntries(
|
|
Object.entries(current.terminalLayoutsByTabId).filter(
|
|
([tabId]) => !replacedTabIds.has(tabId)
|
|
)
|
|
),
|
|
...remote.terminalLayoutsByTabId
|
|
},
|
|
activeWorktreeIdsOnShutdown: [
|
|
...(current.activeWorktreeIdsOnShutdown ?? []).filter((id) => !replaceWorktreeIds.has(id)),
|
|
...(remote.activeWorktreeIdsOnShutdown ?? [])
|
|
],
|
|
activeTabIdByWorktree: {
|
|
...omitTargetWorktrees(current.activeTabIdByWorktree),
|
|
...remote.activeTabIdByWorktree
|
|
},
|
|
remoteSessionIdsByTabId: {
|
|
...Object.fromEntries(
|
|
Object.entries(current.remoteSessionIdsByTabId ?? {}).filter(
|
|
([tabId]) => !replacedTabIds.has(tabId)
|
|
)
|
|
),
|
|
...remote.remoteSessionIdsByTabId
|
|
},
|
|
lastVisitedAtByWorktreeId: {
|
|
...omitTargetWorktrees(current.lastVisitedAtByWorktreeId),
|
|
...remote.lastVisitedAtByWorktreeId
|
|
}
|
|
}
|
|
}
|
|
|
|
async function applyRemoteWorkspaceSnapshot(
|
|
targetId: string,
|
|
snapshot: RemoteWorkspaceSnapshot
|
|
): Promise<void> {
|
|
if (!(await prepareRemoteWorkspaceTarget(targetId))) {
|
|
throw new Error('Workspace sync waited for local session hydration and timed out')
|
|
}
|
|
const worktreeIds = targetWorktreeIds(targetId)
|
|
const localByPath = new Map(
|
|
Array.from(worktreeIds).map((worktreeId) => {
|
|
const separator = worktreeId.indexOf('::')
|
|
return [separator === -1 ? worktreeId : worktreeId.slice(separator + 2), worktreeId] as const
|
|
})
|
|
)
|
|
const remoteSession = importRemoteWorkspaceSession(snapshot.session, {
|
|
resolveWorktreeId: (worktreePath) => localByPath.get(worktreePath) ?? null
|
|
})
|
|
const current = buildWorkspaceSessionPayload(useAppStore.getState())
|
|
const merged = mergeRemoteWorkspaceSession(current, remoteSession, targetId)
|
|
const store = useAppStore.getState()
|
|
remoteWorkspaceSnapshotApplyDepth += 1
|
|
try {
|
|
store.hydrateWorkspaceSession(merged)
|
|
store.hydrateTabsSession(merged)
|
|
store.hydrateEditorSession(merged)
|
|
store.hydrateBrowserSession(merged)
|
|
store.markRemoteWorkspaceHydrated(targetId)
|
|
store.setRemoteWorkspaceSyncStatus(targetId, {
|
|
phase: 'synced',
|
|
direction: 'pull',
|
|
revision: snapshot.revision,
|
|
updatedAt: snapshot.updatedAt,
|
|
lastSyncedAt: Date.now(),
|
|
message: 'Workspace synced'
|
|
})
|
|
await useAppStore.getState().reconnectPersistedTerminals()
|
|
} finally {
|
|
// Why: remote terminal reattach can update pty ids and titles just after
|
|
// hydration. Those local side effects came from the remote snapshot and
|
|
// must not echo back as a fresh workspace revision.
|
|
remoteWorkspaceSnapshotWriteSuppressUntil =
|
|
Date.now() + REMOTE_WORKSPACE_SNAPSHOT_WRITE_SUPPRESS_MS
|
|
remoteWorkspaceSnapshotApplyDepth -= 1
|
|
}
|
|
}
|
|
|
|
async function syncRemoteWorkspaceAfterConnect(targetId: string): Promise<void> {
|
|
const store = useAppStore.getState()
|
|
if (store.sshTargetRemoteSyncEnabled.get(targetId) !== true) {
|
|
return
|
|
}
|
|
if (!(await prepareRemoteWorkspaceTarget(targetId))) {
|
|
store.setRemoteWorkspaceSyncStatus(targetId, {
|
|
phase: 'error',
|
|
direction: 'pull',
|
|
message: 'Workspace sync waited for local session hydration and timed out'
|
|
})
|
|
return
|
|
}
|
|
store.setRemoteWorkspaceSyncStatus(targetId, { phase: 'pulling', direction: 'pull' })
|
|
const worktreeIds = targetWorktreeIds(targetId)
|
|
const hasLocalTabs = Array.from(worktreeIds).some(
|
|
(worktreeId) => (useAppStore.getState().tabsByWorktree[worktreeId] ?? []).length > 0
|
|
)
|
|
const snapshot = await window.api.remoteWorkspace.get({ targetId })
|
|
if (!snapshot) {
|
|
useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, {
|
|
phase: 'offline',
|
|
direction: 'pull',
|
|
message: 'Remote workspace sync unavailable'
|
|
})
|
|
return
|
|
}
|
|
if (snapshot.revision > 0) {
|
|
await applyRemoteWorkspaceSnapshot(targetId, snapshot)
|
|
return
|
|
}
|
|
|
|
useAppStore.getState().markRemoteWorkspaceHydrated(targetId)
|
|
if (hasLocalTabs) {
|
|
// Why: first connect must read the relay before publishing local tabs.
|
|
// Otherwise a reconnecting device can overwrite a newer cross-device
|
|
// workspace snapshot with stale local renderer state.
|
|
const session = buildWorkspaceSessionPayload(useAppStore.getState())
|
|
const results = await window.api.remoteWorkspace.setForConnectedTargets({
|
|
session,
|
|
hydratedTargetIds: [targetId]
|
|
})
|
|
const result = results.find((entry) => entry.targetId === targetId)?.result
|
|
applyRemoteWorkspacePatchStatus(targetId, result)
|
|
if (result?.ok) {
|
|
useAppStore.getState().markRemoteWorkspaceHydrated(targetId)
|
|
}
|
|
return
|
|
}
|
|
useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, {
|
|
phase: 'idle',
|
|
revision: snapshot.revision,
|
|
updatedAt: snapshot.updatedAt,
|
|
message: 'No remote workspace yet'
|
|
})
|
|
}
|
|
|
|
function applyRemoteWorkspacePatchStatus(
|
|
targetId: string,
|
|
result: RemoteWorkspacePatchResult | undefined
|
|
): void {
|
|
if (!result) {
|
|
useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, {
|
|
phase: 'offline',
|
|
direction: 'push',
|
|
lastSyncedAt: Date.now(),
|
|
message: 'Remote workspace sync unavailable'
|
|
})
|
|
return
|
|
}
|
|
if (result.ok) {
|
|
useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, {
|
|
phase: 'synced',
|
|
direction: 'push',
|
|
revision: result.snapshot.revision,
|
|
updatedAt: result.snapshot.updatedAt,
|
|
lastSyncedAt: Date.now(),
|
|
message: 'Workspace uploaded'
|
|
})
|
|
return
|
|
}
|
|
useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, {
|
|
phase: result.reason === 'stale-revision' ? 'conflict' : 'offline',
|
|
direction: 'push',
|
|
revision: result.snapshot?.revision,
|
|
updatedAt: result.snapshot?.updatedAt,
|
|
lastSyncedAt: Date.now(),
|
|
message:
|
|
result.message ??
|
|
(result.reason === 'stale-revision'
|
|
? 'Workspace changed on another device'
|
|
: 'Remote workspace sync unavailable')
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
function isRuntimeEnvironmentActive(): boolean {
|
|
return Boolean(useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim())
|
|
}
|
|
|
|
function getActiveRuntimeEnvironmentId(): string | null {
|
|
return useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim() || null
|
|
}
|
|
|
|
export function useIpcEvents(): void {
|
|
useEffect(() => {
|
|
const unsubs: (() => void)[] = []
|
|
|
|
unsubs.push(attachMobileMarkdownBridge())
|
|
|
|
unsubs.push(
|
|
window.api.repos.onChanged(() => {
|
|
if (isRuntimeEnvironmentActive()) {
|
|
// Why: this event comes from the local Electron store. While a
|
|
// runtime server is selected, repo hydration must be driven by the
|
|
// selected server instead of local-disk changes.
|
|
return
|
|
}
|
|
useAppStore.getState().fetchRepos()
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.worktrees.onChanged(async (data: { repoId: string }) => {
|
|
if (isRuntimeEnvironmentActive()) {
|
|
// Why: local worktree events carry local repo ids. Fetching the
|
|
// active runtime with those ids can purge or overwrite server state.
|
|
return
|
|
}
|
|
// Why: diff before vs. after fetchWorktrees to detect server-side
|
|
// deletions (CLI `orca worktree rm`, other window, out-of-band RPC)
|
|
// and purge worktree-scoped state for removed ids. Without this,
|
|
// `ptyIdsByTabId` would retain entries for tabs whose worktree is
|
|
// gone, and SessionsStatusSegment's `boundPtyIds` set would keep
|
|
// misclassifying the zombie as bound (design §2c, §4.4).
|
|
const state = useAppStore.getState()
|
|
const before = new Set((state.worktreesByRepo[data.repoId] ?? []).map((w) => w.id))
|
|
await state.fetchWorktrees(data.repoId)
|
|
await useAppStore.getState().fetchWorktreeLineage()
|
|
const afterState = useAppStore.getState()
|
|
const after = new Set((afterState.worktreesByRepo[data.repoId] ?? []).map((w) => w.id))
|
|
const removed: string[] = []
|
|
for (const id of before) {
|
|
if (!after.has(id)) {
|
|
removed.push(id)
|
|
}
|
|
}
|
|
if (removed.length > 0) {
|
|
console.warn(
|
|
`[worktree-purge] diff-based purge removing state for ${removed.length} worktree(s):`,
|
|
removed
|
|
)
|
|
afterState.purgeWorktreeTerminalState(removed)
|
|
afterState.removeWorkspaceSpaceWorktrees(removed)
|
|
}
|
|
})
|
|
)
|
|
|
|
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)
|
|
})
|
|
)
|
|
|
|
if (window.api.gh?.onPRRefreshEvent) {
|
|
unsubs.push(
|
|
window.api.gh.onPRRefreshEvent((event) => {
|
|
useAppStore.getState().applyGitHubPRRefreshEvent(event)
|
|
})
|
|
)
|
|
}
|
|
|
|
unsubs.push(
|
|
window.api.ui.onOpenSettings(() => {
|
|
useAppStore.getState().openSettingsPage()
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.ui.onOpenFeatureTour(() => {
|
|
useAppStore.getState().openModal('feature-wall', { source: 'help_menu' })
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.ui.onShowFeatureTourNudge(() => {
|
|
useAppStore.getState().showFeatureTourNudge()
|
|
})
|
|
)
|
|
|
|
// Why: the View > Appearance menu toggles settings directly in main (so
|
|
// checkbox state reflects the persisted value without a round-trip) and
|
|
// broadcasts the change. Merge it into the store so the sidebar and
|
|
// titlebar re-render immediately instead of waiting for the next
|
|
// fetchSettings() call.
|
|
unsubs.push(
|
|
window.api.settings.onChanged((updates) => {
|
|
const store = useAppStore.getState()
|
|
if (!store.settings) {
|
|
return
|
|
}
|
|
useAppStore.setState({
|
|
settings: {
|
|
...store.settings,
|
|
...updates,
|
|
notifications: {
|
|
...store.settings.notifications,
|
|
...updates.notifications
|
|
}
|
|
}
|
|
})
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.ui.onToggleLeftSidebar(() => {
|
|
useAppStore.getState().toggleSidebar()
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.ui.onToggleRightSidebar(() => {
|
|
useAppStore.getState().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))
|
|
})
|
|
)
|
|
|
|
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.onOpenNewWorkspace(() => {
|
|
// Why: mirror the renderer's App.tsx Cmd+N guard — only open the
|
|
// composer when there is at least one real git repo configured, so
|
|
// users on a fresh install don't get a modal with nothing to target.
|
|
const store = useAppStore.getState()
|
|
if (!store.repos.some((repo) => isGitRepoKind(repo))) {
|
|
return
|
|
}
|
|
if (store.activeModal === 'new-workspace-composer') {
|
|
return
|
|
}
|
|
store.openModal('new-workspace-composer', { telemetrySource: 'shortcut' })
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.ui.onJumpToWorktreeIndex((index) => {
|
|
const store = useAppStore.getState()
|
|
if (store.activeView !== 'terminal') {
|
|
return
|
|
}
|
|
const visibleIds = getVisibleWorktreeIds()
|
|
if (index < visibleIds.length) {
|
|
activateAndRevealWorktree(visibleIds[index])
|
|
}
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.ui.onWorktreeHistoryNavigate((direction) => {
|
|
const store = useAppStore.getState()
|
|
// Why: mirror the button-visibility rule — worktree history navigation
|
|
// is only meaningful in the terminal (worktree) view. Settings/Tasks
|
|
// transitions aren't worktree activations and the buttons are hidden,
|
|
// so the shortcut no-ops there too.
|
|
if (store.activeView !== 'terminal') {
|
|
return
|
|
}
|
|
if (direction === 'back') {
|
|
store.goBackWorktree()
|
|
} else {
|
|
store.goForwardWorktree()
|
|
}
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.ui.onToggleStatusBar(() => {
|
|
const store = useAppStore.getState()
|
|
store.setStatusBarVisible(!store.statusBarVisible)
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup, startup }) => {
|
|
void (async () => {
|
|
if (isRuntimeEnvironmentActive()) {
|
|
// Why: local CLI-created worktree events carry local repo/worktree
|
|
// ids. Runtime server activation arrives through runtime state,
|
|
// not this local Electron event.
|
|
return
|
|
}
|
|
// Why: fetch worktrees first so the activation helper can resolve
|
|
// the CLI-created worktree via findWorktreeById — it arrived from
|
|
// the main process and is not yet in the renderer state.
|
|
await useAppStore.getState().fetchWorktrees(repoId)
|
|
// Why: route through activateAndRevealWorktree so CLI-created
|
|
// worktrees share the canonical activation path with UI-created
|
|
// ones. This records the visit in the back/forward history stack
|
|
// (recordWorktreeVisit), without which the nav buttons would
|
|
// ignore the CLI-driven workspace switch.
|
|
activateAndRevealWorktree(worktreeId, { setup, startup })
|
|
})().catch((error) => {
|
|
console.error('Failed to activate CLI-created worktree:', error)
|
|
})
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.ui.onCreateTerminal(
|
|
({
|
|
requestId,
|
|
worktreeId,
|
|
command,
|
|
title,
|
|
ptyId,
|
|
activate,
|
|
tabId,
|
|
leafId,
|
|
splitFromLeafId,
|
|
splitDirection
|
|
}) => {
|
|
try {
|
|
if (isRuntimeEnvironmentActive()) {
|
|
if (requestId) {
|
|
window.api.ui.replyTerminalCreate({
|
|
requestId,
|
|
error: 'Local terminal reveal is unavailable while a remote runtime is active'
|
|
})
|
|
}
|
|
return
|
|
}
|
|
const store = useAppStore.getState()
|
|
const shouldActivate = activate !== false
|
|
if (shouldActivate) {
|
|
store.setActiveView('terminal')
|
|
store.setActiveWorktree(worktreeId)
|
|
// Why: CLI-driven terminal focus is a user-initiated worktree switch
|
|
// and must stamp focus recency for Cmd+J. Doesn't route through
|
|
// activateAndRevealWorktree because it has custom terminal-creation
|
|
// logic; see docs/cmd-j-empty-query-ordering.md.
|
|
store.markWorktreeVisited(worktreeId)
|
|
}
|
|
const existingTab = ptyId
|
|
? (store.tabsByWorktree[worktreeId] ?? []).find(
|
|
(candidate) =>
|
|
candidate.ptyId === ptyId ||
|
|
(store.ptyIdsByTabId[candidate.id] ?? []).includes(ptyId)
|
|
)
|
|
: undefined
|
|
const isSplitReveal = Boolean(ptyId && tabId && leafId && splitFromLeafId)
|
|
const splitTargetTab = isSplitReveal
|
|
? (store.tabsByWorktree[worktreeId] ?? []).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,
|
|
// Why: tabId hint comes from CLI-spawned PTYs whose env
|
|
// already has the pane key baked in. Adopting the tab under
|
|
// the same id keeps hook-event attribution working.
|
|
...(tabId !== undefined ? { id: tabId } : {})
|
|
})
|
|
: store.createTab(worktreeId))
|
|
// Why: when an existing tab already owns this ptyId, we reuse it instead of
|
|
// minting a new one — but the PTY env already carries a paneKey from main.
|
|
// If the existing tab id doesn't match the hint, hook attribution degrades
|
|
// for that PTY's lifetime. Warn so this is visible during development.
|
|
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)
|
|
store.revealWorktreeInSidebar(worktreeId)
|
|
}
|
|
// Why: only stamp the runtime-supplied title on freshly created tabs.
|
|
// Existing tabs may have a user customTitle (set via UI rename) that
|
|
// the runtime's stored title would otherwise silently overwrite on
|
|
// every focus.
|
|
if (title && !reusedTab) {
|
|
store.setTabCustomTitle(tab.id, title)
|
|
}
|
|
if (leafId && ptyId) {
|
|
if (splitFromLeafId) {
|
|
// Why: runtime-spawned split PTYs already carry the parent tab's
|
|
// paneKey. Reusing the existing tab preserves native split-pane
|
|
// behavior instead of letting createTab mint a collision tab.
|
|
store.updateTabPtyId(tab.id, ptyId)
|
|
store.setTabLayout(
|
|
tab.id,
|
|
addSplitLeafToLayout(
|
|
store.terminalLayoutsByTabId?.[tab.id],
|
|
splitFromLeafId,
|
|
leafId,
|
|
ptyId,
|
|
splitDirection ?? 'horizontal',
|
|
title
|
|
)
|
|
)
|
|
window.dispatchEvent(
|
|
new CustomEvent<SplitTerminalPaneDetail>(SPLIT_TERMINAL_PANE_EVENT, {
|
|
detail: {
|
|
tabId: tab.id,
|
|
paneRuntimeId: -1,
|
|
direction: splitDirection ?? 'horizontal',
|
|
sourceLeafId: splitFromLeafId,
|
|
newLeafId: leafId,
|
|
ptyId
|
|
}
|
|
})
|
|
)
|
|
} else {
|
|
// Why: CLI/runtime-spawned PTYs emit hook events before a hidden
|
|
// tab mounts TerminalPane, so the adopted UUID leaf must exist
|
|
// in layout state for paneKey validation to accept them.
|
|
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 })
|
|
}
|
|
if (requestId) {
|
|
window.api.ui.replyTerminalCreate({
|
|
requestId,
|
|
tabId: tab.id,
|
|
title: title ?? tab.title
|
|
})
|
|
}
|
|
} catch (err) {
|
|
if (!requestId) {
|
|
throw err
|
|
}
|
|
window.api.ui.replyTerminalCreate({
|
|
requestId,
|
|
error: err instanceof Error ? err.message : 'Terminal reveal failed'
|
|
})
|
|
}
|
|
}
|
|
)
|
|
)
|
|
|
|
// Why: CLI-driven terminal creation sends a request and waits for the
|
|
// tabId reply so it can resolve a handle the caller can use immediately.
|
|
// This mirrors the browser's onRequestTabCreate/replyTabCreate pattern.
|
|
unsubs.push(
|
|
window.api.ui.onRequestTerminalCreate((data) => {
|
|
try {
|
|
if (isRuntimeEnvironmentActive()) {
|
|
window.api.ui.replyTerminalCreate({
|
|
requestId: data.requestId,
|
|
error: 'Local terminal creation is unavailable while a remote runtime is active'
|
|
})
|
|
return
|
|
}
|
|
const store = useAppStore.getState()
|
|
const worktreeId = data.worktreeId ?? store.activeWorktreeId
|
|
if (!worktreeId) {
|
|
window.api.ui.replyTerminalCreate({
|
|
requestId: data.requestId,
|
|
error: 'No active worktree'
|
|
})
|
|
return
|
|
}
|
|
const shouldActivate = data.activate !== false
|
|
if (shouldActivate) {
|
|
store.setActiveView('terminal')
|
|
store.setActiveWorktree(worktreeId)
|
|
// Why: CLI-driven focused terminal-create requests are user-initiated
|
|
// worktree switches; unfocused renderer-backed creates must not reorder Cmd+J.
|
|
store.markWorktreeVisited(worktreeId)
|
|
} else {
|
|
// Why: renderer-backed Codex startup must mount a TerminalPane so the
|
|
// PTY is born in the renderer, but it must not switch the active UI.
|
|
window.dispatchEvent(
|
|
new CustomEvent(BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, {
|
|
detail: { worktreeId }
|
|
})
|
|
)
|
|
}
|
|
const tab = store.createTab(
|
|
worktreeId,
|
|
data.targetGroupId,
|
|
undefined,
|
|
shouldActivate ? undefined : { activate: false }
|
|
)
|
|
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)
|
|
}
|
|
}
|
|
if (shouldActivate) {
|
|
store.setActiveTabType('terminal')
|
|
store.setActiveTab(tab.id)
|
|
store.revealWorktreeInSidebar(worktreeId)
|
|
}
|
|
if (data.title) {
|
|
store.setTabCustomTitle(tab.id, data.title)
|
|
}
|
|
if (data.command) {
|
|
store.queueTabStartupCommand(tab.id, { command: data.command })
|
|
}
|
|
window.api.ui.replyTerminalCreate({
|
|
requestId: data.requestId,
|
|
tabId: tab.id,
|
|
title: data.title ?? tab.title
|
|
})
|
|
} catch (err) {
|
|
window.api.ui.replyTerminalCreate({
|
|
requestId: data.requestId,
|
|
error: err instanceof Error ? err.message : 'Terminal creation failed'
|
|
})
|
|
}
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.ui.onSplitTerminal(({ tabId, paneRuntimeId, direction, command }) => {
|
|
const detail: SplitTerminalPaneDetail = { tabId, paneRuntimeId, direction, command }
|
|
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()
|
|
store.setActiveWorktree(worktreeId)
|
|
// Why: CLI-driven focus is a user-initiated switch; stamp focus
|
|
// recency for Cmd+J. See docs/cmd-j-empty-query-ordering.md.
|
|
store.markWorktreeVisited(worktreeId)
|
|
store.setActiveView('terminal')
|
|
store.setActiveTab(tabId)
|
|
store.revealWorktreeInSidebar(worktreeId)
|
|
if (ackPaneKeyOnSuccess || flashFocusedPane || scrollToBottomIfOutputSinceLastView) {
|
|
activateTabAndFocusPane(tabId, leafId ?? null, {
|
|
...(ackPaneKeyOnSuccess ? { ackPaneKeyOnSuccess } : {}),
|
|
...(flashFocusedPane ? { flashFocusedPane: true } : {}),
|
|
...(scrollToBottomIfOutputSinceLastView
|
|
? { scrollToBottomIfOutputSinceLastView: true }
|
|
: {})
|
|
})
|
|
return
|
|
}
|
|
if (!focusRuntimeTerminalSurface(tabId, leafId)) {
|
|
focusTerminalTabSurface(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 can 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: mobile session tabs reuse this IPC for renderer-owned
|
|
// unified tabs. Browser tabs need their own active-page state,
|
|
// not the editor file activation path.
|
|
store.setActiveBrowserTab(browserTarget.workspaceId)
|
|
store.setActiveTabType('browser')
|
|
} else {
|
|
store.setActiveFile(tab.entityId)
|
|
store.setActiveTabType('editor')
|
|
}
|
|
store.revealWorktreeInSidebar(worktreeId)
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.ui.onCloseSessionTab(({ tabId, worktreeId }) => {
|
|
const store = useAppStore.getState()
|
|
const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, tabId)
|
|
if (browserTarget) {
|
|
store.closeBrowserTab(browserTarget.workspaceId)
|
|
return
|
|
}
|
|
store.closeUnifiedTab(tabId)
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.ui.onMoveSessionTab((move) => {
|
|
const { tabId, targetGroupId } = move
|
|
const store = useAppStore.getState()
|
|
if (move.kind === 'reorder') {
|
|
store.reorderUnifiedTabs(targetGroupId, move.tabOrder)
|
|
return
|
|
}
|
|
store.dropUnifiedTab(tabId, {
|
|
groupId: targetGroupId,
|
|
...(move.kind === 'move-to-group' ? { index: move.index } : {}),
|
|
...(move.kind === 'split' ? { splitDirection: move.splitDirection } : {})
|
|
})
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.ui.onOpenFileFromMobile(({ worktreeId, filePath, relativePath }) => {
|
|
const store = useAppStore.getState()
|
|
const basename = relativePath.split(/[\\/]/).pop() || relativePath
|
|
store.setActiveWorktree(worktreeId)
|
|
store.markWorktreeVisited(worktreeId)
|
|
store.setActiveView('terminal')
|
|
// Why: mobile only sends a desktop-backed path. The renderer owns
|
|
// editor tab creation so grouped tab order and markdown bridges update
|
|
// through the same store path as desktop File Explorer.
|
|
store.openFile({
|
|
filePath,
|
|
relativePath,
|
|
worktreeId,
|
|
language: detectLanguage(basename),
|
|
mode: 'edit'
|
|
})
|
|
store.setActiveTabType('editor')
|
|
store.revealWorktreeInSidebar(worktreeId)
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.ui.onOpenDiffFromMobile(({ worktreeId, filePath, relativePath, staged }) => {
|
|
const store = useAppStore.getState()
|
|
const language = detectLanguage(relativePath)
|
|
store.setActiveWorktree(worktreeId)
|
|
store.markWorktreeVisited(worktreeId)
|
|
store.setActiveView('terminal')
|
|
// Why: mobile renders diff tabs from diff metadata. The desktop
|
|
// markdown Changes-mode shortcut is editor-local and would publish
|
|
// plain markdown content back to mobile.
|
|
store.openDiff(worktreeId, filePath, relativePath, language, staged)
|
|
store.setActiveTabType('editor')
|
|
store.revealWorktreeInSidebar(worktreeId)
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.ui.onCloseTerminal(({ tabId, paneRuntimeId }) => {
|
|
if (paneRuntimeId != null) {
|
|
// Why: when targeting a specific pane in a split layout, dispatch to the
|
|
// lifecycle hook so PaneManager.closePane() handles sibling promotion.
|
|
// The lifecycle hook falls through to closeTab() if this is the last pane.
|
|
const detail: CloseTerminalPaneDetail = { tabId, paneRuntimeId }
|
|
window.dispatchEvent(new CustomEvent(CLOSE_TERMINAL_PANE_EVENT, { detail }))
|
|
} else {
|
|
useAppStore.getState().closeTab(tabId)
|
|
}
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.ui.onSleepWorktree(({ worktreeId }) => {
|
|
void runSleepWorktree(worktreeId)
|
|
})
|
|
)
|
|
|
|
// Hydrate initial update status then subscribe to changes
|
|
window.api.updater.getStatus().then((status) => {
|
|
useAppStore.getState().setUpdateStatus(status as UpdateStatus)
|
|
})
|
|
|
|
unsubs.push(
|
|
window.api.updater.onStatus((raw) => {
|
|
const status = raw as UpdateStatus
|
|
useAppStore.getState().setUpdateStatus(status)
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.updater.onClearDismissal(() => {
|
|
useAppStore.getState().clearDismissedUpdateVersion()
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.ui.onFullscreenChanged((isFullScreen) => {
|
|
useAppStore.getState().setIsFullScreen(isFullScreen)
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.browser.onGuestLoadFailed(({ browserPageId, loadError }) => {
|
|
if (isRuntimeEnvironmentActive()) {
|
|
return
|
|
}
|
|
useAppStore.getState().updateBrowserPageState(browserPageId, {
|
|
loading: false,
|
|
loadError,
|
|
canGoBack: false,
|
|
canGoForward: false
|
|
})
|
|
})
|
|
)
|
|
|
|
// Why: agent-browser drives navigation via CDP, bypassing Electron's webview
|
|
// event system. The renderer's did-navigate listener never fires for those
|
|
// navigations, so the Zustand store (address bar, tab title) stays stale.
|
|
// This IPC pushes the live URL/title from main after goto/click/back/reload.
|
|
unsubs.push(
|
|
window.api.browser.onNavigationUpdate(({ browserPageId, url, title }) => {
|
|
if (isRuntimeEnvironmentActive()) {
|
|
return
|
|
}
|
|
const store = useAppStore.getState()
|
|
store.setBrowserPageUrl(browserPageId, url)
|
|
store.updateBrowserPageState(browserPageId, { title, loading: false })
|
|
})
|
|
)
|
|
|
|
// Why: browser webviews only start their guest process when the container
|
|
// has display != none. After app restart, activeTabType defaults to 'terminal'
|
|
// so persisted browser tabs never mount. The main process sends this IPC
|
|
// before browser commands so the webview can start and registerGuest fires.
|
|
unsubs.push(
|
|
window.api.browser.onActivateView(() => {
|
|
if (isRuntimeEnvironmentActive()) {
|
|
return
|
|
}
|
|
useAppStore.getState().setActiveTabType('browser')
|
|
})
|
|
)
|
|
|
|
// Why: `orca tab switch --focus` lands here after the bridge's state-only
|
|
// `tabSwitch`. We deliberately DO NOT call `setActiveWorktree` — multiple
|
|
// agents drive browsers in parallel worktrees, so a global focus call from
|
|
// one agent's tab switch would steal the user's view from whichever
|
|
// worktree they're actually reading. Instead `focusBrowserTabInWorktree`
|
|
// updates the targeted worktree's per-worktree state in place; globals
|
|
// (activeBrowserTabId, activeTabType) only flip when the user is already
|
|
// on the targeted worktree. Cross-worktree --focus calls are silent
|
|
// pre-staging for whenever the user next visits that worktree.
|
|
unsubs.push(
|
|
window.api.browser.onPaneFocus(({ worktreeId, browserPageId }) => {
|
|
if (isRuntimeEnvironmentActive()) {
|
|
return
|
|
}
|
|
const store = useAppStore.getState()
|
|
// Why: main sends `worktreeId: null` if the tab closed between the
|
|
// bridge resolving tabSwitch and getWorktreeIdForTab running. Falling
|
|
// back to activeWorktreeId means a stale page id in another worktree
|
|
// is silently ignored by focusBrowserTabInWorktree (page not found
|
|
// in its tabsForWorktree.find), which is the intended no-op.
|
|
const targetWt = worktreeId ?? store.activeWorktreeId
|
|
if (!targetWt) {
|
|
return
|
|
}
|
|
store.focusBrowserTabInWorktree(targetWt, browserPageId)
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.browser.onOpenLinkInOrcaTab(({ browserPageId, url }) => {
|
|
if (isRuntimeEnvironmentActive()) {
|
|
return
|
|
}
|
|
const store = useAppStore.getState()
|
|
const sourcePage = Object.values(store.browserPagesByWorkspace)
|
|
.flat()
|
|
.find((page) => page.id === browserPageId)
|
|
if (!sourcePage) {
|
|
return
|
|
}
|
|
// Why: the guest process can request "open this link in Orca", but it
|
|
// does not own Orca's worktree/tab model. Resolve the source page's
|
|
// worktree and create a new outer browser tab so the link opens as a
|
|
// separate tab in the outer Orca tab bar.
|
|
store.createBrowserTab(sourcePage.worktreeId, url, { title: url })
|
|
})
|
|
)
|
|
|
|
// Shortcut forwarding for embedded browser guests whose webContents
|
|
// capture keyboard focus and bypass the renderer's window-level keydown.
|
|
unsubs.push(
|
|
window.api.ui.onNewBrowserTab(() => {
|
|
const store = useAppStore.getState()
|
|
const worktreeId = store.activeWorktreeId
|
|
if (worktreeId) {
|
|
if (isRuntimeEnvironmentActive()) {
|
|
const environmentId = getActiveRuntimeEnvironmentId()
|
|
if (!isWebRuntimeSessionActive(environmentId)) {
|
|
store.createBrowserTab(worktreeId, store.browserDefaultUrl ?? 'about:blank', {
|
|
title: 'New Browser Tab',
|
|
focusAddressBar: true
|
|
})
|
|
return
|
|
}
|
|
void (async () => {
|
|
// Why: paired web browser tabs are host-owned and arrive through
|
|
// session.tabs. On RPC failure we leave local state unchanged so
|
|
// the next host snapshot remains authoritative.
|
|
await createWebRuntimeSessionBrowserTab({
|
|
worktreeId,
|
|
url: store.browserDefaultUrl ?? 'about:blank'
|
|
})
|
|
})()
|
|
return
|
|
}
|
|
store.createBrowserTab(worktreeId, store.browserDefaultUrl ?? 'about:blank', {
|
|
title: 'New Browser Tab',
|
|
focusAddressBar: true
|
|
})
|
|
}
|
|
})
|
|
)
|
|
|
|
// Why: CLI-driven tab creation sends a request with a specific worktreeId and
|
|
// url. The renderer creates the tab and replies with the workspace ID so the
|
|
// main process can wait for registerGuest before returning to the CLI.
|
|
unsubs.push(
|
|
window.api.ui.onRequestTabCreate((data) => {
|
|
try {
|
|
if (isRuntimeEnvironmentActive()) {
|
|
// Why: browser automation targets client-local Electron webviews.
|
|
// Runtime agents cannot see or control those surfaces.
|
|
window.api.ui.replyTabCreate({
|
|
requestId: data.requestId,
|
|
error: '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: 'No active worktree' })
|
|
return
|
|
}
|
|
// Why: CLI-created tabs should land in the same group as the active
|
|
// browser tab, not the terminal's group (which is typically the
|
|
// UI-active group when an agent is running commands).
|
|
const activeBrowserTabId = store.activeBrowserTabIdByWorktree[worktreeId]
|
|
const activeBrowserUnifiedTab = activeBrowserTabId
|
|
? (store.unifiedTabsByWorktree[worktreeId] ?? []).find(
|
|
(t) => t.contentType === 'browser' && t.entityId === activeBrowserTabId
|
|
)
|
|
: undefined
|
|
|
|
const workspace = store.createBrowserTab(worktreeId, data.url, {
|
|
title: data.url,
|
|
targetGroupId: activeBrowserUnifiedTab?.groupId,
|
|
sessionProfileId: data.sessionProfileId
|
|
})
|
|
// Why: registerGuest fires with the page ID (not workspace ID) as
|
|
// browserPageId. Return the page ID so waitForTabRegistration can
|
|
// correlate correctly.
|
|
const pages = useAppStore.getState().browserPagesByWorkspace[workspace.id] ?? []
|
|
const browserPageId = pages[0]?.id ?? workspace.id
|
|
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: '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: `Browser tab ${data.browserPageId} not found`
|
|
})
|
|
return
|
|
}
|
|
// Why: a workspace can host multiple browser pages; profile switch must
|
|
// tear down every sibling webview, not just the one referenced by the IPC.
|
|
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)
|
|
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: 'Browser tabs are unavailable while a remote runtime is active'
|
|
})
|
|
return
|
|
}
|
|
const store = useAppStore.getState()
|
|
const explicitTargetId = data.tabId ?? null
|
|
let tabToClose =
|
|
explicitTargetId ??
|
|
(data.worktreeId
|
|
? (store.activeBrowserTabIdByWorktree?.[data.worktreeId] ?? null)
|
|
: store.activeBrowserTabId)
|
|
if (!tabToClose) {
|
|
window.api.ui.replyTabClose({
|
|
requestId: data.requestId,
|
|
error: 'No active browser tab to close'
|
|
})
|
|
return
|
|
}
|
|
// Why: the bridge stores tabs keyed by browserPageId (which is the page
|
|
// ID from registerGuest), but closeBrowserTab expects a workspace ID. If
|
|
// tabToClose is a page ID, close only that page unless it is the
|
|
// last page in its workspace. The CLI's `tab close --page` contract
|
|
// targets one browser page, not the entire workspace tab.
|
|
const isWorkspaceId = Object.values(store.browserTabsByWorktree)
|
|
.flat()
|
|
.some((ws) => ws.id === tabToClose)
|
|
if (!isWorkspaceId) {
|
|
const owningWorkspace = Object.entries(store.browserPagesByWorkspace).find(
|
|
([, pages]) => pages.some((p) => p.id === tabToClose)
|
|
)
|
|
if (owningWorkspace) {
|
|
const [workspaceId, pages] = owningWorkspace
|
|
if (pages.length <= 1) {
|
|
store.closeBrowserTab(workspaceId)
|
|
} else {
|
|
store.closeBrowserPage(tabToClose)
|
|
}
|
|
window.api.ui.replyTabClose({ requestId: data.requestId })
|
|
return
|
|
}
|
|
}
|
|
if (explicitTargetId) {
|
|
window.api.ui.replyTabClose({
|
|
requestId: data.requestId,
|
|
error: `Browser tab ${explicitTargetId} not found`
|
|
})
|
|
return
|
|
}
|
|
store.closeBrowserTab(tabToClose)
|
|
window.api.ui.replyTabClose({ requestId: data.requestId })
|
|
} catch (err) {
|
|
window.api.ui.replyTabClose({
|
|
requestId: data.requestId,
|
|
error: err instanceof Error ? err.message : 'Tab close failed'
|
|
})
|
|
}
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.ui.onNewTerminalTab(() => {
|
|
const store = useAppStore.getState()
|
|
const worktreeId = store.activeWorktreeId
|
|
if (!worktreeId) {
|
|
return
|
|
}
|
|
void (async () => {
|
|
if (
|
|
await createWebRuntimeSessionTerminal({
|
|
worktreeId,
|
|
activate: true
|
|
})
|
|
) {
|
|
return
|
|
}
|
|
const newTab = store.createTab(worktreeId)
|
|
store.setActiveTabType('terminal')
|
|
// Why: replicate the full reconciliation from Terminal.tsx handleNewTab
|
|
// so the new tab appends at the visual end instead of jumping to index 0
|
|
// when tabBarOrderByWorktree is unset (e.g. restored worktrees).
|
|
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(() => {
|
|
const store = useAppStore.getState()
|
|
if (store.activeTabType === 'browser' && store.activeBrowserTabId) {
|
|
if (isRuntimeEnvironmentActive() && store.activeWorktreeId) {
|
|
const environmentId = getActiveRuntimeEnvironmentId()
|
|
if (!isWebRuntimeSessionActive(environmentId)) {
|
|
store.closeBrowserTab(store.activeBrowserTabId)
|
|
return
|
|
}
|
|
void (async () => {
|
|
await closeWebRuntimeSessionTab({
|
|
worktreeId: store.activeWorktreeId!,
|
|
tabId: store.activeBrowserTabId!
|
|
})
|
|
})()
|
|
return
|
|
}
|
|
store.closeBrowserTab(store.activeBrowserTabId)
|
|
}
|
|
})
|
|
)
|
|
|
|
unsubs.push(window.api.ui.onSwitchTab(handleSwitchTab))
|
|
unsubs.push(window.api.ui.onSwitchTabAcrossAllTypes(handleSwitchTabAcrossAllTypes))
|
|
unsubs.push(window.api.ui.onSwitchTerminalTab(handleSwitchTerminalTab))
|
|
|
|
// Hydrate initial rate limit state then subscribe to push updates
|
|
window.api.rateLimits.get().then((state) => {
|
|
useAppStore.getState().setRateLimitsFromPush(state as RateLimitState)
|
|
})
|
|
|
|
unsubs.push(
|
|
window.api.rateLimits.onUpdate((state) => {
|
|
useAppStore.getState().setRateLimitsFromPush(state as RateLimitState)
|
|
})
|
|
)
|
|
|
|
const unsubscribeWorkspaceSpaceProgress = window.api.workspaceSpace?.onProgress?.(
|
|
(progress) => {
|
|
useAppStore.getState().applyWorkspaceSpaceProgress(progress)
|
|
}
|
|
)
|
|
if (unsubscribeWorkspaceSpaceProgress) {
|
|
unsubs.push(unsubscribeWorkspaceSpaceProgress)
|
|
}
|
|
|
|
// Track SSH connection state changes so the renderer can show
|
|
// disconnected indicators on remote worktrees.
|
|
// Why: hydrate initial state for all known targets so worktree cards
|
|
// reflect the correct connected/disconnected state on app launch.
|
|
void (async () => {
|
|
try {
|
|
const targets = await window.api.ssh.listTargets()
|
|
useAppStore.getState().setSshTargetsMetadata(targets)
|
|
for (const target of targets) {
|
|
const state = await window.api.ssh.getState({ targetId: target.id })
|
|
if (state) {
|
|
useAppStore.getState().setSshConnectionState(target.id, state as SshConnectionState)
|
|
// Why: if the renderer reattaches while an SSH session is alive
|
|
// (e.g. window re-creation or reload), forwarded and detected ports
|
|
// are only populated via push events. Fetch current snapshots so the
|
|
// Ports panel doesn't show empty for an active session.
|
|
if ((state as SshConnectionState).status === 'connected') {
|
|
const [forwards, detected] = await Promise.all([
|
|
window.api.ssh.listPortForwards({ targetId: target.id }),
|
|
window.api.ssh.listDetectedPorts({ targetId: target.id })
|
|
])
|
|
// Why: if the session disconnected while we were awaiting the
|
|
// snapshot, the disconnect handler already cleared port state.
|
|
// Applying stale data here would resurrect a dead session's ports.
|
|
const currentState = useAppStore.getState().sshConnectionStates.get(target.id)
|
|
if (currentState?.status === 'connected') {
|
|
useAppStore.getState().setPortForwards(target.id, forwards)
|
|
useAppStore.getState().setDetectedPorts(target.id, detected)
|
|
}
|
|
if (target.remoteWorkspaceSyncEnabled) {
|
|
void syncRemoteWorkspaceAfterConnect(target.id).catch((err) => {
|
|
useAppStore.getState().setRemoteWorkspaceSyncStatus(target.id, {
|
|
phase: 'error',
|
|
message: err instanceof Error ? err.message : 'Workspace sync failed'
|
|
})
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// SSH may not be configured
|
|
}
|
|
})()
|
|
|
|
unsubs.push(
|
|
window.api.ssh.onCredentialRequest((data) => {
|
|
useAppStore.getState().enqueueSshCredentialRequest(data)
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.ssh.onCredentialResolved(({ requestId }) => {
|
|
useAppStore.getState().removeSshCredentialRequest(requestId)
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.ssh.onPortForwardsChanged(({ targetId, forwards }) => {
|
|
useAppStore.getState().setPortForwards(targetId, forwards)
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.ssh.onDetectedPortsChanged(({ targetId, ports }) => {
|
|
useAppStore.getState().setDetectedPorts(targetId, ports)
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.ssh.onStateChanged((data: { targetId: string; state: unknown }) => {
|
|
const store = useAppStore.getState()
|
|
const state = data.state as SshConnectionState
|
|
store.setSshConnectionState(data.targetId, state)
|
|
const remoteRepos = store.repos.filter((r) => r.connectionId === data.targetId)
|
|
|
|
// Why: targets added after boot aren't in the labels map. Re-fetch
|
|
// so the status bar popover shows the new target immediately.
|
|
if (!store.sshTargetLabels.has(data.targetId)) {
|
|
window.api.ssh
|
|
.listTargets()
|
|
.then((targets) => {
|
|
useAppStore.getState().setSshTargetsMetadata(targets)
|
|
})
|
|
.catch(() => {})
|
|
}
|
|
|
|
if (
|
|
['disconnected', 'auth-failed', 'reconnection-failed', 'error'].includes(state.status)
|
|
) {
|
|
// Why: the remote agent list is tied to a live SSH connection. On
|
|
// disconnect the relay is gone, so clear the cached list and dedup
|
|
// promise. When the user reconnects and opens the quick-launch menu,
|
|
// ensureRemoteDetectedAgents will re-detect against the new relay.
|
|
store.clearRemoteDetectedAgents(data.targetId)
|
|
|
|
// Why: defensive — clear port forward and detected port state in case
|
|
// the broadcast from removeAllForwards races with the state change.
|
|
store.clearPortForwards(data.targetId)
|
|
store.setDetectedPorts(data.targetId, [])
|
|
|
|
// Why: an explicit disconnect or terminal failure tears down the SSH
|
|
// PTY provider without emitting per-PTY exit events. Clear the stale
|
|
// PTY ids in renderer state so a later reconnect remounts TerminalPane
|
|
// instead of keeping a dead remote PTY attached to the tab.
|
|
const remoteWorktreeIds = new Set(
|
|
Object.values(store.worktreesByRepo)
|
|
.flat()
|
|
.filter((w) => remoteRepos.some((r) => r.id === w.repoId))
|
|
.map((w) => w.id)
|
|
)
|
|
for (const worktreeId of remoteWorktreeIds) {
|
|
const tabs = useAppStore.getState().tabsByWorktree[worktreeId] ?? []
|
|
for (const tab of tabs) {
|
|
if (tab.ptyId) {
|
|
useAppStore.getState().clearTabPtyId(tab.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (state.status === 'connected') {
|
|
void Promise.all(remoteRepos.map((r) => store.fetchWorktrees(r.id))).then(async () => {
|
|
await useAppStore.getState().fetchWorktreeLineage()
|
|
// Why: terminal panes that failed to spawn (no PTY provider on cold
|
|
// start) sit inert. Bumping generation forces TerminalPane to remount
|
|
// and retry pty:spawn. Only bump tabs with no live ptyId.
|
|
const freshStore = useAppStore.getState()
|
|
const remoteRepoIds = new Set(remoteRepos.map((r) => r.id))
|
|
const worktreeIds = Object.values(freshStore.worktreesByRepo)
|
|
.flat()
|
|
.filter((w) => remoteRepoIds.has(w.repoId))
|
|
.map((w) => w.id)
|
|
|
|
for (const worktreeId of worktreeIds) {
|
|
const tabs = freshStore.tabsByWorktree[worktreeId] ?? []
|
|
const hasDead = tabs.some((t) => !t.ptyId)
|
|
if (hasDead) {
|
|
useAppStore.setState((s) => ({
|
|
tabsByWorktree: {
|
|
...s.tabsByWorktree,
|
|
[worktreeId]: (s.tabsByWorktree[worktreeId] ?? []).map((t) =>
|
|
t.ptyId ? t : { ...t, generation: (t.generation ?? 0) + 1 }
|
|
)
|
|
}
|
|
}))
|
|
}
|
|
}
|
|
void syncRemoteWorkspaceAfterConnect(data.targetId).catch((err) => {
|
|
useAppStore.getState().setRemoteWorkspaceSyncStatus(data.targetId, {
|
|
phase: 'error',
|
|
message: err instanceof Error ? err.message : 'Workspace sync failed'
|
|
})
|
|
})
|
|
})
|
|
}
|
|
})
|
|
)
|
|
|
|
let remoteWorkspaceClientId: string | null = null
|
|
let remoteWorkspaceClientIdPromise: Promise<string | null> | null = null
|
|
const getRemoteWorkspaceClientId = (): Promise<string | null> => {
|
|
const remoteWorkspace = window.api.remoteWorkspace
|
|
if (!remoteWorkspace) {
|
|
return Promise.resolve(null)
|
|
}
|
|
if (remoteWorkspaceClientId) {
|
|
return Promise.resolve(remoteWorkspaceClientId)
|
|
}
|
|
remoteWorkspaceClientIdPromise ??= remoteWorkspace
|
|
.clientId()
|
|
.then((id) => {
|
|
remoteWorkspaceClientId = id
|
|
return id
|
|
})
|
|
.catch(() => null)
|
|
return remoteWorkspaceClientIdPromise
|
|
}
|
|
if (window.api.remoteWorkspace) {
|
|
void getRemoteWorkspaceClientId()
|
|
unsubs.push(
|
|
window.api.remoteWorkspace.onChanged((event) => {
|
|
void (async () => {
|
|
// Why: relay notifications can race the initial client-id IPC.
|
|
// Self-originated writes must never bounce back into restore.
|
|
const clientId = await getRemoteWorkspaceClientId()
|
|
if (event.sourceClientId && clientId && event.sourceClientId === clientId) {
|
|
return
|
|
}
|
|
await applyRemoteWorkspaceSnapshot(event.targetId, event.snapshot).catch((err) => {
|
|
useAppStore.getState().setRemoteWorkspaceSyncStatus(event.targetId, {
|
|
phase: 'error',
|
|
revision: event.snapshot.revision,
|
|
message: err instanceof Error ? err.message : 'Failed to apply remote workspace'
|
|
})
|
|
})
|
|
})()
|
|
})
|
|
)
|
|
}
|
|
|
|
// Zoom handling for menu accelerators and keyboard fallback paths.
|
|
unsubs.push(
|
|
window.api.ui.onTerminalZoom((direction) => {
|
|
const { activeView, activeTabType, editorFontZoomLevel, setEditorFontZoomLevel, settings } =
|
|
useAppStore.getState()
|
|
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: use the same base font size the editor surfaces use (terminalFontSize)
|
|
// and computeEditorFontSize to account for clamping, so the overlay percent
|
|
// matches the actual 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 rawNext =
|
|
direction === 'in' ? current + ZOOM_STEP : direction === 'out' ? current - ZOOM_STEP : 0
|
|
const next = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, rawNext))
|
|
|
|
applyUIZoom(next)
|
|
void window.api.ui.set({ uiZoomLevel: next })
|
|
|
|
dispatchZoomLevelChanged('ui', zoomLevelToPercent(next))
|
|
})
|
|
)
|
|
|
|
// Why: agent status arrives from native hook receivers in the main process.
|
|
// Re-parse it here so the renderer enforces the same normalization rules
|
|
// (state enum, field truncation) regardless of whether the source was a
|
|
// hook callback or an OSC fallback path. Startup pushes are ignored until
|
|
// workspace session hydration finishes; the snapshot pull below replays the
|
|
// main-process cache after tab identity is available.
|
|
const applyAgentStatus = (
|
|
data: AgentStatusIpcPayload,
|
|
options?: { replay?: boolean }
|
|
): void => {
|
|
const store = useAppStore.getState()
|
|
if (!store.workspaceSessionReady) {
|
|
return
|
|
}
|
|
const payload = normalizeAgentStatusPayload({
|
|
state: data.state,
|
|
prompt: data.prompt,
|
|
agentType: data.agentType,
|
|
toolName: data.toolName,
|
|
toolInput: data.toolInput,
|
|
lastAssistantMessage: data.lastAssistantMessage,
|
|
interrupted: data.interrupted
|
|
})
|
|
if (!payload) {
|
|
return
|
|
}
|
|
const { exists, title, repoConnectionId, repoConnectionResolved, owningWorktreeId } =
|
|
resolvePaneKey(store, data.paneKey)
|
|
if (!exists) {
|
|
// Why: empty paneKeys are dropped in main before IPC fanout. Reaching
|
|
// this branch means a non-empty paneKey escaped without a matching
|
|
// renderer tab, so track the adoption/routing failure separately.
|
|
// Skipped during snapshot replay because main's durable cache may
|
|
// include entries whose tabs were closed before this session — that
|
|
// reconciliation miss is not a regression signal.
|
|
if (options?.replay !== true) {
|
|
track('agent_hook_unattributed', { reason: 'unknown_tab_id' })
|
|
}
|
|
return
|
|
}
|
|
// Why: drop in-flight events from a connection that no longer owns
|
|
// this pane. After an SSH disconnect (or tab destroy/recreate during
|
|
// reconnect), notifications may still arrive stamped with the
|
|
// connectionId of the dead connection. The renderer compares the
|
|
// stamped connectionId against the live repo's connectionId for the
|
|
// pane's worktree — see docs/design/agent-status-over-ssh.md §5.
|
|
// The IPC contract declares connectionId as required (string | null),
|
|
// so the undefined branch only fires under dev hot-reload skew where
|
|
// the renderer bundle is newer than the preload bundle.
|
|
// Why: startup snapshot replay can beat repo/worktree hydration for SSH
|
|
// panes. If the pane is already present and the event's worktreeId
|
|
// matches that tab's worktree, accept the status until repo ownership
|
|
// becomes available; once ownership is resolved, keep the strict
|
|
// connectionId check below.
|
|
const canAcceptPendingRemoteOwnership =
|
|
data.connectionId !== undefined &&
|
|
data.connectionId !== null &&
|
|
!repoConnectionResolved &&
|
|
data.worktreeId !== undefined &&
|
|
data.worktreeId === owningWorktreeId
|
|
if (
|
|
data.connectionId !== undefined &&
|
|
data.connectionId !== repoConnectionId &&
|
|
!canAcceptPendingRemoteOwnership
|
|
) {
|
|
return
|
|
}
|
|
store.setAgentStatus(data.paneKey, payload, title, {
|
|
updatedAt: data.receivedAt,
|
|
stateStartedAt: data.stateStartedAt
|
|
})
|
|
const statusWorktreeId = data.worktreeId ?? owningWorktreeId
|
|
if (options?.replay !== true && statusWorktreeId) {
|
|
// Why: local Codex/Claude hooks arrive through this main-process IPC
|
|
// path, not the PTY OSC fallback, so task-complete notifications must
|
|
// observe accepted hook state here as well.
|
|
observeAgentHookCompletionForNotification({
|
|
paneKey: data.paneKey,
|
|
worktreeId: statusWorktreeId,
|
|
payload
|
|
})
|
|
}
|
|
}
|
|
|
|
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 (requestId !== snapshotRequestId) {
|
|
return
|
|
}
|
|
const current = useAppStore.getState()
|
|
if (!current.workspaceSessionReady) {
|
|
return
|
|
}
|
|
for (const entry of entries) {
|
|
applyAgentStatus(entry, { replay: true })
|
|
}
|
|
const getMigrationUnsupportedSnapshot =
|
|
window.api.agentStatus.getMigrationUnsupportedSnapshot
|
|
if (typeof getMigrationUnsupportedSnapshot !== 'function') {
|
|
return
|
|
}
|
|
void getMigrationUnsupportedSnapshot().then((unsupportedEntries) => {
|
|
const unsupportedStore = useAppStore.getState()
|
|
if (!unsupportedStore.workspaceSessionReady) {
|
|
return
|
|
}
|
|
for (const entry of unsupportedEntries) {
|
|
if (entry.paneKey && resolvePaneKey(unsupportedStore, entry.paneKey).exists) {
|
|
unsupportedStore.setMigrationUnsupportedPty(entry)
|
|
}
|
|
}
|
|
})
|
|
})
|
|
.catch((err) => {
|
|
// Why: keep snapshotRequestedForReadyWindow latched on failure. The
|
|
// store subscriber below fires on every update (including high-rate
|
|
// PTY ticks), so resetting the flag here would turn a persistent IPC
|
|
// failure into an unbounded retry storm. One warning per ready
|
|
// window is sufficient; the flag still clears when
|
|
// workspaceSessionReady toggles off, so a fresh workspace re-ready
|
|
// cycle will retry.
|
|
console.warn('[agent-status] failed to load startup snapshot:', err)
|
|
})
|
|
}
|
|
|
|
unsubs.push(
|
|
window.api.agentStatus.onSet((data) => {
|
|
applyAgentStatus(data)
|
|
})
|
|
)
|
|
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)
|
|
}
|
|
|
|
// Why: the main hook server is the durable source of truth. Pull a
|
|
// snapshot only after workspace tabs are ready, so early startup pushes
|
|
// can be safely ignored instead of buffered against partially hydrated
|
|
// renderer state.
|
|
requestAgentStatusSnapshotIfReady()
|
|
unsubs.push(
|
|
useAppStore.subscribe(() => {
|
|
requestAgentStatusSnapshotIfReady()
|
|
syncAgentHookCompletionNotificationSettings()
|
|
})
|
|
)
|
|
|
|
let mobileStateHydrated = isRuntimeEnvironmentActive()
|
|
type PendingMobileStateEvent =
|
|
| {
|
|
kind: 'fit'
|
|
event: {
|
|
ptyId: string
|
|
mode: 'mobile-fit' | 'desktop-fit'
|
|
cols: number
|
|
rows: number
|
|
}
|
|
}
|
|
| {
|
|
kind: 'driver'
|
|
event: {
|
|
ptyId: string
|
|
driver: RuntimeTerminalDriverState
|
|
}
|
|
}
|
|
| {
|
|
kind: 'browser-driver'
|
|
event: {
|
|
browserPageId: string
|
|
driver: RuntimeBrowserDriverState
|
|
}
|
|
}
|
|
const pendingMobileStateEvents: PendingMobileStateEvent[] = []
|
|
|
|
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
|
|
}
|
|
|
|
unsubs.push(
|
|
window.api.runtime.onTerminalFitOverrideChanged((event) => {
|
|
if (isRuntimeEnvironmentActive()) {
|
|
return
|
|
}
|
|
if (!mobileStateHydrated) {
|
|
pendingMobileStateEvents.push({ kind: 'fit', event })
|
|
return
|
|
}
|
|
setFitOverride(event.ptyId, event.mode, event.cols, event.rows)
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
// Why: presence-lock driver state mirror. Updates the renderer's
|
|
// mobile-driver-state map so TerminalPane / pty-connection guards
|
|
// know which PTYs are currently driven by mobile. See
|
|
// docs/mobile-presence-lock.md.
|
|
window.api.runtime.onTerminalDriverChanged((event) => {
|
|
if (isRuntimeEnvironmentActive()) {
|
|
return
|
|
}
|
|
if (!mobileStateHydrated) {
|
|
pendingMobileStateEvents.push({ kind: 'driver', event })
|
|
return
|
|
}
|
|
setDriverForPty(event.ptyId, event.driver)
|
|
})
|
|
)
|
|
|
|
unsubs.push(
|
|
window.api.runtime.onBrowserDriverChanged((event) => {
|
|
if (isRuntimeEnvironmentActive()) {
|
|
return
|
|
}
|
|
if (!mobileStateHydrated) {
|
|
pendingMobileStateEvents.push({ kind: 'browser-driver', event })
|
|
return
|
|
}
|
|
setDriverForBrowserPage(event.browserPageId, event.driver)
|
|
})
|
|
)
|
|
|
|
// Why: hydrate mobile-owned terminal state on renderer reload. Subscribe
|
|
// first and buffer live events during the snapshot round trip; otherwise an
|
|
// older snapshot could overwrite a newer live lock and hide the overlay.
|
|
if (!isRuntimeEnvironmentActive()) {
|
|
void Promise.all([
|
|
window.api.runtime.getTerminalFitOverrides(),
|
|
window.api.runtime.getTerminalDrivers(),
|
|
window.api.runtime.getBrowserDrivers()
|
|
])
|
|
.then(([overrides, drivers, browserDrivers]) => {
|
|
hydrateOverrides(overrides)
|
|
hydrateDrivers(drivers)
|
|
hydrateBrowserDrivers(browserDrivers)
|
|
mobileStateHydrated = true
|
|
applyPendingMobileStateEvents()
|
|
})
|
|
.catch((error: unknown) => {
|
|
console.error('Failed to hydrate mobile terminal state:', error)
|
|
mobileStateHydrated = true
|
|
applyPendingMobileStateEvents()
|
|
})
|
|
}
|
|
|
|
return () => {
|
|
unsubs.forEach((fn) => fn())
|
|
resetAgentHookCompletionNotificationCoordinators()
|
|
}
|
|
}, [])
|
|
}
|
|
|
|
/** Resolve a paneKey (tabId:leafId) to both a liveness check and the current
|
|
* title, the pane's worktree, and the connectionId of the repo that owns it.
|
|
* Walks tabsByWorktree to locate the tab, then resolves the owning worktree
|
|
* and repo via cached selector maps. Used for agent type inference when the
|
|
* CLI payload omits agentType, plus to drop status updates targeted at panes
|
|
* whose tabs have already been torn down or whose owning connection is no
|
|
* longer live (see docs/design/agent-status-over-ssh.md §5).
|
|
* Why combined: callers need all routing pieces per hook event, and hook
|
|
* events can fire many times per second during a tool-use run. Bundling
|
|
* liveness + title + connectionId into one helper keeps the per-event work
|
|
* in one place and avoids re-deriving the owning repo at the call site. */
|
|
function resolvePaneKey(
|
|
store: ReturnType<typeof useAppStore.getState>,
|
|
paneKey: string
|
|
): {
|
|
exists: boolean
|
|
title: string | undefined
|
|
repoConnectionId: string | null
|
|
repoConnectionResolved: boolean
|
|
owningWorktreeId: string | undefined
|
|
} {
|
|
const parsed = parsePaneKey(paneKey)
|
|
if (!parsed) {
|
|
return {
|
|
exists: false,
|
|
title: undefined,
|
|
repoConnectionId: null,
|
|
repoConnectionResolved: false,
|
|
owningWorktreeId: undefined
|
|
}
|
|
}
|
|
const { tabId, leafId } = parsed
|
|
const layout = store.terminalLayoutsByTabId?.[tabId]
|
|
const leafExists = collectLeafIdsInOrder(layout?.root).includes(leafId)
|
|
if (!leafExists) {
|
|
return {
|
|
exists: false,
|
|
title: undefined,
|
|
repoConnectionId: null,
|
|
repoConnectionResolved: false,
|
|
owningWorktreeId: undefined
|
|
}
|
|
}
|
|
// Why: replay can remint numeric pane ids, so status title recovery must use
|
|
// persisted leaf-keyed titles when crossing from hook state into tab state.
|
|
const rawPaneTitle = layout?.titlesByLeafId?.[leafId]
|
|
// Why: treat an empty-string paneTitle as "no title" so the tab-level
|
|
// fallback still fires. `paneTitle ?? tabTitle` alone would short-circuit on
|
|
// '' and also erase any previously-cached terminalTitle in the store
|
|
// (`terminalTitle ?? existing?.terminalTitle` resolves to '').
|
|
const paneTitle = rawPaneTitle && rawPaneTitle.length > 0 ? rawPaneTitle : undefined
|
|
let exists = false
|
|
let tabTitle: 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
|
|
break
|
|
}
|
|
}
|
|
if (exists) {
|
|
break
|
|
}
|
|
}
|
|
// Why: ownership lookup is `tab → worktree → repo → repo.connectionId`.
|
|
// Keep "resolved to a local repo" distinct from "not hydrated yet" so the
|
|
// caller can preserve strict filtering after hydration while accepting SSH
|
|
// snapshots that arrive 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
|
|
}
|
|
}
|
|
return {
|
|
exists,
|
|
title: paneTitle ?? tabTitle,
|
|
repoConnectionId,
|
|
repoConnectionResolved,
|
|
owningWorktreeId
|
|
}
|
|
}
|