feat(sessions): saved session lists + live-process view

Merged
GR
2mo ago
+3517-273

Summary

Two features that turn out to be one screen, on the Session Buddy model (issues #145 and #94; groups B and C0-adjacent in tracking issue #144):

  • ● N live — a scope chip in the search row that narrows the list to sessions with a running process, with the memory they hold beside it. A stats toggle (off by default, remembered) adds each row's memory and uptime for the "which one do I close first" moment.
  • save list… — captures what is on screen (the live set, the pinned set, or a search result) as a named list. 🗂 N shows the saved lists; click one to view its members in the order they were captured and resume any of them.
  • Search matches the session id on both paths, so the id a terminal status line shows finds the session — the one field that stays unique when several sessions share a name (#142). A row that matched on its id shows an id 4ed7505a marker.

Distinct from pins on purpose: a pin is "this matters long-term", a list is "this is what I had open on Tuesday". Both stay.

Why the live view is a prerequisite, not a bonus

"Save what is open" needs a correct answer to "what is open", and ~/.claude/sessions/<pid>.json alone does not give one. Measured on the reference machine (2026-09-05, 33 real sessions): one session was running with no registration and one registration pointed at a dead process. Saving from the registrations would have omitted a session and stored a ghost.

So the live report joins ps (ground truth for "running", knows nothing about sessions) against the registrations (knows the session, can be stale or missing):

CaseShown as
registered + runningnormal row (+ 327MB · 3h51m with stats on)
running, not registeredits own row, ⚠ unregistered — invisible to every other view in the app
registered, process deadcounted as stale in the chip's tooltip; never rendered as a live row
running, registered, but no history.jsonl line yet (a /branch child before its first prompt)a synthetic row named after its cwd, resumable; the main list deliberately still omits it (#149)
the daemon and its bg-pty-host / bg-spare helpersexcluded — they are claude binaries too, and counting them is how an earlier tally reported 5 orphans instead of 1

The filter is "session process and (registered or attached to a tty)" — VS Code sessions have no tty but are always registered, so they still count. The same join also tells a saved-list member or a pin placeholder whether it is running, so clicking a running session the registration-based detection cannot see switches to it instead of resuming a second copy.

Verified against this machine with the shipped code (a throwaway vitest probe, deleted before commit): 33 live sessions, 4.99GB resident; recaps extracted for 37 of the 40 most recent sessions, timestamps intact, trailing (disable recaps in /config) stripped. Then three rounds of manual testing (25-step plan in the comment below), including the ⚠ unregistered and store-watcher cases driven live from a second session.

What a saved member stores — this is the feature

A list of bare sessionIds is useless for recall. Each member captures title, branch, pin state (a snapshot, never updated afterwards), the last user and assistant messages, and the recap line Claude Code writes into the transcript ("type":"system","subtype":"away_summary" — the ※ recap: line at the bottom of the terminal). Every text field is capped, so a 30-session list is a few tens of KB.

The recap replaces the last-reply line on a member row because it is written to answer exactly the question a snapshot answers. It is reliable enough to lead with — 65 of 66 non-trivial sessions in the corpus carry one; the misses are ≤29-line stubs below the three turns it needs — but not unconditional: it can be switched off in /config, needs the terminal to have been unfocused, and never repeats back-to-back, so it can predate the session's last turn. Hence the fallback to the last message, and the ⏱ mark when a recap is more than 30 minutes older than the session's last activity: its final sentence is usually "next: …", and acting on a stale one is the failure mode.

Deliberately absent

  • No "open all". In a browser, restoring 22 tabs is cheap. Here, 22 sessions is ~3GB of processes — the very problem this feature exists to relieve. Restore is per row (the existing click-to-resume); a whole-set restore, if it ever comes, must show the projected cost first.
  • No "repair" for an untrusted store. A lists file that parses but whose normalization is not a no-op (hand-edited, or a hypothetical future format change) is reported at load — "N lists / M sessions inside — fix or remove it" — never rewritten from the UI. A repair button was drafted and dropped: no released build ever wrote such a file, and a real format change is a versioned migration's job.
  • No tty on the row. A person cannot act on a tty name; it lives in the tooltip and in the data, where window switching (#142 C0) will need it.

Where it lives, and why

Inside the Sessions tab as scopes, not a new tab: row rendering, search, pins, status dots and resume-on-click already live there, and vertical space is the scarce resource. The two entry points are chips in the search row (spare width), and a scope replaces the list rather than adding to it. Scopes rank — a list being viewed beats live, live beats pinned-only — and the ranking is encoded in the pure buildSessionListView so a stale flag can never blank the list.

Under the hood

  • src/atomic-json-store.ts (new): the authoritative-read / atomic-write / directory-watch machinery, extracted from the marks store so the two stores share it. The read-authority invariant that PR #137 spent four review rounds narrowing now has exactly one implementation; session-marks.ts delegates to it and its 16 existing tests pass unchanged.
  • src/session-lists.ts (new): the lists store, ~/.config/codev/session-lists.json, with the same refuse-to-write-over-an-unreadable-file guard. What gets written is built by the store's own normalizer, so a saved list is by construction one a later read accepts as authoritative — and the normalizer is tested to be a fixed point of itself, because the first dev build's was not (a cap that landed on a space) and the store refused every write after the first. A refused write is shown in the UI, never swallowed.
  • src/live-sessions.ts (new): ps parsing, the session-process filter, and the join, all pure and tested against captured ps output; lsof is asked for a cwd only for unregistered processes. An empty ps result is a failure (a process table is never empty), not "nothing is running" — a timed-out ps on a swapping machine had rendered as ● 0 live ⚠33.
  • Enrichment gains one grep pattern (away_summary) in the pass that already reads custom-title / ai-title / pr-link.
  • session-list-view.ts: two new scopes with precedence; running state from both the registration map and the ps join; synthetic live rows keyed by pid (two processes can share one sessionId — a resumed copy, a /branch parent and child — and duplicate React keys left stale rows on screen).
  • Renderer: applyEnrichment() replaces four hand-copied then(...) blocks, so a new enrichment field lands at every call site or none. The save dialog lives at the top level, outside the mode branches — nested inside the Projects branch it never rendered from Sessions.
  • Docs: new §4.8 records the reasoning above; §5.2 and §7.4 corrected — both still said only --fork-session creates duplicate transcripts, which /branch falsified (cubic's finding on #143, folded in here).
  • Tests: 53 new, 138 total; the fixed-point test was mutation-verified (reverting the fix turns it red).

Follow-ups filed

  • #148 — resizable window in normal app mode, then width-aware line caps.
  • #149 — a /branch child with no prompt yet has no row and its ancestors lose their dot (pre-existing; the live scope makes it visible). Decided to leave the main list as is until the generation-chain work in #142.

Lint note

The three new modules and their tests are prettier-clean. switcher-ui.tsx, main.ts and claude-session-utility.ts were never prettier-formatted (1,100+ pre-existing prettier errors in the first alone); per the repo norm, added lines follow the surrounding style rather than reflowing the file. There is no CI lint gate.

Testing

yarn test (138 passing), tsc --noEmit clean, electron-forge package compiles both webpack bundles. Three rounds of manual testing against the 25-step plan in the comment below; every step passed or has an issue.

🤖 On behalf of @grimmerk — generated with Claude Code

Review rounds (bot review loop, 4 work-rounds)

RoundHeadFindingsOutcome
1434103bCodeRabbit 5 + cubic 15 threads, 3 body nitpicks17 fixed, 3 declined with reasons (prettier on never-formatted files; a live-scope timer the user had ruled out; a store lock → #150)
2fb7518ecubic 5all fixed — representative pid chosen from the live report; -p one-shots
3001f2b4cubic 2 + CodeRabbit 1 + 2 body itemsall fixed — live report refreshed with every refetch; one-shot flags after other options; captured fields searchable
47426ecbcubic 4all fixed — scan every token for one-shot flags (the leading-options walk could not survive ps dropping quotes); list-row a11y settled

| 5 (by request) | 9842bb6 | cubic 1 | the saved-list row, fifth finding on it across the rounds — settled as three siblings in a plain wrapper, the opener filling the row |

| 6 (by request) | d4a1c13 | cubic 1 | the row's gap and right padding were dead area — spacing moved onto the opener and the controls; plus the user-requested change that a scope survives resuming from it |

| 7 (by request) | 604dc58 | cubic 0 · CodeRabbit 1 + 1 body nitpick | only the newest live report is committed (request sequence); ref written in an effect. Plus the README catch-up for #139 and this PR |

CodeRabbit was rate-limited on 4 of the 8 heads (free plan, 1 review/hour). All 35 threads are resolved.