orca/docs/resource-usage-merge-spec.md

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 at src/main/ipc/pty.ts:832 because remote process trees are not visible to the local ps/wmic sweep).
  • 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:

  1. Lists every PTY the daemon knows about (the union, not the intersection).
  2. Shows CPU/memory when available (i.e. for local PTYs) and for remote ones, rather than silently dropping remote sessions.
  3. Keeps tab-binding affordances (click row → navigate, kill X with confirm dialog) on every row.
  4. 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: MemorySnapshot and pty.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 by worktreeId. May be null while the daemon is unreachable.
  • sessions: DaemonSession[] — every PTY id the daemon knows, with cwd, title, and a sticky isAlive-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.worktrees is the authoritative source for any local PTY's CPU/Mem and for the worktree's identity (it already carries worktreeId, worktreeName, repoId, repoName, and a sessions[] 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) is pty.sessionId ?? pty.ptyId from pty-registry.ts. In-process local PTYs spawn with args.sessionId === undefined (pty.ts:684), so the registry stores sessionId: null and the fallback pty.ptyId is used — which equals result.id from the local provider's spawn, which is also what LocalPtyProvider.listProcesses() returns as id (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 and pty.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:

  1. Tab-store walk (existing logic in SessionsTabPanel): look up ptyIdsByTabIdtabIdtabsByWorktreeworktreeId. This resolves any session bound to a live tab in this renderer.
  2. Session-id parse: if the id contains @@, take id.slice(0, id.lastIndexOf('@@')) as a candidate worktreeId. This is the convention enforced by mintPtySessionId (see pty-session-id.ts:5-7 and the symmetric parser in daemon-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.).
  3. Unattributed: if neither resolves, bucket under a synthetic unattributed repo 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

  1. Initialize repos: Map<repoId, UnifiedRepoGroup> empty.
  2. Insert all snapshot.worktrees (if any) into the map, grouped by their repoId. Each worktree carries hasLocalSamples: true and numeric metrics; its sessions inherit numeric metrics.
  3. Build seenSessionIds = new Set<string>(...all session ids in snapshot.worktrees[].sessions).
  4. For each DaemonSession in sessions: DaemonSession[]:
    • If seenSessionIds.has(session.id), skip (already merged from snapshot).
    • Resolve worktreeId per the three-step rule above.
    • If the resolved worktreeId is already a worktree group in the map, append the session to its sessions[] with cpu: 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.
  5. Compute per-repo aggregates: sum cpu/memory from worktrees with hasLocalSamples === true. If the repo has any worktree with hasLocalSamples === false, set repoHasRemoteChildren: true (used by the UI to render a · remote badge on the repo header so the user knows the displayed totals exclude remote worktrees).
  6. 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 sessionId appears in boundPtyIds. 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

  1. New helper mergeSnapshotAndSessions.ts (renderer-side, pure function) that takes (snapshot, sessions, storeContext) and returns the unified RepoGroup[] shape above. Unit-testable.
  2. Refactor ResourceUsageStatusSegment.tsx:
    • Remove the tab switcher (drop activeTab state, both 'resources' / 'sessions' branches, the inline pill component, and the Trash2 import for the old switcher).
    • Replace the body with a single render path that consumes the unified RepoGroup[] from the helper.
    • Keep the existing WorktreeSection/AppSection patterns — extend them to render null metrics as and to render the kill-X on each session row (with bound-vs-orphan visibility rule).
    • Promote killConfirm state and the confirm Dialog from SessionsTabPanel to the segment level since there's only one panel now.
    • Keep onSessionsChanged plumbing — call it from kill / kill- orphans handlers to trigger an immediate refreshSessions().
    • Delete the now-unused SessionsTabPanel component.
  3. Wire metric formatting to render when metric is null. Use the null-vs-0 distinction already present in the data model; don't conflate them.
  4. Migration: status-bar item id stays 'resource-usage'. The migrateStatusBarItems helper is unchanged. No persistence migration needed for this UI-only change.

Testing

  • Unit: mergeSnapshotAndSessions with 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 id repoX::/path/wtA@@abcd with no matching tab resolves to repoId repoX, worktreeId repoX::/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 reports cpu/memory = 125 MB and hasRemoteChildren = 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 · remote badge 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.

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 · remote badge 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: synthetic Unattributed repo group at the bottom, which already exists in spirit as ORPHAN_WORKTREE_ID in the memory collector.
  • Risk: Kill-X on a remote session fires pty.kill(id), which the IPC layer routes through ptyOwnership to 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 sessions list can retain the dead row for up to 10s while the snapshot has already moved on; conversely a freshly-spawned local PTY shows up in sessions first 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 local sessions state immediately after the IPC resolves (in addition to calling onSessionsChanged()), so the killed row never lingers on screen. The new-session-flash is bounded by POLL_MS and 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: after runKillConfirmed resolves, 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.