Files
orca/src/renderer/src/hooks/useIpcEvents.ts
T
Jinwoo HongandOrca 66d596fe11 fix: zombie worktree PTY cleanup (issue #218) (#1376)
* fix(runtime): kill all PTYs for a worktree on removal (design §4.3)

Worktree deletion only shut down renderer-tracked terminals, so PTYs owned
by background tabs, split panes, or pre-reload sessions survived the
removal and kept leaking memory. Introduce killAllProcessesForWorktree
with three sweeps (runtime leaves, provider-prefix scan of daemon session
ids, pty-registry by worktreeId) and wire it into both teardown paths:
the CLI-initiated removeManagedWorktree and the renderer-initiated
worktrees:remove IPC handler. OrcaRuntimeService gets a lazy
getLocalProvider thunk so construction order stays robust.

Co-authored-by: Orca <help@stably.ai>

* fix(renderer): purge worktree-scoped state on removal + hydration (design §4.4)

When a worktree is deleted, ~25 worktree-scoped maps (tabsByWorktree,
git caches, browser state, split-tab models, per-file editor drafts,
etc.) kept references to the gone worktree, so SessionsStatusSegment
kept mis-classifying orphaned PTYs as bound and dropdowns rendered stale
ids. Add purgeWorktreeTerminalState as a single atomic action that
wipes every scoped map plus cascades top-level actives. Fire it from
the worktrees:changed listener on the set-diff of removed ids, and once
more at hydration via fetchAllWorktrees to clean up persisted entries
from pre-fix sessions. The hydration-time purge is gated behind a
per-repo success check: a single transient IPC error or an all-empty
fetch defers the purge so a degraded launch cannot wipe legitimate
persisted state.

Co-authored-by: Orca <help@stably.ai>

* test(zombie-worktree): regression coverage for design §4.5

Adds tests for every layer of the zombie-worktree fix:
- worktree-teardown: unit coverage of the three-sweep helper including
  best-effort error swallowing across provider/registry.
- orca-runtime: RPC-initiated removeManagedWorktree kills PTYs before
  any git mutation + verifies the lazy getLocalProvider thunk resolves
  on each call.
- worktrees IPC: renderer-initiated remove kills PTYs before git and
  skips the kill helper for SSH-backed repos.
- renderer slice: fetchAllWorktrees defers the purge when any sibling
  repo fetch fails or every repo returns empty (F1 regression);
  happy-path fires the purge once and does not re-run on subsequent
  calls. Direct purgeWorktreeTerminalState coverage pins the cascade
  across worktree-keyed, tab-id-keyed, and file-id-keyed maps.

Co-authored-by: Orca <help@stably.ai>

* fix(runtime): log worktree-teardown kill counts (design §4.4 observability)

Breadcrumb lets ops distinguish a renderer-state-induced leak (diff-path
purge non-empty) from a backend-induced one (nothing to kill but memory
still pinned). Emit only when the sweep actually shut anything down so
steady-state logs stay quiet. Added at both call sites —
removeManagedWorktree (CLI path) and the worktrees:remove IPC handler.

Co-authored-by: Orca <help@stably.ai>

* test(zombie-worktree): fix ptyIdsByTabId seed shape to match production type

The purge unit test seeded ptyIdsByTabId as Record<string, string> when
the runtime type is Record<string, string[]>. The unit tests passed
because they never hit the UI renderer, but live e2e surfaced a
TypeError: (ptyIdsByTabId[tabId] ?? []).some is not a function.

Corrected to arrays; 21/21 tests still pass.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-05-03 15:54:45 -07:00

845 lines
34 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 { applyUIZoom } from '@/lib/ui-zoom'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { 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 { UpdateStatus } from '../../../shared/types'
import type { RateLimitState } from '../../../shared/rate-limit-types'
import type { SshConnectionState } from '../../../shared/ssh-types'
import { zoomLevelToPercent, ZOOM_MIN, ZOOM_MAX } from '@/components/settings/SettingsConstants'
import { dispatchZoomLevelChanged } from '@/lib/zoom-events'
import { resolveZoomTarget } from './resolve-zoom-target'
import { handleSwitchTab, handleSwitchTerminalTab } from './ipc-tab-switch'
import { dispatchClearModifierHints } from './useModifierHint'
import { normalizeAgentStatusPayload } from '../../../shared/agent-status-types'
import { isGitRepoKind } from '../../../shared/repo-kind'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
export { resolveZoomTarget } from './resolve-zoom-target'
const ZOOM_STEP = 0.5
export function useIpcEvents(): void {
useEffect(() => {
const unsubs: (() => void)[] = []
unsubs.push(
window.api.repos.onChanged(() => {
useAppStore.getState().fetchRepos()
})
)
unsubs.push(
window.api.worktrees.onChanged(async (data: { repoId: string }) => {
// 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)
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)
}
})
)
unsubs.push(
window.api.ui.onOpenSettings(() => {
useAppStore.getState().openSettingsPage()
})
)
// 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(() => {
dispatchClearModifierHints()
useAppStore.getState().toggleSidebar()
})
)
unsubs.push(
window.api.ui.onToggleRightSidebar(() => {
dispatchClearModifierHints()
useAppStore.getState().toggleRightSidebar()
})
)
unsubs.push(
window.api.ui.onToggleWorktreePalette(() => {
dispatchClearModifierHints()
const store = useAppStore.getState()
if (store.activeModal === 'worktree-palette') {
store.closeModal()
return
}
store.openModal('worktree-palette')
})
)
unsubs.push(
window.api.ui.onOpenQuickOpen(() => {
dispatchClearModifierHints()
const store = useAppStore.getState()
if (store.activeView === 'terminal' && store.activeWorktreeId !== null) {
store.openModal('quick-open')
}
})
)
unsubs.push(
window.api.ui.onOpenNewWorkspace((tab) => {
// 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
}
dispatchClearModifierHints()
// Why: if the composer is already open, switch tabs in place so
// repeated Cmd+N / Cmd+Shift+N presses toggle between Quick and
// Create-from without remounting and losing in-flight composer state
// (repo pick, note drafts). Opening when closed seeds the initial tab.
if (store.activeModal === 'new-workspace-composer') {
store.setNewWorkspaceComposerTab(tab)
return
}
store.openModal('new-workspace-composer', { initialTab: tab })
})
)
unsubs.push(
window.api.ui.onJumpToWorktreeIndex((index) => {
dispatchClearModifierHints()
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) => {
dispatchClearModifierHints()
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 }) => {
void (async () => {
// 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 })
})().catch((error) => {
console.error('Failed to activate CLI-created worktree:', error)
})
})
)
unsubs.push(
window.api.ui.onCreateTerminal(({ worktreeId, command, title }) => {
const store = useAppStore.getState()
store.setActiveView('terminal')
store.setActiveWorktree(worktreeId)
const tab = store.createTab(worktreeId)
store.setActiveTabType('terminal')
store.setActiveTab(tab.id)
store.revealWorktreeInSidebar(worktreeId)
if (title) {
store.setTabCustomTitle(tab.id, title)
}
if (command) {
store.queueTabStartupCommand(tab.id, { command })
}
})
)
// 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 {
const store = useAppStore.getState()
const worktreeId = data.worktreeId ?? store.activeWorktreeId
if (!worktreeId) {
window.api.ui.replyTerminalCreate({
requestId: data.requestId,
error: 'No active worktree'
})
return
}
store.setActiveView('terminal')
store.setActiveWorktree(worktreeId)
const tab = store.createTab(worktreeId)
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 }) => {
const store = useAppStore.getState()
store.setActiveWorktree(worktreeId)
store.setActiveView('terminal')
store.setActiveTab(tabId)
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)
}
})
)
// 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 }) => {
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 }) => {
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(() => {
useAppStore.getState().setActiveTabType('browser')
})
)
unsubs.push(
window.api.browser.onOpenLinkInOrcaTab(({ browserPageId, url }) => {
const store = useAppStore.getState()
const sourcePage = Object.values(store.browserPagesByWorkspace)
.flat()
.find((page) => page.id === browserPageId)
if (!sourcePage) {
return
}
// 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) {
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 {
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
})
// 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.onRequestTabClose((data) => {
try {
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
}
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 currentTerminals = store.tabsByWorktree[worktreeId] ?? []
const currentEditors = store.openFiles.filter((f) => f.worktreeId === worktreeId)
const currentBrowsers = store.browserTabsByWorktree[worktreeId] ?? []
const stored = store.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)
store.setTabBarOrder(worktreeId, order)
focusTerminalTabSurface(newTab.id)
})
)
unsubs.push(
window.api.ui.onCloseActiveTab(() => {
const store = useAppStore.getState()
if (store.activeTabType === 'browser' && store.activeBrowserTabId) {
store.closeBrowserTab(store.activeBrowserTabId)
}
})
)
unsubs.push(window.api.ui.onSwitchTab(handleSwitchTab))
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)
})
)
// 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()) as {
id: string
label: string
}[]
// Why: populate target labels map so WorktreeCard (and other components)
// can look up display labels without issuing per-card IPC calls.
const labels = new Map<string, string>()
for (const target of targets) {
labels.set(target.id, target.label)
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)
}
}
}
}
useAppStore.getState().setSshTargetLabels(labels)
} 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) => {
const labels = new Map<string, string>()
for (const t of targets as { id: string; label: string }[]) {
labels.set(t.id, t.label)
}
useAppStore.getState().setSshTargetLabels(labels)
})
.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') {
// Why: the file explorer may have tried (and failed) to load the tree
// before the SSH connection was established. Bumping the generation
// lets it detect that providers are now available and retry.
store.bumpSshConnectedGeneration()
void Promise.all(remoteRepos.map((r) => store.fetchWorktrees(r.id))).then(() => {
// 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 }
)
}
}))
}
}
})
}
})
)
// 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. The subscription itself is
// unconditional so flipping the experimental dashboard setting takes
// effect without re-running this App-mount effect; the per-event guard
// inside the handler drops payloads when the setting is off.
unsubs.push(
window.api.agentStatus.onSet((data) => {
const store = useAppStore.getState()
if (store.settings?.experimentalAgentDashboard !== true) {
return
}
// Why: the IPC payload is already a structured object — pass it
// straight to the object-input normalizer instead of round-tripping
// through JSON.stringify/JSON.parse. Hook events can fire many times
// per second during a tool-use run, so this avoids the per-event
// serialization cost.
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
}
// Why: resolve tab existence and terminal title in a single pass.
// Previously the code walked store.tabsByWorktree twice — once to
// look up the tab-level title (when the pane-level title was
// missing) and again for the explicit tabExists check. A paneKey
// that no longer resolves to a live tab belongs to a pane that has
// already been torn down; dropping here prevents orphan entries
// from accumulating in agentStatusByPaneKey.
const { exists, title } = resolvePaneKey(store, data.paneKey)
if (!exists) {
return
}
store.setAgentStatus(data.paneKey, payload, title)
})
)
return () => unsubs.forEach((fn) => fn())
}, [])
}
/** Resolve a paneKey (tabId:paneId) to both a liveness check and the current
* terminal title, in a single walk of tabsByWorktree. 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.
* Why combined: callers need both pieces per hook event, and hook events can
* fire many times per second during a tool-use run. Two separate O(N) scans
* over the same map is wasteful; one pass returns both. */
function resolvePaneKey(
store: ReturnType<typeof useAppStore.getState>,
paneKey: string
): { exists: boolean; title: string | undefined } {
const [tabId, paneIdRaw] = paneKey.split(':')
if (!tabId) {
return { exists: false, title: undefined }
}
// Why: split panes track per-pane titles in runtimePaneTitlesByTabId; prefer
// the pane's own title over the tab-level (last-winning) title so agent type
// inference attributes status to the correct pane.
const paneTitles = store.runtimePaneTitlesByTabId?.[tabId]
const paneIdNum = paneIdRaw !== undefined ? Number(paneIdRaw) : NaN
const rawPaneTitle = paneTitles && !Number.isNaN(paneIdNum) ? paneTitles[paneIdNum] : undefined
// 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
for (const tabs of Object.values(store.tabsByWorktree)) {
for (const tab of tabs) {
if (tab.id === tabId) {
exists = true
tabTitle = tab.title
break
}
}
if (exists) {
break
}
}
return { exists, title: paneTitle ?? tabTitle }
}