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

388 lines
18 KiB
Markdown

# 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:
```ts
// 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.sessionId`s 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 `DaemonSession`s
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
`ptyIdsByTabId` → `tabId` → `tabsByWorktree` → `worktreeId`. 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.