18 KiB
Resource Usage popover: merge Resources + Sessions into one view
Problem
The status-bar Resource Usage popover currently has two tabs:
- Resources — per-worktree CPU/memory grouped by repo, sourced from
MemorySnapshot.worktrees. Local PTYs only (SSH excluded by design atsrc/main/ipc/pty.ts:832because remote process trees are not visible to the localps/wmicsweep). - Sessions — flat list of every PTY the daemon tracks (local and
SSH), sourced from
pty.listSessions(). Adds tab-binding info, click- to-navigate, kill-X per row, and "Kill N orphans".
The data sources only partially overlap: Sessions can show worktrees
(e.g. orca/Porpoise, orca/Stingray) that never appear in Resources
because they're SSH-backed. Users see this as a confusing inconsistency
between two adjacent tabs in the same popover.
The user wants one unified view that:
- Lists every PTY the daemon knows about (the union, not the intersection).
- Shows CPU/memory when available (i.e. for local PTYs) and
—for remote ones, rather than silently dropping remote sessions. - Keeps tab-binding affordances (click row → navigate, kill X with confirm dialog) on every row.
- Surfaces orphans (PTYs not bound to any tab) with the "Kill N orphans" bulk action.
Direction (already decided by user)
Merge into one tab, drop the switcher entirely.
Out of scope:
- Adding new IPC. The merge uses only the data already available:
MemorySnapshotandpty.listSessions(). - Changing what the memory collector tracks. SSH PTYs remain unsampled. The unified view simply shows them with empty metric cells instead of hiding them.
- Adding new actions (e.g. "stop all in repo", per-repo restart, etc.). The exact action set today (kill one with confirm, kill orphans, sleep worktree, restart daemon, kill-all-sessions) is preserved.
Data model
Inputs
snapshot: MemorySnapshot | null— local-only CPU/Mem data, grouped byworktreeId. May benullwhile the daemon is unreachable.sessions: DaemonSession[]— every PTY id the daemon knows, withcwd,title, and a stickyisAlive-equivalent.tabsByWorktree,ptyIdsByTabId,runtimePaneTitlesByTabId,workspaceSessionReady— store-side context used to (a) compute bound/orphan and (b) resolve human labels.
Output: a renderer-local view model
The merge produces a renderer-local view-model — not a widening of
the shared WorktreeMemory shape. src/shared/types.ts and the
collector are untouched. New types live next to the merge helper:
// src/renderer/src/components/status-bar/unifiedRow.ts
type Metric = number | null // null === "no local sample" (e.g. SSH)
type UnifiedSessionRow = {
sessionId: string
label: string
bound: boolean
tabId: string | null
cpu: Metric
memory: Metric
hasLocalSamples: boolean
}
type UnifiedWorktreeRow = {
worktreeId: string
worktreeName: string
repoId: string
repoName: string
cpu: Metric
memory: Metric
history: number[] // empty when hasLocalSamples is false
hasLocalSamples: boolean
sessions: UnifiedSessionRow[]
}
type UnifiedRepoGroup = {
repoId: string
repoName: string
cpu: Metric // null if every child has hasLocalSamples false
memory: Metric
hasRemoteChildren: boolean // drives the "· remote" badge on the row
worktrees: UnifiedWorktreeRow[]
}
A small adapter inside mergeSnapshotAndSessions converts each
WorktreeMemory from the shared type into a UnifiedWorktreeRow with
numeric metrics and hasLocalSamples: true. Synthetic remote rows are
constructed directly with null metrics. The existing MetricPair and
Sparkline callsites in the popover get a thin wrapper that renders
— when its input is null; nothing in src/shared/ or src/main/
changes.
Merge algorithm
The merge is renderer-only and pure. Inputs in priority order:
- Snapshot first.
snapshot.worktreesis the authoritative source for any local PTY's CPU/Mem and for the worktree's identity (it already carriesworktreeId,worktreeName,repoId,repoName, and asessions[]array with per-session metrics). - Sessions augments.
sessions: DaemonSession[]is the union over the snapshot — it adds (a) SSH PTYs that the local memory collector cannot see and (b) any local PTY that registered too late to make this snapshot's sweep.
Join key (snapshot session ↔ daemon session)
Both layers identify a session by the same string: the PTY id.
SessionMemory.sessionId(collector) ispty.sessionId ?? pty.ptyIdfrompty-registry.ts. In-process local PTYs spawn withargs.sessionId === undefined(pty.ts:684), so the registry storessessionId: nulland the fallbackpty.ptyIdis used — which equalsresult.idfrom the local provider's spawn, which is also whatLocalPtyProvider.listProcesses()returns asid(local-pty-provider.ts:518-522).- For daemon-hosted spawns,
mintPtySessionId(worktreeId)produces the${worktreeId}@@${shortUuid}form (src/main/daemon/pty-session-id.ts); both the registry andpty.listSessions()carry that exact string as the id.
Therefore the dedup key is simply session.id on both sides. The
merge MUST build Set<string> of all SessionMemory.sessionIds from
the snapshot and skip any DaemonSession whose id is already in the
set (so a local session never appears twice — once with metrics, once
as a — placeholder).
Worktree resolution for DaemonSessions
When a DaemonSession is not already accounted for by the
snapshot, we need to bucket it under a worktree group. Try in order:
- Tab-store walk (existing logic in
SessionsTabPanel): look upptyIdsByTabId→tabId→tabsByWorktree→worktreeId. This resolves any session bound to a live tab in this renderer. - Session-id parse: if the id contains
@@, takeid.slice(0, id.lastIndexOf('@@'))as a candidateworktreeId. This is the convention enforced bymintPtySessionId(seepty-session-id.ts:5-7and the symmetric parser indaemon-pty-adapter.ts:328-331). It correctly recovers the worktreeId for SSH sessions that haven't been bound to a tab in this Orca instance — which is the user's primary scenario (orca/Stingray,orca/Sawfish, etc.). - Unattributed: if neither resolves, bucket under a synthetic
unattributedrepo group at the bottom of the list.
The repoId for each new worktree group is recovered the same way the
collector does it (collector.ts:resolveWorktreeNames): split the
worktreeId on the first ::. Resolved repo display name comes from
the renderer-side store (store.repos[repoId]?.displayName) when
available; otherwise the bare repoId is used.
Step-by-step
- Initialize
repos: Map<repoId, UnifiedRepoGroup>empty. - Insert all
snapshot.worktrees(if any) into the map, grouped by theirrepoId. Each worktree carrieshasLocalSamples: trueand numeric metrics; its sessions inherit numeric metrics. - Build
seenSessionIds = new Set<string>(...all session ids in snapshot.worktrees[].sessions). - For each
DaemonSessioninsessions: DaemonSession[]:- If
seenSessionIds.has(session.id), skip (already merged from snapshot). - Resolve
worktreeIdper the three-step rule above. - If the resolved
worktreeIdis already a worktree group in the map, append the session to itssessions[]withcpu: null,memory: null,hasLocalSamples: false. (Reachable for SSH sessions whose worktree happens to match a locally-active worktree id — rare but possible.) - Otherwise, create a new worktree group with
cpu: null,memory: null,history: [],hasLocalSamples: false, and append the session.
- If
- Compute per-repo aggregates: sum
cpu/memoryfrom worktrees withhasLocalSamples === true. If the repo has any worktree withhasLocalSamples === false, setrepoHasRemoteChildren: true(used by the UI to render a· remotebadge on the repo header so the user knows the displayed totals exclude remote worktrees). - The Orca app section (Main / Renderer / Other) renders unchanged below the list.
The whole pass is O(W + S) where W = snapshot worktrees and S = sessions. No nested scans.
Why this is safe with current data
- Every existing local session continues to render with the same numbers. The merge adds rows; it does not change values.
- The snapshot rebuilds every poll (10s), so SSH sessions transitioning to local (or vice versa) flip representation on the next tick. No reconciliation logic needed.
- Orphan detection still works: a session is "bound" iff its
sessionIdappears inboundPtyIds. Sessions in repos we couldn't resolve still get the X-kill affordance.
UI
Trigger (status-bar badge) — unchanged
Mac/Linux/Windows safe (no platform-specific glyphs introduced).
Popover — single panel, fixed height
┌─────────────────────────────────────────────────────────────┐
│ Resource Usage ⟳ 🗑 ✕ │ ← header
├─────────────────────────────────────────────────────────────┤
│ 1.9% · 955.2 MB · 3% of system RAM │ ← summary
├─────────────────────────────────────────────────────────────┤
│ Name CPU Memory · │ ← sort row
├─────────────────────────────────────────────────────────────┤
│ ▾ Triton 0.0% 125.5 MB │
│ Terminal 1 0.0% 63.2 MB ✕│
│ Terminal 1 0.0% 1.3 MB ✕│
│ ▾ Stingray ⓘ remote — — │
│ orca/Stingray — — ✕│
│ ▾ Sawfish ⓘ remote — — │
│ orca/Sawfish — — ✕│
│ ───────────────────────── │
│ ▸ Orca app 0.5% 829.7 MB │
└─────────────────────────────────────────────────────────────┘
↑ fixed 420px body, owns its own scroll
↓ orphan bulk-kill pill renders here when N>0
The header keeps the icon-only Restart daemon (RotateCw) and Kill all sessions (Trash2) buttons in the top-right.
Interaction states (4 paths)
| Scenario | Resource cells | Kill X | Click row |
|---|---|---|---|
| Local PTY, bound | numeric | hover only | navigates |
| Local PTY, orphan | numeric | always | no-op |
| Remote PTY, bound | — |
hover only | navigates |
| Remote PTY, orphan | — |
always | no-op |
— is rendered as text-muted-foreground/50. The worktree row carries
a small · remote badge when hasLocalSamples === false; the repo row
carries the same badge when hasRemoteChildren === true (so users
know the displayed repo totals exclude remote worktrees).
bound semantics are unchanged from today: a session is bound iff
boundPtyIds.has(session.id) evaluated against this renderer's
ptyIdsByTabId. SSH sessions bound to tabs in another Orca process
look identical to local orphans here — same as before the merge.
Empty / loading / error states
- Daemon unreachable → existing banner remains, body shows nothing.
- Sessions error AND no snapshot AND zero sessions → "Resource data unavailable. Restart daemon."
- Snapshot present but zero worktrees AND zero sessions → "Nothing running right now."
- Loading (no snapshot, no error) → "Loading…".
Sort
Three buttons in the sort row: Name, CPU, Memory. CPU/Mem
sort puts null metrics last (stable). Name uses localeCompare.
Selection is repo-group aware: sorting by CPU sorts repos by aggregate
CPU and worktrees within a repo by their CPU.
Confirm dialogs (unchanged)
- Per-row kill X: confirm Dialog with copy "Kill this session? Force-
quits
<id>. Any unsaved work in that pane is lost. This can't be undone." - Header Trash2: existing "Kill all sessions" Dialog from
useDaemonActions. - Header RotateCw: existing "Restart daemon" Dialog from
useDaemonActions. - Worktree-level Sleep / Delete: unchanged.
Implementation plan
- New helper
mergeSnapshotAndSessions.ts(renderer-side, pure function) that takes(snapshot, sessions, storeContext)and returns the unifiedRepoGroup[]shape above. Unit-testable. - Refactor
ResourceUsageStatusSegment.tsx:- Remove the tab switcher (drop
activeTabstate, both'resources'/'sessions'branches, the inline pill component, and theTrash2import for the old switcher). - Replace the body with a single render path that consumes the
unified
RepoGroup[]from the helper. - Keep the existing
WorktreeSection/AppSectionpatterns — extend them to rendernullmetrics as—and to render the kill-X on each session row (with bound-vs-orphan visibility rule). - Promote
killConfirmstate and the confirm Dialog fromSessionsTabPanelto the segment level since there's only one panel now. - Keep
onSessionsChangedplumbing — call it from kill / kill- orphans handlers to trigger an immediaterefreshSessions(). - Delete the now-unused
SessionsTabPanelcomponent.
- Remove the tab switcher (drop
- Wire metric formatting to render
—when metric isnull. Use thenull-vs-0distinction already present in the data model; don't conflate them. - Migration: status-bar item id stays
'resource-usage'. ThemigrateStatusBarItemshelper is unchanged. No persistence migration needed for this UI-only change.
Testing
- Unit:
mergeSnapshotAndSessionswith synthetic inputs covering:- All 4 interaction-state paths (local-bound, local-orphan, remote-bound, remote-orphan).
- Dedup: a local session that appears in both the snapshot and the daemon list is rendered exactly once with numeric metrics.
@@parse: an SSH session idrepoX::/path/wtA@@abcdwith no matching tab resolves to repoIdrepoX, worktreeIdrepoX::/path/wtA.- Tab walk wins over
@@parse: a session bound to a tab whose worktreeId differs from the@@prefix uses the tab's worktreeId (defensive against id-format drift). - Repo aggregate excludes remote children: a repo with one
local (125 MB) and one remote (
null) worktree reportscpu/memory = 125 MBandhasRemoteChildren = true. - Edge cases: snapshot null, sessions empty, both empty, sessionId
without
@@and no tab match → falls into Unattributed.
- Manual:
- Open with mixed local + SSH worktrees → both render, SSH rows show
—and a· remotebadge on their worktree and repo headers. - Kill a remote session via the X → confirm dialog → optimistic removal so row disappears immediately (does not wait 10s).
- Kill all (header trash) → orphans gone, bound sessions remain.
- Resize window / scroll the body → fixed 420px holds, no reflow.
- Accessibility: tab through rows → kill X focusable; after kill
confirm, focus lands back on the popover content root, not on
<body>. Dialog is keyboard-dismissable except while in-flight.
- Open with mixed local + SSH worktrees → both render, SSH rows show
Risks & mitigations
- Risk: SSH worktrees the user never opened locally still appear in
Resources (potentially noisy if the daemon retains many idle SSH
sessions). Mitigation: bucketing under a
· remotebadge keeps them visually distinct from local-active groups; sort-by-memory pushes them to the bottom. If noise becomes a real complaint, a future PR can add a "hide remote" toggle. - Risk: Some sessions can't be resolved to any worktreeId (e.g.
daemon-internal sessions whose ids don't follow the
@@convention, or sessions whose worktreeId resolves to a repo store doesn't know about). Mitigation: syntheticUnattributedrepo group at the bottom, which already exists in spirit asORPHAN_WORKTREE_IDin the memory collector. - Risk: Kill-X on a remote session fires
pty.kill(id), which the IPC layer routes throughptyOwnershipto the right SSH provider. Behavior is identical to the old Sessions tab — no new failure mode. - Risk: poll-rate skew between snapshot (2s) and sessions (10s).
After killing an SSH session, the daemon-side
sessionslist can retain the dead row for up to 10s while the snapshot has already moved on; conversely a freshly-spawned local PTY shows up insessionsfirst and is rendered as a remote placeholder for up to 2s before the next snapshot promotes it. Mitigation: per-row kill optimistically removes the session id from the renderer's localsessionsstate immediately after the IPC resolves (in addition to callingonSessionsChanged()), so the killed row never lingers on screen. The new-session-flash is bounded byPOLL_MSand acceptable. - Risk: focus dropped to
<body>after kill confirm. When the killed session disappears on the next refresh, the per-row X that had focus is unmounted. With the confirm Dialog now mounted at the segment level, this regression is more visible. Mitigation: afterrunKillConfirmedresolves, focus the popover's content root via a stable ref so keyboard users land back in the list, not on<body>.
Non-goals
- No new IPC.
- No collector changes.
- No new persisted UI prefs (the per-tab visibility split is gone, not replaced with a per-section toggle).
- No remote memory sampling — that's a separate, much bigger project.