diff --git a/CHANGELOG.md b/CHANGELOG.md index 67db9da..1f82c5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 1.0.87 + +- Feat: saved session lists and a live-process view, on the Session Buddy model ([#145](https://github.com/grimmerk/codev/issues/145), [#94](https://github.com/grimmerk/codev/issues/94)) + - **`● N live` chip** next to the search box scopes the list to sessions with a running process; the total memory they hold shows beside it. A **`stats`** toggle (off by default, remembered) adds each row's **memory and uptime** for the "which one to close first" moment — off by default because on most rows those figures track the message count closely enough to be noise. Pid and terminal device live in the tooltip. Measured while building it: 36 `claude` processes held 4.66GB while the terminal app itself held 368MB + - The live view is built by joining `ps` against `~/.claude/sessions/`, not by trusting the registration files: a session that is running but never registered shows up marked **`⚠ unregistered`** (invisible to every other view), and a registration whose process is gone is counted as stale in the chip's tooltip instead of being shown as a ghost. The same join also tells a saved-list member whether it is running, so clicking a running session that has no history row yet (a fresh `/branch` child) switches to it instead of resuming a second copy + - **`save list…`** captures what is on screen — the live set, the pinned set, or a search result — as a named list, stored in `~/.config/codev/session-lists.json` + - **`🗂 N` chip** shows the saved lists; click one to view its members in the order they were captured and resume any of them. Lists can be renamed (`✎` on the row or in the list header) and deleted (`✕`, confirmed with a second click). The default name is today's `MMDD` — a label, not an identity — and becomes `MMDD-2`, `MMDD-3` on a second save that day. A member whose transcript is gone still reads as the session it was, because the list stored its title, branch and last messages + - Each member carries the **recap line** Claude Code writes into the transcript (`away_summary` — "where we are, what's next"), shown on the row in place of the last reply. Measured: 65 of 66 non-trivial sessions have one. A recap that predates the session's last activity by more than 30 minutes is marked `⏱`, because its "next step" may already be done + - **Search matches the session id** (both search paths, one shared rule), so the id a terminal status line shows finds the session — the one field that stays unique when several sessions share a name ([#142](https://github.com/grimmerk/codev/issues/142)). The rule is a **prefix of at least four hex characters**, never a substring — `de` or `cafe` would otherwise match nearly every session through its id. A row that matched on its id shows an `id 4ed7505a` marker, since the id is not otherwise on screen + - A session with **two running processes** (a resumed copy, or a `/branch` parent and child) shows both in the live scope, the second marked `⚠ 2nd process`, so the chip's count and the list agree and the memory total adds every process + - The marks and lists stores are now written with owner-only permissions (`0600`); the lists store carries conversation snippets + - **A scope survives resuming from it.** Open a session from a saved list or from the live scope, come back, and you are still in that list / that scope — a scope is a place to work through several sessions. Only the search box is cleared on return, as before + - Deliberately absent: an "open all" button. Reopening 22 browser tabs is cheap; resuming 22 sessions is ~3GB of processes, which is the problem this feature exists to relieve + - Under the hood: the marks store and the new lists store share one atomic-JSON-store module (`src/atomic-json-store.ts`) — the read-authority invariant PR #137 spent four review rounds on now has exactly one implementation. 53 new unit tests (lists normalize / transitions / file roundtrip / normalizer fixed point / untrusted-file inspection, `ps` parsing and the live join, list-view scopes, session-id prefix search) — 138 total + ## 1.0.86 - Feat: session rows are readable again when titles are long diff --git a/README.md b/README.md index 61db21c..157ad5c 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Press `⌃+⌘+R` or click the menu bar icon to launch the Quick Switcher. Searc CodeV can list, search, and resume Claude Code sessions. Press `⌃+⌘+R` to open the Quick Switcher, then `Tab` to toggle to Sessions mode. Live status dots show session state: working (orange pulse), idle (green), needs attention (orange blink). -Search covers **every session and every user prompt you ever typed** (not just the ~100 most recent sessions shown in the list) plus titles, branches, PR links, and last AI replies. When a match sits in the middle of a conversation, the row shows a `⌕ #N …` snippet with the surrounding context. Closed one-shot sessions (≤2 messages, untitled, no PR) fold into an expandable "minor sessions" row to keep the list scannable. +Search covers **every session and every user prompt you ever typed** (not just the ~100 most recent sessions shown in the list) plus titles, branches, PR links, last AI replies, and the **session id** (a prefix of four or more hex characters — type the id your terminal status line shows to find that exact session, then `⌘D` to pin it; the row shows an `id 4ed7505a` marker since the id is not otherwise on screen). When a match sits in the middle of a conversation, the row shows an amber `match #N` snippet with the surrounding context, and every capped line — title, first/last message, branch, last reply — **moves its window to the match** so you can see *why* the row is there. Long titles are shortened **from the middle** (`head … tail`), so a title written as an `A -> B > C` chain keeps its newest step; hover for the full title. Closed one-shot sessions (≤2 messages, untitled, no PR) fold into an expandable "minor sessions" row to keep the list scannable. **Pin** the sessions you keep coming back to (hover 📌 on a row, or `⌘D` on the selected row): they **move into** a **📌 Pinned** zone at the top, ordered by recency like the rest of the list — works even for old sessions found via deep search. **Hide** one-offs you never want in the main flow (hover ⊘, or `⇧⌘D`): they move into the minor-sessions fold, stay searchable, and can be unhidden from inside the fold (they carry a persistent ⊘ marker there). Pins and hides live in `~/.config/codev/session-marks.json`, shared across accounts. @@ -35,6 +35,18 @@ The `📌 Pinned (N)` header carries two independent toggles: Keyboard semantics worth knowing: the shortcuts act on the **selected row** (the one with the blue left border — hovering selects), and require an explicit selection. `⌘D` = pin/unpin toggle; `⇧⌘D` = hide (on a pinned row this unpins *and* folds in one step — pin and hide are mutually exclusive). When the last pin is removed the header disappears entirely (that's normal, not a collapse). A pinned session is never folded away as a "minor session", whatever its message count. +#### Live view and saved session lists + +Two chips beside the search box, on the [Session Buddy](https://sessionbuddy.com/) model — save what is open, put the windows down, come back to the set later: + +| Chip | What it does | +|---|---| +| `● N live` | **Scope the list to sessions with a running process**, with the memory they hold beside it. Built by joining `ps` against Claude Code's own `~/.claude/sessions/` registrations, so a session that is running but never registered still shows (marked `⚠ unregistered`), a registration whose process is gone is never shown as a ghost, and a session running under **two** processes (a resumed copy, a `/branch` parent and child) shows both (`⚠ 2nd process`). 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…` | Appears whenever the list is scoped (`● live`, `only`, or a search): **saves exactly what is on screen as a named list**. The default name is today's `MMDD`, then `MMDD-2`, `MMDD-3` — a label, not an identity. | +| `🗂 N` | Shows the saved lists. Click one to view its members **in the order they were captured** and resume any of them; `✎` renames, `✕` (then `delete?`) deletes. | + +A saved member stores what you recognise a session by — title, branch, pin state at capture, the last messages, and the **recap** line Claude Code writes into the transcript (the `※ recap:` "where we are, what's next" line), which replaces the last-reply line on the member's row; a recap much older than the session's last activity is marked `⏱`, since its "next step" may already be done. A member whose transcript is gone still reads as the session it was. Opening a session from a list or from the live scope **leaves you in that scope** when you come back (only the search box is cleared), so you can work through a set one session at a time. There is deliberately no "open all": 22 sessions is a few GB of processes, which is the very thing a saved list exists to relieve. Lists live in `~/.config/codev/session-lists.json`; a file that cannot be trusted as written is reported at load, never rewritten. + **Simple rule**: when running multiple sessions in the same project directory at the same time, give each running session a name. Closed sessions don't need names — they won't cause issues. - **Best**: start with a name — `claude -n "my task"` (or `claude --name "my task"`) diff --git a/docs/session-finding-plan.md b/docs/session-finding-plan.md index 7cf1447..9325a34 100644 --- a/docs/session-finding-plan.md +++ b/docs/session-finding-plan.md @@ -326,6 +326,110 @@ listing / `history.jsonl` tail; run it either side of an action and diff. It is "resume sometimes seems confused" into the numbers above, and it applies to any future question about session identity (`/fork` is the open one — see #142's comment). +### 4.8 Saved session lists + the live view (issues #145, #94) + +Status and open questions live in **#144**; the reasoning is here. + +**The model is Session Buddy, not favourites.** Pins (§4.4) are "this matters long-term"; +a saved list is "this is what I had open on Tuesday" — a named snapshot of the running +set, put down so the windows can be closed and picked up later. The two are different +things and both stay. + +**Why the live view is a prerequisite, not a nicety.** "Save what is open" needs a correct +answer to "what is open", and `~/.claude/sessions/.json` alone does not give one: +measured 2026-09-05 across 33 real sessions, one was running with no registration and +one was registered with a dead process. Saving from the registrations would omit a +session and store 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), and a process with no registration is shown as its own row marked +`⚠ unregistered`. The join is what makes the count trustworthy; the same measurement also +showed that four of five "unregistered claude processes" were the daemon and its pty +helpers, which is why the filter is "session process **and** (registered **or** attached to +a tty)" rather than "any `claude` binary". + +**Why it lives in the Sessions tab as scopes, not as a new tab.** Row rendering, search, +pins, status dots and resume-on-click all already live there; a separate screen would +either duplicate them or force a refactor of the biggest file in the app. Vertical space +is the scarce resource, so the two new entry points are chips in the search row (which has +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 — encoded in `session-list-view.ts` so a +stale flag can never blank the list. + +**A scope survives resuming from it; the search box does not.** Opening a session clears the +query on the next show (a query is a way of finding one session), but it leaves a saved list or +the live scope in place: a scope is a place to work *through* several sessions — resume one, +come back, resume the next — and being dropped out of it on every return was the first +complaint in live testing. The scope's header / active chip keeps it visible; `✕` or the chip +leaves it. + +**What a member stores is the feature.** A list of bare sessionIds is useless for recall. +Each member captures title, branch, pin state (a snapshot — never updated later), the last +user and assistant messages, and the **recap** Claude Code writes into the transcript +(`"type":"system","subtype":"away_summary"`), every text field capped so a 30-session list +is a few tens of KB. The recap is preferred over the last assistant turn because it is +written to answer exactly the question a snapshot answers, and it is reliable enough to +lead with: 65 of 66 non-trivial sessions carry one (the misses are ≤29-line stubs that never +reached the three turns it needs). It is 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** — which is why the row shows the last message as a +fallback and marks a recap `⏱` when it 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: "open all".** In a browser, restoring 22 tabs is cheap. Here, 22 +sessions is ~3GB of processes — the very problem the feature exists to relieve. Restore is +per-row (the existing click-to-resume), and a whole-set restore, if it ever comes, has to +show the projected cost first. + +**Drift across `/branch` is shared with pins.** A list's members are keyed by sessionId +(each carrying the captured title, branch, pin state, messages and recap), so §4.7 applies +unchanged: after a branch, the member's key points at the ancestor. That is one more consumer of +the stable-task-identity decision in #142 (C1), and an argument for making it rather than +routing around it. + +**Store.** `~/.config/codev/session-lists.json`, beside the marks store, on the same +authoritative-read / atomic-write / directory-watch machinery — extracted into +`src/atomic-json-store.ts` so the read-authority invariant PR #137 spent four rounds on has +exactly one implementation. **That invariant has a corollary the first live test paid for: +the store's normalizer must be a fixed point of itself.** A cap that landed on a space wrote +a trailing blank that the next read trimmed away, so the file the app had just written read +back as "normalization would change this" — non-authoritative — and every later write was +refused, silently. Two rules follow: normalize → serialize → normalize must be byte-stable +(tested), and a refused write must be shown, never swallowed. + +**The `ps` join is also what makes "is it running" right for rows the registration-based +detection cannot see.** A session with no history row yet (a `/branch` child before its +first prompt) is invisible to `detectActiveSessions`, so a row for it reads as not running and +a click *resumes* it — a second process for the same id. Saved-list members and pin +placeholders now take their running state from the join as well, and viewing a list refreshes +it. The general fix — feeding the join into active detection itself — is #142 C0 territory. + +**What the row shows.** By default, nothing extra: the live scope is a "running sessions +only" browse, and the one figure that says whether there is a problem — the total memory — +sits beside the search box. Per-row memory and uptime are behind a `stats` toggle (off by +default, remembered), because on most rows they track the message count closely enough to be +noise (user verdict after two rounds); they earn their width at the "which one do I close +first" moment, which is occasional. The tty is never on the row: a person cannot act on a tty +name, and it stays in the tooltip and in the data, where the future window-switching (#142 C0) +needs it. + +**What the main list deliberately does not show.** A running process whose session has no +`history.jsonl` line — a `/branch` child before its first prompt (measured: the `/branch` prompt +itself is recorded under the *parent*) — has no row in the main list, and never did before +this feature either. The live scope synthesizes a row for it (named after its cwd, `⚠ +unregistered` if it also lacks a registration); the main list does not. Decided 2026-09-05 to +keep it that way for now: a synthetic main-list row would be nearly blank (`codev · … msgs · +dot`) until `forkedFrom` is read, and the honest presentation is the generation chain — the +child under its parent's lineage, with the parent's title — which is #142 C2/C3. The precise +repro and the interim option are in #149; the workaround today is the live scope. + +**An untrusted store is reported, not repaired.** When the lists file exists but its +normalization is not a no-op (hand-edited, or a hypothetical future format change), the UI +says so with what the file holds — "N lists / M sessions inside; fix or remove it" — instead +of silently showing an empty list, which read as "my list was deleted" in a live test. It is +never rewritten from the UI: that would be an exception to the read-authority rule for a case +no released build produces, and a real format change is a versioned migration's job. + ## 5. Batch 2 — structural investments ### 5.1 C4: preview / detail (v1 card → v2 pane) @@ -367,8 +471,11 @@ question about session identity (`/fork` is the open one — see #142's comment) - Multi-account: one DB with an `account` column; scan sources via the existing `getScannableAccounts()`. - Duplicate-content note: normal resumes append to the same file (§4.4) — no cross-file - duplication; only explicit `--fork-session` creates ancestor/descendant double-matches — - rare, v1 ignores. + duplication. **But `/branch` copies the transcript into a new file on every use, and it is + a daily action (23.9% of transcripts, §4.7)** — so ancestor/descendant double-matches are + common, not rare, and an index must dedupe them. The copied lines carry `forkedFrom`, so + "skip lines whose `forkedFrom.sessionId` is already indexed" is exact. (This note used to + say only `--fork-session` creates duplicates; that was wrong.) - Expired sessions (transcript already cleaned up): the index keeps the text → results get an "expired" badge (readable, not resumable). @@ -397,10 +504,12 @@ question about session identity (`/fork` is the open one — see #142's comment) the same file (fact 4) → titles persist naturally; pins keyed by sessionId persist the same way. 4. **Resume semantics (verified on 2.1.207 via `--help`)**: `--resume` / `--continue` - **reuse the sessionId and continue the same file by default**; only `--fork-session` - creates a new id/file. ⚠️ Old Claude Code versions forked by default — stale web posts and - old experience still claim that; don't trust them (this plan's first draft got it wrong - until the user challenged it). + **reuse the sessionId and continue the same file by default**; `--fork-session` creates a + new id/file, **and so does `/branch`** — the same process keeps writing to a copied + transcript under a new id (§4.7, measured on 2.1.260). ⚠️ Old Claude Code versions forked + by default — stale web posts and old experience still claim that; don't trust them (this + plan's first draft got it wrong until the user challenged it). Anything about `/fork` must + carry a version: its meaning changed at 2.1.161 and again at 2.1.212 (issue #142). 5. **history.jsonl: one line = one complete user prompt** (`display` untruncated, longest measured 9,224 chars); a session spans many lines; the accumulator keeps first/last and, since PR #132, all prompts in a main-side map. diff --git a/package.json b/package.json index 806b9d8..7cf69ef 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "CodeV", "productName": "CodeV", - "version": "1.0.86", + "version": "1.0.87", "description": "Quick switcher for VS Code, Cursor, and Claude Code sessions", "repository": { "type": "git", diff --git a/src/atomic-json-store.ts b/src/atomic-json-store.ts new file mode 100644 index 0000000..1c08983 --- /dev/null +++ b/src/atomic-json-store.ts @@ -0,0 +1,170 @@ +/** + * One small JSON file as a store: authoritative reads, atomic writes, and a + * directory watch — shared by `session-marks.ts` (pins / hidden) and + * `session-lists.ts` (saved session lists). + * + * The rule that matters here is the AUTHORITY invariant, and it lives in one + * place on purpose. Every store has a forgiving `normalize` that coerces + * anything into a valid value so a partly-corrupt file still renders; that is + * right for display and wrong for authority, because a read-modify-write + * would write the coerced result back for real and erase the original. PR + * #137 spent four review rounds narrowing that check for the marks store — + * each round found one more way normalization could differ — before settling + * on "normalization must be a no-op". A second store re-deriving that rule + * would re-derive it wrong, so both stores call this one. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * A read plus whether its result is authoritative. + * + * `known: false` means the contents are UNKNOWN, not empty. Callers that act + * on emptiness — clearing a stored preference, broadcasting a change, writing + * back — must not act on an unknown read, or a transient filesystem failure + * destroys user state that is still perfectly intact on disk. + */ +export interface StoreRead { + value: T; + known: boolean; +} + +/** Stable JSON — object keys sorted, so key ORDER can never fake a difference. */ +export const canonical = (value: unknown): string => + JSON.stringify(value, (_key, val) => + val && typeof val === 'object' && !Array.isArray(val) + ? Object.fromEntries( + Object.entries(val as Record).sort(([x], [y]) => + x < y ? -1 : x > y ? 1 : 0, + ), + ) + : val, + ); + +/** + * Is a parsed value a store we may treat as AUTHORITATIVE? + * + * The invariant is simply **normalization must be a no-op**. Comparing the + * whole normalized result against the input has no narrower case left to + * miss, and it tracks the store's `normalize` automatically instead of + * restating its rules beside it. + * + * Deliberately strict: a file this build would rewrite in ANY way — including + * one carrying a field a future version added, or a bare `{}` — is refused + * rather than silently rewritten. Refusing costs one lost action; rewriting + * costs the user's data. + */ +export const isAuthoritativeRead = ( + raw: unknown, + normalized: unknown, +): boolean => canonical(raw) === canonical(normalized); + +export const readStoreResult = ( + filePath: string, + normalize: (raw: unknown) => T, + empty: () => T, +): StoreRead => { + try { + const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + const value = normalize(raw); + return { value, known: isAuthoritativeRead(raw, value) }; + } catch (err) { + // A missing file IS authoritative: no store yet means nothing stored yet, + // which is simply the first run. Anything else — permissions, IO, + // malformed JSON — leaves the real contents unknown. + const code = (err as NodeJS.ErrnoException | undefined)?.code; + return { value: empty(), known: code === 'ENOENT' }; + } +}; + +export const writeStoreFile = (filePath: string, value: unknown): void => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + // temp + rename so a crash mid-write can't corrupt the store. Owner-only + // permissions: the lists store carries conversation snippets. + const tmp = `${filePath}.tmp-${process.pid}`; + fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { + encoding: 'utf-8', + mode: 0o600, + }); + // `mode` applies only when the file is created; a leftover temp from a + // crashed write keeps whatever mode it had. Set it explicitly. + fs.chmodSync(tmp, 0o600); + fs.renameSync(tmp, filePath); +}; + +/** + * Read-modify-write that REFUSES to write when the store could not be read. + * + * Every mutation is read-modify-write over the whole file, so a read that + * silently degrades to the empty value turns the next change into a full + * overwrite. A missing file is still fine — ENOENT is authoritative — so the + * first-ever write creates the store as usual. + * + * Returns the resulting value with `known: true` when the write happened, or + * the unknown read (`known: false`) when it was refused and nothing was + * touched. + */ +export const mutateStoreFile = ( + filePath: string, + normalize: (raw: unknown) => T, + empty: () => T, + mutate: (value: T) => T, +): StoreRead => { + const read = readStoreResult(filePath, normalize, empty); + if (!read.known) return read; + const next = mutate(read.value); + writeStoreFile(filePath, next); + return { value: next, known: true }; +}; + +/** + * Watch a store file for changes. Watches the parent DIRECTORY: the + * rename-based write replaces the file inode, which would detach a plain file + * watcher. Events for sibling files (other stores, our own .tmp) are filtered + * out by name. + * + * Never broadcasts an unknown read. Announcing "the store is now empty" + * because the file could not be parsed would push every listener into acting + * on state that is still intact on disk; staying silent leaves them on the + * last thing actually seen. + */ +export const watchStoreFile = ( + filePath: string, + read: (filePath: string) => StoreRead, + onChange: (value: T) => void, + onError?: (err: Error) => void, +): (() => void) => { + const dir = path.dirname(filePath); + const filename = path.basename(filePath); + fs.mkdirSync(dir, { recursive: true }); + + // Debounce: fs.watch on macOS fires several times per change + let debounceTimer: ReturnType | null = null; + const watcher = fs.watch(dir, { persistent: false }, (_event, changed) => { + if (changed && changed !== filename) return; + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + const result = read(filePath); + if (!result.known) return; + onChange(result.value); + }, 50); + }); + + watcher.on('error', (err: Error) => { + // A dead watcher must not crash the main process (unhandled 'error' + // would) — close it and let the owner decide whether to recreate. + try { + watcher.close(); + } catch { + // already closed by the OS; nothing left to release + } + if (debounceTimer) clearTimeout(debounceTimer); + onError?.(err); + }); + + return () => { + if (debounceTimer) clearTimeout(debounceTimer); + watcher.close(); + }; +}; diff --git a/src/claude-session-utility.ts b/src/claude-session-utility.ts index 8c66d4f..ee56863 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -15,7 +15,7 @@ import { } from './accounts'; import { findPromptMatch, - matchesAllWords, + matchesAllWordsOrId, PromptMatch, } from './session-search'; @@ -96,6 +96,7 @@ export const invalidateSessionCache = () => { cachedCustomTitles = null; cachedBranches = null; cachedPRLinks = null; + cachedRecaps = null; enrichedFileState.clear(); }; @@ -227,9 +228,12 @@ export const searchClaudeSessions = ( for (const s of allSessions) { const sessionPrompts = promptsBySession.get(s.sessionId) || []; + // The id shown in a terminal status line finds the session too — by the + // prefix rule in matchesSessionId, shared with the renderer's + // filterSessionsLocally so the two paths agree on what an id query is. const target = `${s.projectName} ${s.project} ${sessionPrompts.join('\n')}`.toLowerCase(); - if (!matchesAllWords(target, words)) continue; + if (!matchesAllWordsOrId(target, s.sessionId, words)) continue; sessions.push(s); const match = findPromptMatch(sessionPrompts, words); @@ -1709,15 +1713,31 @@ export interface PRLinkInfo { prUrl: string; } +/** + * The one-line "where we are, what's next" recap Claude Code writes into the + * transcript (`"type":"system","subtype":"away_summary"`) when you come back + * to an unfocused terminal. Measured 2026-09-05: 65 of 66 non-trivial + * sessions carry one; the misses are ≤29-line stubs that never reached the + * three turns it needs. It can be switched off in /config, so it is a + * primary source with a fallback, never the only one. + */ +export interface RecapInfo { + text: string; + /** ISO time it was written — a recap never repeats back-to-back, so it can lag the session's last turn. */ + at: string; +} + export interface SessionEnrichment { titles: Map; branches: Map; prLinks: Map; + recaps: Map; } -// Cache for branches and PR links +// Cache for branches, PR links and recaps let cachedBranches: Map | null = null; let cachedPRLinks: Map | null = null; +let cachedRecaps: Map | null = null; // Per-file enrichment scan state: a transcript unchanged since its last scan // (same mtime+size) is never re-grepped — the stat check IS the freshness @@ -1772,6 +1792,7 @@ export const loadSessionEnrichment = async ( const titles = (cachedCustomTitles ??= new Map()); const branches = (cachedBranches ??= new Map()); const prLinks = (cachedPRLinks ??= new Map()); + const recaps = (cachedRecaps ??= new Map()); const { execFile } = require('child_process'); // Shell-free (no interpolated paths anywhere near a shell). Resolves null @@ -1838,11 +1859,12 @@ export const loadSessionEnrichment = async ( // from an in-process tail read (~256KB reaches far beyond the old // 50-line window — an active session's tail is often tool output with // no gitBranch field: measured tail -5 hit 0, tail -20 hit 12). - const [titleOutput, aiTitleOutput, prLinkOutput, tailOutput] = + const [titleOutput, aiTitleOutput, prLinkOutput, recapOutput, tailOutput] = await Promise.all([ grepFileP('"type":"custom-title"', jsonlPath), grepFileP('"type":"ai-title"', jsonlPath), grepFileP('"type":"pr-link"', jsonlPath), + grepFileP('"subtype":"away_summary"', jsonlPath), readTailUtf8(jsonlPath, 256 * 1024), ]); @@ -1893,12 +1915,32 @@ export const loadSessionEnrichment = async ( } catch {} } + if (recapOutput) { + try { + const parsed = JSON.parse(lastLine(recapOutput)); + // The line ends with a UI hint that is not part of the summary. + const text = String(parsed.content || '') + .replace(/\s*\(disable recaps in \/config\)\s*$/, '') + .trim(); + if (text) { + recaps.set(session.sessionId, { + text, + at: typeof parsed.timestamp === 'string' ? parsed.timestamp : '', + }); + } + } catch { + // A malformed recap line is skipped; the session keeps its + // previous recap, if any. + } + } + // Mark fresh ONLY when every read succeeded — a timed-out or failed // pass stays unrecorded so the next call retries it. if ( titleOutput !== null && aiTitleOutput !== null && prLinkOutput !== null && + recapOutput !== null && tailOutput !== null ) { enrichedFileState.set(session.sessionId, { mtimeMs, size }); @@ -1909,7 +1951,7 @@ export const loadSessionEnrichment = async ( enrichmentQueue = enrichmentQueue.then(scan, scan); await enrichmentQueue; - return { titles, branches, prLinks }; + return { titles, branches, prLinks, recaps }; }; /** diff --git a/src/electron-api.d.ts b/src/electron-api.d.ts index 61348e9..a2d1331 100644 --- a/src/electron-api.d.ts +++ b/src/electron-api.d.ts @@ -13,6 +13,28 @@ interface CodevAccountInfo { loggedIn?: boolean; } +/** One captured member of a saved session list (see src/session-lists.ts). */ +interface SessionListMemberRecord { + sessionId: string; + project: string; + projectName: string; + accountLabel?: string; + title?: string; + branch?: string; + pinned: boolean; + lastTimestamp: number; + recap?: { text: string; at: string }; + lastUserMessage?: string; + lastAssistantMessage?: string; +} + +interface SessionListRecord { + id: string; + name: string; + createdAt: string; + members: SessionListMemberRecord[]; +} + interface IElectronAPI { // App actions getHomeDir: () => Promise; @@ -149,6 +171,52 @@ interface IElectronAPI { onSessionMarksUpdated: (callback: IpcCallback) => () => void; getSessionsByIds: (ids: string[]) => Promise; + // Saved session lists (issue #145) + getSessionLists: () => Promise<{ + version: number; + lists: SessionListRecord[]; + /** False when the store exists but could not be read — the lists are unknown, not empty. */ + known: boolean; + /** Present when `known` is false: what the file holds, so the UI can say so. */ + inspection?: { + known: boolean; + parseable: boolean; + rawLists: number; + rawMembers: number; + keptLists: number; + keptMembers: number; + }; + }>; + saveSessionList: ( + name: string, + members: SessionListMemberRecord[], + ) => Promise<{ ok: boolean; error?: string; lists?: { lists: SessionListRecord[] }; list?: SessionListRecord }>; + deleteSessionList: (id: string) => Promise<{ ok: boolean; error?: string; lists?: { lists: SessionListRecord[] } }>; + renameSessionList: ( + id: string, + name: string, + ) => Promise<{ ok: boolean; error?: string; lists?: { lists: SessionListRecord[] } }>; + onSessionListsUpdated: (callback: IpcCallback) => () => void; + + // Live claude processes (issue #94) + getLiveSessions: () => Promise<{ + live: { + pid: number; + sessionId: string | null; + cwd: string | null; + rssKb: number; + tty: string | null; + uptimeSec: number; + registered: boolean; + entrypoint?: string; + accountLabel?: string; + accountIsAnchor?: boolean; + }[]; + staleRegistrations: { pid: number; sessionId: string; cwd: string }[]; + totalRssKb: number; + measuredAt: number; + }>; + // Claude Code sessions getClaudeSessions: (limit?: number) => Promise; searchClaudeSessions: (query: string) => Promise<{ @@ -170,7 +238,13 @@ interface IElectronAPI { launchNewClaudeSession: (projectPath: string, accountLabel?: string) => void; launchNewClaudeSessionInCodev: (projectPath: string) => void; copyClaudeSessionCommand: (sessionId: string, projectPath: string) => void; - loadSessionEnrichment: (sessions: any[]) => Promise<{ titles: Record; branches: Record; prLinks: Record }>; + loadSessionEnrichment: (sessions: any[]) => Promise<{ + titles: Record; + branches: Record; + prLinks: Record; + /** The transcript's `away_summary` recap line, when the session has one. */ + recaps: Record; + }>; loadLastAssistantResponses: (sessions: any[]) => Promise>; loadProjectBranches: (paths: string[]) => Promise>; detectActiveIDEProjects: () => Promise; diff --git a/src/live-sessions.test.ts b/src/live-sessions.test.ts new file mode 100644 index 0000000..ed4ee47 --- /dev/null +++ b/src/live-sessions.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it } from 'vitest'; + +import { + collectLiveSessions, + isSessionProcess, + joinLiveSessions, + parseEtime, + parsePsOutput, + sessionIdFromArgs, + SessionRegistration, +} from './live-sessions'; + +// Captured from `ps -Ao pid=,rss=,tty=,etime=,args=` on 2026-09-05, trimmed +// to the shapes that matter: sessions with a tty, the daemon family without +// one, a versioned binary path, and an unrelated process. +const PS_SAMPLE = ` + 19560 309248 ttys033 02:50:59 claude -r + 33624 292864 ttys052 17-05:40:45 claude -r + 22290 202752 ttys041 02-12:14:50 /Users/g/.local/share/claude/versions/2.1.260 --session-id b7f7c407-1557-4a74-9d4e-36d5db401157 --fork-session + 67810 130048 ttys055 10-06:28:52 claude --resume f339c186-ba82-4362-a901-2938323c0198 + 99578 112640 ttys017 02-12:36:52 claude -r + 93499 131072 ttys034 01:25:04 claude -n branch-test-a + 22140 27648 ?? 02:44:24 /Users/g/.local/bin/claude daemon run --origin transient --spawn + 22197 16384 ?? 02:44:47 claude bg-pty-host --bg-pty-host /tmp/cc-daemon-501/x/spare/59423203 + 22211 33792 ?? 02:44:46 claude bg-spare --bg-spare /tmp/cc-daemon-501/x/spare/59423203.cla + 22198 16384 ?? 03:13:44 /Users/g/.local/share/claude/ClaudeCode.app/Contents/MacOS/claude --helper + 44444 100000 ?? 01:00:00 claude --session-id 11111111-2222-4333-8444-555555555555 + 1447 376832 ?? 03-01:02:03 /Applications/iTerm.app/Contents/MacOS/iTerm2 +`; + +const reg = ( + pid: number, + sessionId: string, + over: Partial = {}, +): SessionRegistration => ({ + pid, + sessionId, + cwd: `/Users/g/git/${sessionId.slice(0, 4)}`, + entrypoint: 'cli', + accountLabel: 'main', + accountDir: '/Users/g/.claude', + ...over, +}); + +describe('parseEtime', () => { + it('reads every `ps -o etime` shape', () => { + expect(parseEtime('02:50:59')).toBe(2 * 3600 + 50 * 60 + 59); + expect(parseEtime('17-05:40:45')).toBe( + 17 * 86400 + 5 * 3600 + 40 * 60 + 45, + ); + expect(parseEtime('04:39')).toBe(4 * 60 + 39); + expect(parseEtime('garbage')).toBe(0); + }); +}); + +describe('parsePsOutput', () => { + it('parses pid / rss / tty / etime / args and maps ?? to null', () => { + const procs = parsePsOutput(PS_SAMPLE); + expect(procs.length).toBe(12); + const first = procs[0]; + expect(first).toMatchObject({ + pid: 19560, + rssKb: 309248, + tty: 'ttys033', + args: 'claude -r', + }); + expect(procs.find((p) => p.pid === 22140)?.tty).toBeNull(); + // Args keep everything after the fixed columns, including the long path. + expect(procs.find((p) => p.pid === 22290)?.args).toMatch( + /^\/Users\/g\/.*--fork-session$/, + ); + }); +}); + +const mustFind = (items: T[], pred: (t: T) => boolean, what: string): T => { + const hit = items.find(pred); + if (!hit) throw new Error(`fixture is missing ${what}`); + return hit; +}; + +describe('isSessionProcess', () => { + const by = (pid: number) => + mustFind(parsePsOutput(PS_SAMPLE), (p) => p.pid === pid, `pid ${pid}`); + + it('accepts bare, -r, --resume, -n and the versioned binary', () => { + for (const pid of [19560, 33624, 22290, 67810, 93499, 44444]) { + expect(isSessionProcess(by(pid))).toBe(true); + } + }); + + it('rejects the daemon family, the app helper, and non-claude processes', () => { + for (const pid of [22140, 22197, 22211, 22198, 1447]) { + expect(isSessionProcess(by(pid))).toBe(false); + } + }); + + it('rejects flag-style one-shots that run in a terminal but open no session', () => { + const procs = parsePsOutput( + [ + ' 1 1 ttys001 00:01 claude --version', + ' 2 1 ttys001 00:01 claude -v', + ' 3 1 ttys001 00:01 claude --help', + ' 4 1 ttys001 00:01 claude --mcp-serve', + ' 5 1 ttys001 00:01 claude --resume f339c186-ba82-4362-a901-2938323c0198', + ' 6 1 ttys001 00:01 claude -n branch-test-a', + ' 7 1 ttys001 00:01 claude -p summarize this', + ' 8 1 ttys001 00:01 claude --print --output-format json', + // One-shot flags after other options are still one-shots… + ' 9 1 ttys001 00:01 claude -c -p query', + ' 10 1 ttys001 00:01 claude --model opus -p query', + ' 11 1 ttys001 00:01 claude -r -p query', + // …including after a value that `ps` split on its space (quoting is gone), + ' 12 1 ttys001 00:01 claude --add-dir "/tmp/my dir" -p query', + ' 13 1 ttys001 00:01 claude --max-turns 3 -p query', + // …while interactive invocations with the same leading options stay sessions, + ' 14 1 ttys001 00:01 claude -c', + ' 15 1 ttys001 00:01 claude --model opus --resume f339c186-ba82-4362-a901-2938323c0198', + // and a prompt that talks about print mode without the exact flag token is one too. + ' 16 1 ttys001 00:01 claude explain the print flag', + ].join('\n'), + ); + expect(procs.map((p) => isSessionProcess(p))).toEqual([ + false, + false, + false, + false, + true, + true, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + ]); + }); +}); + +describe('sessionIdFromArgs', () => { + it('finds --resume, -r and --session-id, ignores bare -r', () => { + expect( + sessionIdFromArgs('claude --resume f339c186-ba82-4362-a901-2938323c0198'), + ).toBe('f339c186-ba82-4362-a901-2938323c0198'); + expect( + sessionIdFromArgs('claude -r f339c186-ba82-4362-a901-2938323c0198'), + ).toBe('f339c186-ba82-4362-a901-2938323c0198'); + expect( + sessionIdFromArgs( + '/x/2.1.260 --session-id b7f7c407-1557-4a74-9d4e-36d5db401157', + ), + ).toBe('b7f7c407-1557-4a74-9d4e-36d5db401157'); + expect(sessionIdFromArgs('claude -r')).toBeNull(); + }); +}); + +describe('joinLiveSessions', () => { + const procs = parsePsOutput(PS_SAMPLE); + const regs = [ + reg(19560, 'aaaa0000-0000-4000-8000-000000000001'), + reg(33624, 'bbbb0000-0000-4000-8000-000000000002'), + reg(22290, 'b7f7c407-1557-4a74-9d4e-36d5db401157'), + reg(67810, 'f339c186-ba82-4362-a901-2938323c0198'), + reg(93499, 'cccc0000-0000-4000-8000-000000000003'), + // VS Code: registered, no tty — must still count as live. + reg(44444, '11111111-2222-4333-8444-555555555555', { + entrypoint: 'claude-vscode', + }), + // Registered but the process is gone. + reg(55555, 'dead0000-0000-4000-8000-000000000009'), + ]; + const report = joinLiveSessions(procs, regs, 1_000); + + it('counts registered sessions and tty-attached orphans, not the daemon family', () => { + const pids = report.live.map((s) => s.pid).sort((a, b) => a - b); + expect(pids).toEqual([19560, 22290, 33624, 44444, 67810, 93499, 99578]); + }); + + it('marks the orphan as unregistered and still recovers an id from the arguments when present', () => { + const orphan = mustFind(report.live, (s) => s.pid === 99578, 'the orphan'); + expect(orphan.registered).toBe(false); + expect(orphan.sessionId).toBeNull(); // bare `claude -r` names nothing + expect(orphan.cwd).toBeNull(); + // Had it been `--resume `, the id would come from the args instead. + const alt = joinLiveSessions( + parsePsOutput( + ' 1 1 ttys001 00:01 claude --resume f339c186-ba82-4362-a901-2938323c0198', + ), + [], + ); + expect(alt.live[0]).toMatchObject({ + registered: false, + sessionId: 'f339c186-ba82-4362-a901-2938323c0198', + }); + }); + + it('reports a registration whose process is dead as stale, not as live', () => { + expect(report.staleRegistrations).toEqual([ + { + pid: 55555, + sessionId: 'dead0000-0000-4000-8000-000000000009', + cwd: '/Users/g/git/dead', + }, + ]); + expect(report.live.some((s) => s.pid === 55555)).toBe(false); + }); + + it('carries registration fields through and sums memory over live sessions only', () => { + const vs = mustFind(report.live, (s) => s.pid === 44444, 'the VS Code row'); + expect(vs).toMatchObject({ + entrypoint: 'claude-vscode', + accountLabel: 'main', + tty: null, + }); + const expected = [ + 309248, 292864, 202752, 130048, 112640, 131072, 100000, + ].reduce((a, b) => a + b, 0); + expect(report.totalRssKb).toBe(expected); + expect(report.measuredAt).toBe(1_000); + // Heaviest first — that is the question this view answers. + expect(report.live[0].pid).toBe(19560); + }); +}); + +describe('readSessionRegistrations (via collectLiveSessions deps)', () => { + it('passes the anchor flag through so a synthetic row can hide the account badge', async () => { + const report = await collectLiveSessions({ + ps: async () => ' 19560 1 ttys033 00:01 claude -r', + readRegistrations: () => [ + reg(19560, 'aaaa0000-0000-4000-8000-000000000001', { + accountIsAnchor: true, + }), + ], + cwdOf: async () => null, + }); + expect(report.live[0]).toMatchObject({ + accountLabel: 'main', + accountIsAnchor: true, + }); + }); +}); + +describe('collectLiveSessions', () => { + it('asks lsof for a cwd only for unregistered sessions that lack one', async () => { + const asked: number[] = []; + const report = await collectLiveSessions({ + ps: async () => PS_SAMPLE, + readRegistrations: () => [ + reg(19560, 'aaaa0000-0000-4000-8000-000000000001'), + ], + cwdOf: async (pid) => { + asked.push(pid); + return `/cwd/of/${pid}`; + }, + }); + // Every tty-attached session except the registered one is unregistered here. + expect(asked.sort((a, b) => a - b)).toEqual([ + 22290, 33624, 67810, 93499, 99578, + ]); + expect(report.live.find((s) => s.pid === 99578)?.cwd).toBe('/cwd/of/99578'); + expect(report.live.find((s) => s.pid === 19560)?.cwd).toBe( + '/Users/g/git/aaaa', + ); + }); +}); diff --git a/src/live-sessions.ts b/src/live-sessions.ts new file mode 100644 index 0000000..6becac8 --- /dev/null +++ b/src/live-sessions.ts @@ -0,0 +1,343 @@ +/** + * What is actually running, and what it costs (issue #94; the LIVE half of + * the saved-lists screen, issue #145). + * + * Two sources that do not agree, joined: + * + * - `ps` — every `claude` process the OS knows about, with memory, tty and + * uptime. Ground truth for "is it running", knows nothing about sessions. + * - `~/.claude/sessions/.json` — Claude Code's own pid → sessionId + * registration. Knows the session, but is written at start and cleaned up + * on a best-effort basis, so it can be stale (pid dead) or missing (pid + * alive, no file). Measured 2026-09-05 on 33 real sessions: one of each. + * + * Saving "what is open" straight from the registration would therefore omit + * one session and store one ghost — which is why this join exists rather + * than a filter over the registrations. The pure pieces (`parsePsOutput`, + * `isSessionProcess`, `joinLiveSessions`) are unit-tested against captured + * output; `collectLiveSessions` wires them to the OS. + */ + +import { execFile } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { getScannableAccounts } from './accounts'; + +export interface ClaudeProcess { + pid: number; + /** Resident set size in KB, as `ps` reports it on macOS. */ + rssKb: number; + /** `null` when `ps` prints `??` — no controlling terminal. */ + tty: string | null; + uptimeSec: number; + args: string; +} + +export interface SessionRegistration { + pid: number; + sessionId: string; + cwd: string; + entrypoint: string; + accountLabel: string; + accountDir: string; + /** The anchor (~/.claude) account shows no account badge. */ + accountIsAnchor?: boolean; +} + +export interface LiveSession { + pid: number; + /** Null for a running process no registration or argument identifies. */ + sessionId: string | null; + cwd: string | null; + rssKb: number; + tty: string | null; + uptimeSec: number; + /** False when found only by `ps` — invisible to every registration-based view. */ + registered: boolean; + entrypoint?: string; + accountLabel?: string; + accountIsAnchor?: boolean; +} + +export interface LiveSessionsReport { + live: LiveSession[]; + /** Registered pids whose process is gone — a stale file, shown as a ghost by anything that trusts it. */ + staleRegistrations: { pid: number; sessionId: string; cwd: string }[]; + totalRssKb: number; + measuredAt: number; +} + +/** `[[dd-]hh:]mm:ss` as `ps -o etime` prints it. */ +export const parseEtime = (s: string): number => { + const m = s.trim().match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/); + if (!m) return 0; + const [, d, h, min, sec] = m; + return ( + (d ? Number(d) * 86400 : 0) + + (h ? Number(h) * 3600 : 0) + + Number(min) * 60 + + Number(sec) + ); +}; + +/** Output of `ps -Ao pid=,rss=,tty=,etime=,args=`, one process per line. */ +export const parsePsOutput = (out: string): ClaudeProcess[] => { + const procs: ClaudeProcess[] = []; + for (const line of out.split('\n')) { + const m = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.*)$/); + if (!m) continue; + procs.push({ + pid: Number(m[1]), + rssKb: Number(m[2]), + tty: m[3] === '??' ? null : m[3], + uptimeSec: parseEtime(m[4]), + args: m[5].trim(), + }); + } + return procs; +}; + +/** + * Subcommands that run under the `claude` binary but are not a session: + * the background daemon and its pty hosts / spares, MCP serving, installers. + * Measured 2026-09-05: four of the five "unregistered claude processes" were + * these, which is how a count of orphans came out as 5 instead of 1. + */ +const NON_SESSION_SUBCOMMANDS = new Set([ + 'daemon', + 'bg-pty-host', + 'bg-spare', + 'mcp', + 'update', + 'doctor', + 'install', + 'plugin', + 'plugins', + 'auth', + 'login', + 'logout', + 'config', + 'setup-token', + 'migrate-installer', + 'agents', +]); + +/** + * Flag-style invocations that run the `claude` binary without opening a + * session: version / help / updater / MCP serving / the app helper. They + * run in a terminal (so they have a tty) and exit within seconds, but a + * `ps` taken in that window would otherwise count them as live. + */ +const NON_SESSION_FLAGS = new Set([ + // Print mode: a non-interactive one-shot, not something to list or save. + '--print', + '-p', + '--version', + '-v', + '--help', + '-h', + '--update', + '--install', + '--mcp-serve', + '--helper', +]); + +const isClaudeBinary = (token: string): boolean => + path.basename(token) === 'claude' || + /\/share\/claude\/versions\/[^/]+$/.test(token) || + /ClaudeCode\.app\/Contents\/MacOS\/claude$/.test(token); + +/** + * A `claude` process that is (or could be) an interactive session. + * + * A one-shot flag ANYWHERE on the command line makes it a one-shot. An + * earlier version walked only the leading options and stopped at the first + * positional, which needed a list of every value-taking flag to stay right + * and still broke on `ps` output, which drops shell quoting — `--add-dir + * "/tmp/my dir" -p query` tokenises with the path split in two, and the + * walk stopped before ever seeing `-p`. Scanning every token has one known + * cost instead: an interactive session launched with an inline prompt that + * contains a token exactly equal to a flag (`claude "explain -p"`) is + * hidden from the live scope while it runs. That is rare, temporary, and + * visible; a one-shot leaking into a saved list is a stale member forever. + */ +export const isSessionProcess = (p: ClaudeProcess): boolean => { + const tokens = p.args.split(/\s+/); + if (!tokens[0] || !isClaudeBinary(tokens[0])) return false; + const first = tokens[1]; + if (first && !first.startsWith('-') && NON_SESSION_SUBCOMMANDS.has(first)) { + return false; + } + return !tokens.slice(1).some((t) => NON_SESSION_FLAGS.has(t)); +}; + +/** `--resume ` / `-r ` / `--session-id ` on the command line. */ +export const sessionIdFromArgs = (args: string): string | null => { + const m = args.match( + /(?:--resume|-r|--session-id)\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i, + ); + return m ? m[1] : null; +}; + +/** + * Join `ps` against the registrations. + * + * A process counts as live when it is a session process AND is either + * registered or attached to a terminal. The tty requirement is what keeps the + * daemon's helpers out: they are `claude` binaries too, but nothing a person + * is typing into. VS Code sessions have no tty but are always registered. + */ +export const joinLiveSessions = ( + procs: ClaudeProcess[], + regs: SessionRegistration[], + now = Date.now(), +): LiveSessionsReport => { + const byPid = new Map(); + for (const p of procs) byPid.set(p.pid, p); + const regByPid = new Map(); + for (const r of regs) regByPid.set(r.pid, r); + + const live: LiveSession[] = []; + for (const p of procs) { + if (!isSessionProcess(p)) continue; + const reg = regByPid.get(p.pid); + if (!reg && p.tty === null) continue; + live.push({ + pid: p.pid, + sessionId: reg?.sessionId ?? sessionIdFromArgs(p.args), + cwd: reg?.cwd ?? null, + rssKb: p.rssKb, + tty: p.tty, + uptimeSec: p.uptimeSec, + registered: !!reg, + entrypoint: reg?.entrypoint, + accountLabel: reg?.accountLabel, + accountIsAnchor: reg?.accountIsAnchor, + }); + } + live.sort((a, b) => b.rssKb - a.rssKb); + + const staleRegistrations = regs + .filter((r) => !byPid.has(r.pid)) + .map((r) => ({ pid: r.pid, sessionId: r.sessionId, cwd: r.cwd })); + + return { + live, + staleRegistrations, + totalRssKb: live.reduce((sum, s) => sum + s.rssKb, 0), + measuredAt: now, + }; +}; + +/** Every account's `sessions/.json`, as written by Claude Code. */ +export const readSessionRegistrations = (): SessionRegistration[] => { + const regs: SessionRegistration[] = []; + for (const account of getScannableAccounts()) { + const dir = path.join(account.dir, 'sessions'); + let files: string[]; + try { + files = fs.readdirSync(dir).filter((f) => /^\d+\.json$/.test(f)); + } catch { + continue; + } + for (const file of files) { + try { + const data = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf-8')); + if ( + typeof data?.pid !== 'number' || + typeof data?.sessionId !== 'string' + ) { + continue; + } + // The filename is the pid Claude Code keys the registration by. A + // body that names a different pid is a file that does not describe + // the process it is filed under — skip it rather than attribute a + // live process to the wrong session. + if (data.pid !== Number(file.slice(0, -5))) continue; + regs.push({ + pid: data.pid, + sessionId: data.sessionId, + cwd: typeof data.cwd === 'string' ? data.cwd : '', + entrypoint: + typeof data.entrypoint === 'string' ? data.entrypoint : 'cli', + accountLabel: account.label, + accountDir: account.dir, + accountIsAnchor: account.isAnchor, + }); + } catch { + // one unreadable registration must not hide the others + } + } + } + return regs; +}; + +const execFileP = ( + file: string, + args: string[], + timeout: number, +): Promise => + new Promise((resolve) => { + execFile( + file, + args, + { encoding: 'utf-8', timeout, maxBuffer: 4 * 1024 * 1024 }, + (err: unknown, stdout: string) => resolve(err ? '' : stdout || ''), + ); + }); + +/** + * `ps -A` on a machine deep in swap took 250–500ms in measurement and can + * exceed a short timeout right after the app returns from the background — + * and a timed-out `ps` looks exactly like "no processes at all", which the + * join would then report as every session dead and every registration stale + * (seen live as "● 0 live ⚠33"). Generous timeout, and an empty result is + * treated as a failure, never as an answer. + */ +const PS_TIMEOUT_MS = 15000; + +/** Working directory of one process, for the unregistered ones only. */ +const lsofCwd = async (pid: number): Promise => { + const out = await execFileP( + 'lsof', + ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], + 2000, + ); + const m = out.match(/^n(.+)$/m); + return m ? m[1] : null; +}; + +export interface CollectDeps { + ps?: () => Promise; + readRegistrations?: () => SessionRegistration[]; + cwdOf?: (pid: number) => Promise; +} + +export const collectLiveSessions = async ( + deps: CollectDeps = {}, +): Promise => { + const ps = + deps.ps ?? + (() => + execFileP('ps', ['-Ao', 'pid=,rss=,tty=,etime=,args='], PS_TIMEOUT_MS)); + const readRegs = deps.readRegistrations ?? readSessionRegistrations; + const cwdOf = deps.cwdOf ?? lsofCwd; + + const procs = parsePsOutput(await ps()); + // A process table is never empty — `ps` lists at least itself — so an empty + // parse means the call failed or timed out. Report that rather than a + // fabricated "nothing is running". + if (procs.length === 0) throw new Error('ps returned no processes'); + const report = joinLiveSessions(procs, readRegs()); + // `lsof` costs a spawn per process, so only the unregistered ones pay it — + // a handful at most, and without a cwd their row would name nothing. + await Promise.all( + report.live + .filter((s) => !s.registered && !s.cwd) + .map(async (s) => { + s.cwd = await cwdOf(s.pid); + }), + ); + return report; +}; diff --git a/src/main.ts b/src/main.ts index 2a7088e..e40bb84 100644 --- a/src/main.ts +++ b/src/main.ts @@ -41,6 +41,18 @@ import { withoutPin, withPin, } from './session-marks'; +import { + inspectSessionLists, + mutateSessionLists, + normalizeList, + readSessionListsResult, + watchSessionLists, + withList, + withoutList, + withRenamedList, +} from './session-lists'; +import { collectLiveSessions } from './live-sessions'; +import { randomUUID } from 'crypto'; import { installHooks, removeHooks, @@ -2346,42 +2358,65 @@ ipcMain.handle('get-sessions-by-ids', (_event, ids: string[]) => { ); }); -// Session pin/hide marks (session-finding Batch 1 PR-2) -let marksWatcherCleanup: (() => void) | null = null; -let marksWatcherRetries = 0; -let marksWatcherRetryTimer: ReturnType | null = null; -const ensureMarksWatcher = () => { - if (marksWatcherCleanup) return; - if (marksWatcherRetryTimer) { - clearTimeout(marksWatcherRetryTimer); - marksWatcherRetryTimer = null; - } - marksWatcherCleanup = watchSessionMarks( - (marks) => { - marksWatcherRetries = 0; - if (switcherWindow && !switcherWindow.isDestroyed()) { - switcherWindow.webContents.send('session-marks-updated', marks); - } - }, - (err) => { - // Watcher died (dir removed, OS watcher limits) — recreate with a - // bounded backoff instead of silently staying deaf until restart. - marksWatcherCleanup = null; - if (marksWatcherRetries < 5) { - marksWatcherRetries += 1; - console.error( - `[session-marks] watcher died (${err?.message || err}); retry ${marksWatcherRetries}/5 in 2s`, - ); - marksWatcherRetryTimer = setTimeout(ensureMarksWatcher, 2000); - } else { - console.error( - '[session-marks] watcher died and retries exhausted — marks pushes disabled until restart', - ); - } - }, - ); +// A user-level store watched on disk and pushed to the renderer. One factory +// for the marks store and the saved-lists store: the recreate-with-backoff +// logic must behave identically for both, and two hand-written copies of it +// would not stay identical. +const makeStoreWatcher = ( + tag: string, + channel: string, + watch: (onChange: (value: T) => void, onError: (err: Error) => void) => () => void, +): (() => void) => { + let cleanup: (() => void) | null = null; + let retries = 0; + let retryTimer: ReturnType | null = null; + const ensure = () => { + if (cleanup) return; + if (retryTimer) { + clearTimeout(retryTimer); + retryTimer = null; + } + cleanup = watch( + (value) => { + retries = 0; + if (switcherWindow && !switcherWindow.isDestroyed()) { + switcherWindow.webContents.send(channel, value); + } + }, + (err) => { + // Watcher died (dir removed, OS watcher limits) — recreate with a + // bounded backoff instead of silently staying deaf until restart. + cleanup = null; + if (retries < 5) { + retries += 1; + console.error( + `[${tag}] watcher died (${err?.message || err}); retry ${retries}/5 in 2s`, + ); + retryTimer = setTimeout(ensure, 2000); + } else { + console.error( + `[${tag}] watcher died and retries exhausted — pushes disabled until restart`, + ); + } + }, + ); + }; + return ensure; }; +// Session pin/hide marks (session-finding Batch 1 PR-2) +const ensureMarksWatcher = makeStoreWatcher( + 'session-marks', + 'session-marks-updated', + watchSessionMarks, +); +// Saved session lists (issue #145) +const ensureListsWatcher = makeStoreWatcher( + 'session-lists', + 'session-lists-updated', + watchSessionLists, +); + ipcMain.handle('get-session-marks', () => { ensureMarksWatcher(); // `known` travels with the marks: an unreadable store yields empty marks @@ -2464,6 +2499,80 @@ ipcMain.handle('unhide-session', (_event, sessionId: string) => { return { ok: true, marks }; }); +// --- Saved session lists (issue #145) --- + +ipcMain.handle('get-session-lists', () => { + ensureListsWatcher(); + const read = readSessionListsResult(); + // An unknown read travels with what the file holds, so the UI can say why + // the list is empty — and that the data is still there — instead of + // showing nothing. + return { + ...read.value, + known: read.known, + inspection: read.known ? undefined : inspectSessionLists(), + }; +}); + +ipcMain.handle('save-session-list', (_event, name: unknown, members: unknown) => { + // One coercion definition: build the record, then run it through the + // store's own normalizer, so what gets written is exactly what a later + // read will accept as authoritative. + const list = normalizeList({ + id: randomUUID(), + name, + createdAt: new Date().toISOString(), + members: Array.isArray(members) ? members : [], + }); + if (!list || list.members.length === 0) { + return { ok: false, error: 'nothing to save' }; + } + const res = mutateSessionLists((prev) => withList(prev, list)); + if (!res.known) { + // Same guard as the marks store: writing over an unreadable file would + // replace every other saved list with this one. + console.warn('[session-lists] save refused: store unreadable', list.name); + return { ok: false, error: 'lists store unreadable' }; + } + console.log('[session-lists] save', list.id, JSON.stringify(list.name), 'members:', list.members.length); + return { ok: true, lists: res.value, list }; +}); + +ipcMain.handle('delete-session-list', (_event, id: string) => { + if (!id || typeof id !== 'string') return { ok: false, error: 'invalid id' }; + const res = mutateSessionLists((prev) => withoutList(prev, id)); + if (!res.known) { + console.warn('[session-lists] delete refused: store unreadable', id); + return { ok: false, error: 'lists store unreadable' }; + } + console.log('[session-lists] delete', id, 'lists:', res.value.lists.length); + return { ok: true, lists: res.value }; +}); + +ipcMain.handle('rename-session-list', (_event, id: string, name: string) => { + if (!id || typeof id !== 'string') return { ok: false, error: 'invalid id' }; + if (typeof name !== 'string' || !name.trim()) return { ok: false, error: 'invalid name' }; + const res = mutateSessionLists((prev) => withRenamedList(prev, id, name)); + if (!res.known) { + console.warn('[session-lists] rename refused: store unreadable', id); + return { ok: false, error: 'lists store unreadable' }; + } + return { ok: true, lists: res.value }; +}); + +// --- Live claude processes (issue #94) --- + +ipcMain.handle('get-live-sessions', async () => { + try { + return await collectLiveSessions(); + } catch (err) { + // null, not an empty report: "we could not look" must never render as + // "nothing is running" — the renderer keeps its last good report. + console.error('[live-sessions] collect failed:', err); + return null; + } +}); + ipcMain.handle('detect-active-sessions', async () => { const { activeMap, vscodeSessions, entrypoints } = await detectActiveSessions(); return { @@ -2520,11 +2629,12 @@ ipcMain.on('copy-claude-session-command', (_event, sessionId: string, projectPat }); ipcMain.handle('load-session-enrichment', async (_event, sessions: any[]) => { - const { titles, branches, prLinks } = await loadSessionEnrichment(sessions); + const { titles, branches, prLinks, recaps } = await loadSessionEnrichment(sessions); return { titles: Object.fromEntries(titles), branches: Object.fromEntries(branches), prLinks: Object.fromEntries(prLinks), + recaps: Object.fromEntries(recaps), }; }); diff --git a/src/preload.ts b/src/preload.ts index a839b41..aea6b1d 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -89,6 +89,19 @@ contextBridge.exposeInMainWorld('electronAPI', { }, getSessionsByIds: (ids: string[]) => ipcRenderer.invoke('get-sessions-by-ids', ids), + // Saved session lists (issue #145) + live processes (issue #94) + getSessionLists: () => ipcRenderer.invoke('get-session-lists'), + saveSessionList: (name: string, members: any[]) => + ipcRenderer.invoke('save-session-list', name, members), + deleteSessionList: (id: string) => ipcRenderer.invoke('delete-session-list', id), + renameSessionList: (id: string, name: string) => + ipcRenderer.invoke('rename-session-list', id, name), + onSessionListsUpdated: (callback: any) => { + ipcRenderer.on('session-lists-updated', callback); + return () => ipcRenderer.removeListener('session-lists-updated', callback); + }, + getLiveSessions: () => ipcRenderer.invoke('get-live-sessions'), + // Claude Code session APIs getClaudeSessions: (limit?: number) => ipcRenderer.invoke('get-claude-sessions', limit), searchClaudeSessions: (query: string) => ipcRenderer.invoke('search-claude-sessions', query), diff --git a/src/session-list-view.test.ts b/src/session-list-view.test.ts index 324f4f7..2e8864c 100644 --- a/src/session-list-view.test.ts +++ b/src/session-list-view.test.ts @@ -168,6 +168,270 @@ describe('buildSessionListView — invariants across the whole matrix', () => { }); }); +describe('buildSessionListView — live scope', () => { + const active = { + sessionId: 'active', + lastTimestamp: 400, + messageCount: 3, + isActive: true, + }; + const orphan = { + sessionId: 'pid:4242', + projectName: 'orphan', + lastTimestamp: 0, + __liveOrphan: true, + }; + + it('keeps only running sessions, appends orphans, and never folds a running one', () => { + const v = build({ + // `junk` has 1 msg and no title — it would fold while browsing. + sessions: [recent, active, junk], + activePids: { junk: 77 }, + liveOnly: true, + liveOrphans: [orphan], + }); + expect(v.liveOnlyActive).toBe(true); + expect(ids(v.displayedSessions)).toEqual(['active', 'junk', 'pid:4242']); + expect(v.minorSessions).toEqual([]); + expect(v.canGroupPins).toBe(false); + }); + + it('never shows a synthetic row for a session a real row already covers', () => { + // The renderer synthesizes a row for a live id the list did not know; + // one render later the by-id fetch may have produced the real row. + // Same id AND same pid as the real row: the same process, twice. + const real = { ...active, activePid: 1 }; + const synthetic = { + sessionId: 'active', + lastTimestamp: 0, + __live: { pid: 1, rssKb: 1, tty: null, uptimeSec: 1, registered: true }, + }; + const v = build({ + sessions: [real, recent], + liveOnly: true, + liveOrphans: [synthetic, orphan], + }); + expect(ids(v.displayedSessions)).toEqual(['active', 'pid:4242']); + expect(v.displayedSessions[0].messageCount).toBe(3); // the real row won + }); + + it('keeps a synthetic row for a SECOND process on a session a real row shows', () => { + // Same id, different pid: a resumed copy, or a /branch parent and child. + // The chip counts both; the list must show both. + const real = { ...active, activePid: 1 }; + const second = { + sessionId: 'active', + lastTimestamp: 0, + activePid: 2, + __live: { + pid: 2, + rssKb: 1, + tty: 'ttys002', + uptimeSec: 1, + registered: true, + }, + __liveExtra: true, + }; + const v = build({ + sessions: [real], + liveOnly: true, + liveOrphans: [second], + }); + expect(v.displayedSessions.map((s) => s.activePid)).toEqual([1, 2]); + }); + + it('gives a timeline row its running state from the ps join, and the join pid wins over a stale map', () => { + // `middle` is not in the active map at all; `recent` is, but the map's + // pid is stale — the join says the session now runs under pid 9. + const v = build({ + sessions: [recent, middle], + activePids: { recent: 1 }, + liveOnly: true, + liveBySession: { + middle: { + pid: 5, + rssKb: 1, + tty: 'ttys005', + uptimeSec: 1, + registered: true, + }, + recent: { + pid: 9, + rssKb: 1, + tty: 'ttys009', + uptimeSec: 1, + registered: true, + }, + }, + }); + expect( + v.displayedSessions.map((s) => [s.sessionId, s.isActive, s.activePid]), + ).toEqual([ + ['recent', true, 9], + ['middle', true, 5], + ]); + // …and a synthetic row for the SAME process as such a row is dropped. + const dup = { + sessionId: 'middle', + lastTimestamp: 0, + __live: { + pid: 5, + rssKb: 1, + tty: 'ttys005', + uptimeSec: 1, + registered: true, + }, + }; + const w = build({ + sessions: [middle], + liveOnly: true, + liveBySession: { middle: dup.__live }, + liveOrphans: [dup], + }); + expect(ids(w.displayedSessions)).toEqual(['middle']); + }); + + it('does not fold junk under a saved list', () => { + // `junk` is in the browse list and would fold; the list scope renders + // members, so the fold must be empty there. + const v = build({ + viewingList: { + id: 'L', + name: 'l', + createdAt: '2026-09-05T00:00:00Z', + members: [], + }, + }); + expect(v.listViewActive).toBe(true); + expect(v.minorSessions).toEqual([]); + expect(v.displayedSessions).toEqual([]); + }); + + it('recognises liveness from the live report, not only from the active map', () => { + const v = build({ + sessions: [recent, middle], + liveOnly: true, + liveBySession: { + middle: { + pid: 1, + rssKb: 1, + tty: 'ttys001', + uptimeSec: 1, + registered: false, + }, + }, + }); + expect(ids(v.displayedSessions)).toEqual(['middle']); + }); + + it('drops orphans while searching and outranks the pinned scope', () => { + const v = build({ + sessions: [active, middle], + pins: { middle: at('2026-01-01T00:00:00Z') }, + pinnedOnly: true, + liveOnly: true, + liveOrphans: [orphan], + isSearching: true, + }); + expect(v.pinnedOnlyActive).toBe(false); + expect(ids(v.displayedSessions)).toEqual(['active']); + }); +}); + +describe('buildSessionListView — saved-list scope', () => { + const memberOf = (sessionId: string, over: Record = {}) => ({ + sessionId, + project: '/p/' + sessionId, + projectName: sessionId + '-proj', + pinned: false, + lastTimestamp: 1, + ...over, + }); + const list = { + id: 'L1', + name: 'tuesday', + createdAt: '2026-09-05T00:00:00Z', + // Captured order is deliberately NOT recency order. + members: [ + memberOf('middle'), + memberOf('gone', { lastUserMessage: 'last words' }), + memberOf('old'), + ], + }; + + it('renders members in captured order, resolving rows from the list, the by-id fetch, or the capture itself', () => { + const v = build({ + viewingList: list, + extraListSessions: [outOfWindowPin], + }); + expect(v.listViewActive).toBe(true); + expect(ids(v.displayedSessions)).toEqual(['middle', 'gone', 'old']); + const [m, g, o] = v.displayedSessions; + expect(m.messageCount).toBe(20); // the loaded row + expect(o.messageCount).toBe(90); // the by-id row + // The placeholder carries what was captured, with no fake message count. + expect(g.messageCount).toBeUndefined(); + expect(g.projectName).toBe('gone-proj'); + expect(g.lastUserMessage).toBe('last words'); + expect(g.__listMember?.sessionId).toBe('gone'); + }); + + it('narrows to the matched members while searching but keeps captured order', () => { + const v = build({ + viewingList: list, + sessions: [outOfWindowPin, middle], // as if the query matched these two + isSearching: true, + }); + expect(ids(v.displayedSessions)).toEqual(['middle', 'old']); + }); + + it('outranks both the live and the pinned scope', () => { + const v = build({ + viewingList: list, + liveOnly: true, + pinnedOnly: true, + pins: { recent: at('2026-01-01T00:00:00Z') }, + }); + expect(v.liveOnlyActive).toBe(false); + expect(v.pinnedOnlyActive).toBe(false); + expect(ids(v.displayedSessions)).toEqual(['middle', 'gone', 'old']); + }); + + it('marks a member as active from the active map even for a placeholder', () => { + const v = build({ viewingList: list, activePids: { gone: 99 } }); + const g = v.displayedSessions.find((s) => s.sessionId === 'gone'); + expect(g?.isActive).toBe(true); + expect(g?.activePid).toBe(99); + }); + + it('marks a member as active from the ps join when the active map missed it', () => { + // A running session with no history row is invisible to the + // registration-based detection; a click on it would RESUME (a second + // process) instead of switching. The live report knows better. + const live = { + pid: 4242, + rssKb: 1, + tty: 'ttys009', + uptimeSec: 5, + registered: true, + }; + const v = build({ viewingList: list, liveBySession: { gone: live } }); + const g = v.displayedSessions.find((s) => s.sessionId === 'gone'); + expect(g?.isActive).toBe(true); + expect(g?.activePid).toBe(4242); + // Same rule for a pinned placeholder. + const p = build({ + pins: { ghost: at('2026-01-01T00:00:00Z') }, + liveBySession: { ghost: live }, + }); + expect(p.pinnedRows[0]).toMatchObject({ + sessionId: 'ghost', + isActive: true, + activePid: 4242, + }); + }); +}); + describe('mergeSessionsById', () => { it('appends only rows the primary list does not already have', () => { const merged = mergeSessionsById( diff --git a/src/session-list-view.ts b/src/session-list-view.ts index 121a7e5..3efc9ec 100644 --- a/src/session-list-view.ts +++ b/src/session-list-view.ts @@ -8,18 +8,27 @@ * it is exactly where the bugs have been (PR #136 needed five rounds of live * testing, all of them list/index interactions). * - * The three browse states are independent, which is what makes the matrix - * worth testing rather than eyeballing: + * The browse states are independent, which is what makes the matrix worth + * testing rather than eyeballing: * * - `isSearching` — search shows everything, ungrouped. * - `pinnedOnly` — scope: non-pinned rows drop out, search included. * - `pinnedCollapsed` — grouping: collapsed UNGROUPS (pins fall back to their * chronological slot with a ★) rather than hiding, so no combination of * states can make a pinned session invisible. + * - `liveOnly` — scope: only sessions with a running process (issue #94), + * plus rows for running processes no session explains. + * - `viewingList` — scope: the members of one saved list (issue #145), in the + * order they were captured, resolved to live rows where possible. + * + * Scopes are exclusive and ranked: a saved list beats live, live beats pins. + * The renderer turns the others off when it turns one on; ranking here is + * what keeps a stale flag from ever blanking the list. * * Used by: `switcher-ui.tsx`. */ +import type { SessionList, SessionListMember } from './session-lists'; import { isMinorSession } from './session-search'; /** @@ -44,6 +53,14 @@ export interface ListViewSession { /** Set on rows lifted out of the pin store (zone rows and placeholders). */ __pinnedRow?: boolean; __pinnedAt?: string; + /** Set on rows lifted out of a saved list: what was captured about them. */ + __listMember?: SessionListMember; + /** A running `claude` process that no session row explains (live scope only). */ + __liveOrphan?: boolean; + /** Process facts carried by a synthetic live row (its own process, not a lookup by id). */ + __live?: LiveRowInfo; + /** A second process on a session the list already shows (a resumed copy, a /branch pair). */ + __liveExtra?: boolean; [key: string]: unknown; } @@ -53,6 +70,15 @@ export interface PinRecord { accountLabel?: string; } +/** Process facts for one running session, keyed by sessionId in the renderer. */ +export interface LiveRowInfo { + pid: number; + rssKb: number; + tty: string | null; + uptimeSec: number; + registered: boolean; +} + export interface BuildListViewArgs { /** The list to render: the full timeline while browsing, results while searching. Recency-sorted. */ sessions: ListViewSession[]; @@ -73,6 +99,21 @@ export interface BuildListViewArgs { minorsExpanded: boolean; /** Folding waits for the first active-session detection (never fold a just-started session). */ activeDetectionReady: boolean; + /** Live scope (issue #94). Optional so existing callers and tests need no change. */ + liveOnly?: boolean; + /** sessionId -> process facts, from the live-sessions report. */ + liveBySession?: Record; + /** + * Rows synthesized for running processes that have no session row to carry + * them: no id at all (`__liveOrphan`, not resumable) or an id the loaded + * list does not know (resumable). The live scope must list every process + * the chip counted, so these are appended after the real rows. + */ + liveOrphans?: ListViewSession[]; + /** Saved-list scope (issue #145). */ + viewingList?: SessionList | null; + /** List members resolved by id because they fall outside `allSessions`. */ + extraListSessions?: ListViewSession[]; } export interface ListView { @@ -89,8 +130,10 @@ export interface ListView { /** How many folded rows got there by an explicit hide rather than the junk predicate. */ hiddenMinorCount: number; pinnedOnlyActive: boolean; + liveOnlyActive: boolean; + listViewActive: boolean; groupPinned: boolean; - /** Grouping is meaningless while searching or scoped to pins — the header hides its arrow. */ + /** Grouping is meaningless while searching or inside any scope — the header hides its arrow. */ canGroupPins: boolean; } @@ -120,6 +163,7 @@ const resolvePinnedRow = ( info: PinRecord, pinnedById: Map, activePids: Record, + liveBySession: Record, ): ListViewSession => { // A pin can be momentarily unresolvable (VS Code sessions are absent from // history.jsonl until the closed-scan merges them in) or permanently so @@ -143,8 +187,73 @@ const resolvePinnedRow = ( ...s, __pinnedRow: true, __pinnedAt: info.pinnedAt || '', - isActive: s.sessionId in activePids || s.isActive, - activePid: activePids[s.sessionId] ?? s.activePid, + ...activeFrom(s, activePids, liveBySession), + }; +}; + +/** + * Is this session running, and under which pid? Two sources: the + * registration-based detection (`activePids`) and the `ps` join + * (`liveBySession`). Either marks it running; for the PID the join wins, + * because it is the fresher of the two — the detection's pid map is cached + * and can name a process that has since been replaced by another on the + * same session, and switching to a dead pid fails. The join exists in the + * first place because the detection cannot see a running session that has + * no history row yet — a fresh `/branch` child before its first prompt — and + * a row wrongly marked inactive RESUMES on click, spawning a second process + * for the same session. Seen live. + */ +const activeFrom = ( + s: ListViewSession, + activePids: Record, + liveBySession: Record, +): { isActive: boolean; activePid: number | undefined } => ({ + isActive: + s.sessionId in activePids || s.sessionId in liveBySession || !!s.isActive, + activePid: + liveBySession[s.sessionId]?.pid ?? activePids[s.sessionId] ?? s.activePid, +}); + +/** Apply `activeFrom` to a timeline row, allocating only when it changes something. */ +const withLiveState = ( + s: ListViewSession, + activePids: Record, + liveBySession: Record, +): ListViewSession => { + if (!(s.sessionId in liveBySession) && !(s.sessionId in activePids)) return s; + const next = activeFrom(s, activePids, liveBySession); + if (next.isActive === !!s.isActive && next.activePid === s.activePid) + return s; + return { ...s, ...next }; +}; + +/** + * Resolve one saved-list member to a real row, or synthesize one from what + * was captured. Unlike a pin placeholder this one is rich: the list stored + * the project, the last messages and the recap precisely so a member whose + * transcript is gone still reads as the session it was. + */ +const resolveMemberRow = ( + member: SessionListMember, + byId: Map, + activePids: Record, + liveBySession: Record, +): ListViewSession => { + const s = byId.get(member.sessionId) ?? { + sessionId: member.sessionId, + project: member.project, + projectName: member.projectName, + firstUserMessage: '', + lastUserMessage: member.lastUserMessage || '', + lastTimestamp: member.lastTimestamp, + messageCount: undefined, + isActive: false, + accountLabel: member.accountLabel, + }; + return { + ...s, + __listMember: member, + ...activeFrom(s, activePids, liveBySession), }; }; @@ -162,26 +271,49 @@ export const buildSessionListView = ({ pinnedCollapsed, minorsExpanded, activeDetectionReady, + liveOnly = false, + liveBySession = {}, + liveOrphans = [], + viewingList = null, + extraListSessions = [], }: BuildListViewArgs): ListView => { const hiddenSet = new Set(hidden); const hasPins = Object.keys(pins).length > 0; - const pinnedOnlyActive = pinnedOnly && hasPins; - const groupPinned = !isSearching && !pinnedOnlyActive && !pinnedCollapsed; - const canGroupPins = !isSearching && !pinnedOnlyActive; + const listViewActive = !!viewingList; + const liveOnlyActive = !listViewActive && liveOnly; + const pinnedOnlyActive = + !listViewActive && !liveOnlyActive && pinnedOnly && hasPins; + const inScope = listViewActive || liveOnlyActive || pinnedOnlyActive; + const groupPinned = !isSearching && !inScope && !pinnedCollapsed; + const canGroupPins = !isSearching && !inScope; + const isLive = (s: ListViewSession) => + !!s.isActive || s.sessionId in activePids || s.sessionId in liveBySession; // C1: fold minor (junk) sessions while browsing; searching shows everything. // Minors keep their recency order but render below the fold row at the end. // A user-hidden session is forced into the fold regardless of its stats. const majorSessions: ListViewSession[] = []; const minorSessions: ListViewSession[] = []; - for (const s of sessions) { + for (const raw of sessions) { + // Timeline rows get the same running-state rule as pins and list + // members, so a row that is live only per the ps join shows its dot and + // switches (rather than resumes) on click. + const s = withLiveState(raw, activePids, liveBySession); const isPinned = !!pins[s.sessionId]; if (pinnedOnlyActive && !isPinned) continue; + if (liveOnlyActive && !isLive(s)) continue; // Lifted into the zone — no second copy in the timeline (user verdict: // the duplicate was more noise than signal). if (groupPinned && isPinned) continue; const minor = !isSearching && + // A running session is never junk, whatever its stats say — and a + // scope that asked for running sessions must show every one of them. + !liveOnlyActive && + // A saved list renders its members, not `sessions`; a fold computed + // from `sessions` would render under members it has nothing to do + // with, and hide the "this list is empty" message. + !listViewActive && // An ungrouped pin must never fold into the minor group: pinning is an // explicit "keep this", and a pinned session can still be a short // untitled one that the junk predicate would happily fold away. @@ -212,7 +344,9 @@ export const buildSessionListView = ({ } } const pinnedRows = Object.entries(pins) - .map(([id, info]) => resolvePinnedRow(id, info, pinnedById, activePids)) + .map(([id, info]) => + resolvePinnedRow(id, info, pinnedById, activePids, liveBySession), + ) // Recency first, like every other list in the app. The previous pinnedAt // ASC order (chosen so a new pin appended at the bottom instead of // reshuffling rows under the cursor) buried the session touched five @@ -238,22 +372,54 @@ export const buildSessionListView = ({ for (const s of majorSessions) timelineIds.add(s.sessionId); for (const s of minorSessions) timelineIds.add(s.sessionId); const ungroupedPins = - !groupPinned && !pinnedOnlyActive && !isSearching + !groupPinned && !inScope && !isSearching ? pinnedRows.filter((s) => !timelineIds.has(s.sessionId)) : []; const timelineRows = [...majorSessions, ...ungroupedPins]; - const displayedSessions = - pinnedOnlyActive && !isSearching - ? // Same reason: scope to the resolved pin set rather than filtering - // `sessions`, which would silently drop the out-of-window ones. - pinnedRows - : [ - ...visiblePinnedRows, - ...(minorsExpanded - ? [...timelineRows, ...minorSessions] - : timelineRows), - ]; + let displayedSessions: ListViewSession[]; + if (listViewActive && viewingList) { + // Captured order, not recency: a list is a snapshot, and reshuffling it + // by activity would hide what it was a snapshot OF. Searching narrows to + // the members the query matched (the renderer widens its candidates with + // the by-id rows, same as pins) but keeps the captured order. + const byId = new Map(); + for (const s of allSessions) byId.set(s.sessionId, s); + for (const s of extraListSessions) { + if (!byId.has(s.sessionId)) byId.set(s.sessionId, s); + } + const matched = isSearching + ? new Set(sessions.map((s) => s.sessionId)) + : null; + displayedSessions = viewingList.members + .filter((m) => !matched || matched.has(m.sessionId)) + .map((m) => resolveMemberRow(m, byId, activePids, liveBySession)); + } else if (liveOnlyActive) { + // Synthetic rows have nothing a query could match, so they step aside + // while searching rather than sitting under every result as noise. A + // synthetic row is dropped only when a real row already represents the + // SAME process — same id and same pid (or no pid at all): the renderer + // builds them from a snapshot that can lag the loaded list by one + // render. A second process on the same id is a different row and stays. + const shownPid = new Map(); + for (const s of majorSessions) shownPid.set(s.sessionId, s.activePid); + const covered = (s: ListViewSession) => + shownPid.has(s.sessionId) && + (s.__live?.pid === undefined || + s.__live.pid === shownPid.get(s.sessionId)); + displayedSessions = isSearching + ? majorSessions + : [...majorSessions, ...liveOrphans.filter((s) => !covered(s))]; + } else if (pinnedOnlyActive && !isSearching) { + // Same reason: scope to the resolved pin set rather than filtering + // `sessions`, which would silently drop the out-of-window ones. + displayedSessions = pinnedRows; + } else { + displayedSessions = [ + ...visiblePinnedRows, + ...(minorsExpanded ? [...timelineRows, ...minorSessions] : timelineRows), + ]; + } return { pinnedRows, @@ -264,6 +430,8 @@ export const buildSessionListView = ({ minorFoldHeaderIndex: visiblePinnedRows.length + timelineRows.length, hiddenMinorCount, pinnedOnlyActive, + liveOnlyActive, + listViewActive, groupPinned, canGroupPins, }; diff --git a/src/session-lists.test.ts b/src/session-lists.test.ts new file mode 100644 index 0000000..3e9d438 --- /dev/null +++ b/src/session-lists.test.ts @@ -0,0 +1,269 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { isAuthoritativeRead } from './atomic-json-store'; +import { + emptyLists, + inspectListsFile, + LIST_TEXT_CAPS, + mutateListsFile, + normalizeLists, + normalizeMember, + readListsFileResult, + SessionList, + withList, + withoutList, + withRenamedList, + writeListsFile, +} from './session-lists'; + +const member = (sessionId: string, over: Record = {}) => ({ + sessionId, + project: '/Users/x/git/proj', + projectName: 'proj', + pinned: false, + lastTimestamp: 1000, + ...over, +}); + +const list = (id: string, members: unknown[] = []): SessionList => ({ + id, + name: `list ${id}`, + createdAt: '2026-09-05T00:00:00.000Z', + members: members as SessionList['members'], +}); + +describe('normalizeMember', () => { + it('rejects anything without a sessionId', () => { + expect(normalizeMember(null)).toBeNull(); + expect(normalizeMember('x')).toBeNull(); + expect(normalizeMember({ project: '/a' })).toBeNull(); + expect(normalizeMember({ sessionId: '' })).toBeNull(); + }); + + it('caps every text field and drops empty optionals', () => { + const long = 'x'.repeat(2000); + const m = normalizeMember( + member('s1', { + title: long, + branch: long, + recap: { text: long, at: '2026-09-05T01:00:00Z' }, + lastUserMessage: long, + lastAssistantMessage: ' ', + }), + ); + expect(m?.title?.length).toBe(LIST_TEXT_CAPS.title); + expect(m?.branch?.length).toBe(LIST_TEXT_CAPS.title); + expect(m?.recap?.text.length).toBe(LIST_TEXT_CAPS.recap); + expect(m?.recap?.at).toBe('2026-09-05T01:00:00Z'); + expect(m?.lastUserMessage?.length).toBe(LIST_TEXT_CAPS.message); + // Whitespace-only is absent, not an empty string that renders as a line. + expect(m?.lastAssistantMessage).toBeUndefined(); + }); + + it('never cuts a character in half, and caps the derived projectName too', () => { + // 499 ASCII chars then an emoji (two UTF-16 units): a unit-based cap + // would keep only the high surrogate. + const emojiAtCap = 'a'.repeat(LIST_TEXT_CAPS.message - 1) + '🙂 and more'; + const m = normalizeMember(member('s1', { lastUserMessage: emojiAtCap })); + expect(m?.lastUserMessage?.endsWith('🙂')).toBe(true); + expect(Array.from(m?.lastUserMessage ?? '').length).toBe( + LIST_TEXT_CAPS.message, + ); + + const longDir = '/x/' + 'd'.repeat(LIST_TEXT_CAPS.title + 50); + const p = normalizeMember({ sessionId: 's2', project: longDir }); + expect(p?.projectName.length).toBe(LIST_TEXT_CAPS.title); + }); + + it('derives projectName from the path when it is missing', () => { + const m = normalizeMember({ + sessionId: 's1', + project: '/Users/x/git/fred-ff', + }); + expect(m?.projectName).toBe('fred-ff'); + expect(m?.pinned).toBe(false); + expect(m?.lastTimestamp).toBe(0); + }); +}); + +describe('normalizeLists', () => { + it('returns empty lists for garbage input', () => { + expect(normalizeLists(null)).toEqual(emptyLists()); + expect(normalizeLists('nope')).toEqual(emptyLists()); + expect(normalizeLists({ lists: 'x' })).toEqual(emptyLists()); + }); + + it('drops malformed lists, dedupes list ids and member ids', () => { + const raw = { + version: 1, + lists: [ + list('a', [member('s1'), member('s1'), 'junk', member('s2')]), + list('a'), + { name: 'no id' }, + list('b'), + ], + }; + const n = normalizeLists(raw); + expect(n.lists.map((l) => l.id)).toEqual(['a', 'b']); + expect(n.lists[0].members.map((m) => m.sessionId)).toEqual(['s1', 's2']); + }); + + it('is a no-op on a well-formed store (the authority invariant)', () => { + const raw = { + version: 1, + lists: [ + list('a', [ + member('s1', { title: 't', recap: { text: 'r', at: 'x' } }), + ]), + ], + }; + expect(isAuthoritativeRead(raw, normalizeLists(raw))).toBe(true); + }); + + it('is a fixed point of itself, even when a cap lands on whitespace', () => { + // A 500-char message whose 500th character is a space: the first pass + // caps it, and the result must survive a second pass byte-for-byte — + // otherwise the store the app just wrote is refused by the next write. + const onSpace = + 'a'.repeat(LIST_TEXT_CAPS.message - 1) + ' tail of the message'; + const raw = { + version: 1, + lists: [ + list('a', [ + member('s1', { + lastUserMessage: onSpace, + lastAssistantMessage: + 'x'.repeat(LIST_TEXT_CAPS.message - 3) + ' end', + title: 'y'.repeat(LIST_TEXT_CAPS.title - 1) + ' z', + }), + ]), + ], + }; + const once = normalizeLists(raw); + const twice = normalizeLists(JSON.parse(JSON.stringify(once))); + expect(isAuthoritativeRead(once, twice)).toBe(true); + expect(once.lists[0].members[0].lastUserMessage?.endsWith(' ')).toBe(false); + }); + + it('is NOT a no-op when it had to coerce — so such a read is not authoritative', () => { + // A member that normalization would rewrite (missing pinned, capped title). + const raw = { + version: 1, + lists: [list('a', [{ sessionId: 's1', title: 'x'.repeat(300) }])], + }; + expect(isAuthoritativeRead(raw, normalizeLists(raw))).toBe(false); + }); +}); + +describe('list transitions', () => { + it('withList puts the newest first and replaces by id', () => { + let s = withList(emptyLists(), list('a')); + s = withList(s, list('b')); + expect(s.lists.map((l) => l.id)).toEqual(['b', 'a']); + s = withList(s, { ...list('a'), name: 'renamed' }); + expect(s.lists.map((l) => l.id)).toEqual(['a', 'b']); + expect(s.lists[0].name).toBe('renamed'); + }); + + it('withoutList and withRenamedList leave other lists untouched', () => { + let s = withList(withList(emptyLists(), list('a')), list('b')); + s = withRenamedList(s, 'a', ' new name '); + expect(s.lists.find((l) => l.id === 'a')?.name).toBe('new name'); + expect(s.lists.find((l) => l.id === 'b')?.name).toBe('list b'); + // An empty rename keeps the old name rather than producing a blank one. + s = withRenamedList(s, 'a', ' '); + expect(s.lists.find((l) => l.id === 'a')?.name).toBe('new name'); + s = withoutList(s, 'a'); + expect(s.lists.map((l) => l.id)).toEqual(['b']); + }); +}); + +describe('lists file roundtrip', () => { + let dir: string; + let file: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codev-lists-')); + file = path.join(dir, 'session-lists.json'); + }); + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('missing file reads as empty AND authoritative', () => { + const r = readListsFileResult(file); + expect(r.value).toEqual(emptyLists()); + expect(r.known).toBe(true); + }); + + it('writes atomically and reads back the same value', () => { + const s = withList(emptyLists(), list('a', [member('s1', { title: 't' })])); + writeListsFile(file, s); + expect(fs.readdirSync(dir)).toEqual(['session-lists.json']); // no .tmp left + const r = readListsFileResult(file); + expect(r.known).toBe(true); + expect(r.value).toEqual(s); + }); + + it('mutate refuses to write over an unreadable store', () => { + fs.writeFileSync(file, '{not json'); + const r = mutateListsFile(file, (s) => withList(s, list('a'))); + expect(r.known).toBe(false); + // The corrupt bytes are still there — nothing was overwritten. + expect(fs.readFileSync(file, 'utf-8')).toBe('{not json'); + }); + + it('inspect reports what a non-authoritative file holds without touching it', () => { + // A store whose normalization is not a no-op (a message with a trailing + // blank the normalizer would trim): refused for writing, but the UI must + // be able to say what is in it rather than showing an empty list. + const untrusted = { + version: 1, + lists: [ + list('a', [ + member('s1', { + lastUserMessage: 'x'.repeat(LIST_TEXT_CAPS.message - 1) + ' ', + }), + member('s2'), + ]), + ], + }; + const bytes = JSON.stringify(untrusted); + fs.writeFileSync(file, bytes); + expect(readListsFileResult(file).known).toBe(false); + expect(mutateListsFile(file, (s) => withList(s, list('b'))).known).toBe( + false, + ); + expect(inspectListsFile(file)).toMatchObject({ + known: false, + parseable: true, + rawLists: 1, + rawMembers: 2, + keptLists: 1, + keptMembers: 2, + }); + expect(fs.readFileSync(file, 'utf-8')).toBe(bytes); // inspect never writes + }); + + it('inspect says when the file is not JSON at all', () => { + fs.writeFileSync(file, '{not json'); + expect(inspectListsFile(file)).toMatchObject({ + known: false, + parseable: false, + }); + }); + + it('mutate refuses to write over a store it would have coerced', () => { + const coercible = JSON.stringify({ + version: 1, + lists: [list('keep', [{ sessionId: 's1' }])], // member lacks pinned/lastTimestamp + }); + fs.writeFileSync(file, coercible); + const r = mutateListsFile(file, (s) => withList(s, list('new'))); + expect(r.known).toBe(false); + expect(fs.readFileSync(file, 'utf-8')).toBe(coercible); + }); +}); diff --git a/src/session-lists.ts b/src/session-lists.ts new file mode 100644 index 0000000..24ad6de --- /dev/null +++ b/src/session-lists.ts @@ -0,0 +1,322 @@ +/** + * Saved session lists (issue #145): a named snapshot of the sessions that + * were open at one moment, on the Session Buddy model — save what is open, + * put the windows down, come back to the list later. + * + * This is NOT the pinned zone. A pin is "this matters long-term"; a list is + * "this is what I had open on Tuesday". Both are keyed by sessionId and both + * drift across `/branch` (issue #142) in the same way. + * + * What a member stores is the point of the feature. A bare sessionId is + * useless for recall, so each member carries the material a person actually + * recognises a session by, captured at save time: title, branch, the recap + * line Claude Code writes into the transcript (`away_summary`), and the last + * user / assistant messages — every text field capped, so a 30-session list + * stays a few tens of KB. + * + * Store: ~/.config/codev/session-lists.json, beside the marks store, using + * the same authoritative-read / atomic-write / directory-watch machinery + * (`atomic-json-store.ts`). Pure helpers first, fs wrappers below. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { + mutateStoreFile, + readStoreResult, + StoreRead, + watchStoreFile, + writeStoreFile, +} from './atomic-json-store'; + +/** Per-field caps. The recap is already capped at 400 by Claude Code. */ +export const LIST_TEXT_CAPS = { + name: 80, + title: 200, + recap: 400, + message: 500, +} as const; + +export interface SessionListRecap { + text: string; + /** ISO time the recap was written — a recap can lag the session's last activity. */ + at: string; +} + +export interface SessionListMember { + sessionId: string; + project: string; + projectName: string; + accountLabel?: string; + title?: string; + branch?: string; + /** Pin state AT CAPTURE — a snapshot, never updated when the pin changes later. */ + pinned: boolean; + /** Session's last activity at capture (unix ms). */ + lastTimestamp: number; + recap?: SessionListRecap; + lastUserMessage?: string; + lastAssistantMessage?: string; +} + +export interface SessionList { + id: string; + name: string; + createdAt: string; // ISO + members: SessionListMember[]; +} + +export interface SessionLists { + version: 1; + lists: SessionList[]; +} + +export const emptyLists = (): SessionLists => ({ version: 1, lists: [] }); + +/** + * Trim, cap, and trim AGAIN. The second trim is load-bearing: a cap that + * lands on a space leaves trailing whitespace, and a store written with it + * then reads back as "normalization would change this" — non-authoritative — + * so every later write is refused. The first live test hit exactly that: + * the first save worked (no file yet), the second save and every delete were + * silently refused. Normalization must be a fixed point of itself. + */ +const capText = (value: unknown, max: number): string | undefined => { + if (typeof value !== 'string') return undefined; + const t = value.trim(); + if (!t) return undefined; + // Cap in code points, not UTF-16 units: a cut inside a surrogate pair + // (an emoji at the boundary) would store a lone surrogate that renders + // as a broken glyph forever. + const points = Array.from(t); + const capped = points.length > max ? points.slice(0, max).join('').trim() : t; + return capped || undefined; +}; + +/** Coerce one unknown member record; null when it cannot be a member at all. */ +export const normalizeMember = (raw: unknown): SessionListMember | null => { + if (!raw || typeof raw !== 'object') return null; + const m = raw as Record; + if (typeof m.sessionId !== 'string' || !m.sessionId) return null; + const project = typeof m.project === 'string' ? m.project : ''; + const member: SessionListMember = { + sessionId: m.sessionId, + project, + projectName: + capText(m.projectName, LIST_TEXT_CAPS.title) || + capText(project.split('/').filter(Boolean).pop(), LIST_TEXT_CAPS.title) || + m.sessionId.slice(0, 8), + pinned: m.pinned === true, + lastTimestamp: + typeof m.lastTimestamp === 'number' && Number.isFinite(m.lastTimestamp) + ? m.lastTimestamp + : 0, + }; + const accountLabel = capText(m.accountLabel, LIST_TEXT_CAPS.name); + if (accountLabel) member.accountLabel = accountLabel; + const title = capText(m.title, LIST_TEXT_CAPS.title); + if (title) member.title = title; + const branch = capText(m.branch, LIST_TEXT_CAPS.title); + if (branch) member.branch = branch; + if (m.recap && typeof m.recap === 'object') { + const r = m.recap as Record; + const text = capText(r.text, LIST_TEXT_CAPS.recap); + if (text) { + member.recap = { + text, + at: typeof r.at === 'string' ? r.at : new Date(0).toISOString(), + }; + } + } + const lastUser = capText(m.lastUserMessage, LIST_TEXT_CAPS.message); + if (lastUser) member.lastUserMessage = lastUser; + const lastAssistant = capText(m.lastAssistantMessage, LIST_TEXT_CAPS.message); + if (lastAssistant) member.lastAssistantMessage = lastAssistant; + return member; +}; + +/** Coerce one unknown list record; null when it has no usable identity. */ +export const normalizeList = (raw: unknown): SessionList | null => { + if (!raw || typeof raw !== 'object') return null; + const l = raw as Record; + if (typeof l.id !== 'string' || !l.id) return null; + const members: SessionListMember[] = []; + const seen = new Set(); + if (Array.isArray(l.members)) { + for (const item of l.members) { + const m = normalizeMember(item); + if (!m || seen.has(m.sessionId)) continue; + seen.add(m.sessionId); + members.push(m); + } + } + return { + id: l.id, + name: capText(l.name, LIST_TEXT_CAPS.name) || 'untitled', + createdAt: + typeof l.createdAt === 'string' ? l.createdAt : new Date(0).toISOString(), + members, + }; +}; + +/** Coerce unknown JSON into valid SessionLists (drops malformed entries). */ +export const normalizeLists = (raw: unknown): SessionLists => { + const lists = emptyLists(); + if (!raw || typeof raw !== 'object') return lists; + const obj = raw as Record; + if (!Array.isArray(obj.lists)) return lists; + const seen = new Set(); + for (const item of obj.lists) { + const l = normalizeList(item); + if (!l || seen.has(l.id)) continue; + seen.add(l.id); + lists.lists.push(l); + } + return lists; +}; + +/** Newest first — a saved list is browsed the way it was made, by time. */ +export const withList = ( + lists: SessionLists, + list: SessionList, +): SessionLists => ({ + version: 1, + lists: [list, ...lists.lists.filter((l) => l.id !== list.id)], +}); + +export const withoutList = (lists: SessionLists, id: string): SessionLists => ({ + version: 1, + lists: lists.lists.filter((l) => l.id !== id), +}); + +export const withRenamedList = ( + lists: SessionLists, + id: string, + name: string, +): SessionLists => ({ + version: 1, + lists: lists.lists.map((l) => + l.id === id + ? { ...l, name: capText(name, LIST_TEXT_CAPS.name) || l.name } + : l, + ), +}); + +// --- fs layer (path-based, testable; default-path wrappers below) --- + +const LISTS_FILENAME = 'session-lists.json'; + +export type ListsRead = StoreRead; + +export const readListsFileResult = (filePath: string): ListsRead => + readStoreResult(filePath, normalizeLists, emptyLists); + +export const writeListsFile = (filePath: string, lists: SessionLists): void => + writeStoreFile(filePath, lists); + +export const mutateListsFile = ( + filePath: string, + mutate: (lists: SessionLists) => SessionLists, +): ListsRead => mutateStoreFile(filePath, normalizeLists, emptyLists, mutate); + +export const watchListsFile = ( + filePath: string, + onChange: (lists: SessionLists) => void, + onError?: (err: Error) => void, +): (() => void) => + watchStoreFile(filePath, readListsFileResult, onChange, onError); + +/** + * What a non-authoritative store actually holds, so the UI can say "the + * file has N lists / M sessions but cannot be trusted as written" instead of + * silently showing zero — which read as "my list was deleted" in a live + * test. The store is never rewritten from here: an unreadable-as-is file is + * for the user to fix or remove, same policy as the marks store. If a future + * format change ever makes old files non-authoritative, the answer is a + * versioned migration, not a repair button. + */ +export interface ListsInspection { + known: boolean; + /** False when the file is not JSON at all. */ + parseable: boolean; + rawLists: number; + rawMembers: number; + keptLists: number; + keptMembers: number; +} + +const countRaw = (raw: unknown): { lists: number; members: number } => { + const lists = (raw as { lists?: unknown } | null)?.lists; + if (!Array.isArray(lists)) return { lists: 0, members: 0 }; + let members = 0; + for (const l of lists) { + const m = (l as { members?: unknown } | null)?.members; + if (Array.isArray(m)) members += m.length; + } + return { lists: lists.length, members }; +}; + +const countKept = ( + lists: SessionLists, +): { lists: number; members: number } => ({ + lists: lists.lists.length, + members: lists.lists.reduce((n, l) => n + l.members.length, 0), +}); + +export const inspectListsFile = (filePath: string): ListsInspection => { + const read = readListsFileResult(filePath); + if (read.known) { + const kept = countKept(read.value); + return { + known: true, + parseable: true, + rawLists: kept.lists, + rawMembers: kept.members, + keptLists: kept.lists, + keptMembers: kept.members, + }; + } + let raw: unknown; + try { + raw = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return { + known: false, + parseable: false, + rawLists: 0, + rawMembers: 0, + keptLists: 0, + keptMembers: 0, + }; + } + const r = countRaw(raw); + const k = countKept(normalizeLists(raw)); + return { + known: false, + parseable: true, + rawLists: r.lists, + rawMembers: r.members, + keptLists: k.lists, + keptMembers: k.members, + }; +}; + +const defaultListsPath = (): string => + path.join(os.homedir(), '.config', 'codev', LISTS_FILENAME); + +export const inspectSessionLists = (): ListsInspection => + inspectListsFile(defaultListsPath()); + +export const readSessionListsResult = (): ListsRead => + readListsFileResult(defaultListsPath()); + +export const mutateSessionLists = ( + mutate: (lists: SessionLists) => SessionLists, +): ListsRead => mutateListsFile(defaultListsPath(), mutate); + +export const watchSessionLists = ( + onChange: (lists: SessionLists) => void, + onError?: (err: Error) => void, +): (() => void) => watchListsFile(defaultListsPath(), onChange, onError); diff --git a/src/session-marks.ts b/src/session-marks.ts index 1978797..d05b33e 100644 --- a/src/session-marks.ts +++ b/src/session-marks.ts @@ -5,18 +5,28 @@ * Single cross-account store at ~/.config/codev/session-marks.json (the same * directory as the accounts registry), pushed to the renderer via fs.watch — * the same pattern as the status files. sessionIds are stable across resumes - * (verified: `--resume`/`--continue` reuse the id; only `--fork-session` - * creates a new one), so plain sessionId keying needs no migration logic. + * (`--resume`/`--continue` reuse the id); `--fork-session` and `/branch` mint + * a new one, which is why pins drift across a branch (issue #142) — plain + * sessionId keying stays, and the drift is a display problem, not a store one. * * Pure helpers (normalize / with* transitions) are separated from fs wrappers * so they are unit-testable; fs functions take an explicit file path with - * default-path wrappers for the app (same layout as share-manager.ts). + * default-path wrappers for the app (same layout as share-manager.ts). The + * authority invariant, atomic write and directory watch are shared with the + * saved-lists store — see `atomic-json-store.ts` for why they must be one. */ -import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { + isAuthoritativeRead as isAuthoritativeStoreRead, + mutateStoreFile, + readStoreResult, + watchStoreFile, + writeStoreFile, +} from './atomic-json-store'; + export interface PinInfo { pinnedAt: string; // ISO timestamp cwd: string; @@ -141,72 +151,26 @@ export interface MarksRead { known: boolean; } -/** The only store version this build understands. */ -const SUPPORTED_VERSION = 1; - -/** Stable JSON — object keys sorted, so key ORDER can never fake a difference. */ -const canonical = (value: unknown): string => - JSON.stringify(value, (_key, val) => - val && typeof val === 'object' && !Array.isArray(val) - ? Object.fromEntries( - Object.entries(val as Record).sort(([x], [y]) => - x < y ? -1 : x > y ? 1 : 0, - ), - ) - : val, - ); - /** - * Is a parsed value a store we may treat as AUTHORITATIVE? - * - * `normalizeMarks` is forgiving by design — it coerces anything into valid v1 - * marks so a partly-corrupt store still renders. That is right for display and - * wrong for authority: whatever it changes would be written back for real by - * the next read-modify-write, erasing the original. - * - * So the invariant is simply **normalization must be a no-op**. Earlier - * versions of this check enumerated the ways normalization can differ — the - * envelope, then dropped entries, then coerced fields, then unknown top-level - * keys — and each round of review found one more that the enumeration missed, - * because "all the ways a forgiving function can be forgiving" is not a list - * anyone can finish. Comparing the whole normalized result against the input - * has no narrower case left to miss, and it tracks `normalizeMarks` - * automatically instead of restating its rules beside it. - * - * Deliberately strict: a store this build would rewrite in ANY way — including - * one carrying a field a future version added, or a bare `{}` — is refused - * rather than silently rewritten. Refusing costs a lost pin action; rewriting - * costs the user's data. + * Is a parsed value a marks store we may treat as AUTHORITATIVE? The rule — + * normalization must be a no-op — is the shared store's; kept as a named + * export because the tests exercise it against `normalizeMarks` directly. */ export const isAuthoritativeRead = ( raw: unknown, marks: SessionMarks, -): boolean => canonical(raw) === canonical(marks); +): boolean => isAuthoritativeStoreRead(raw, marks); export const readMarksFileResult = (filePath: string): MarksRead => { - try { - const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8')); - const marks = normalizeMarks(raw); - return { marks, known: isAuthoritativeRead(raw, marks) }; - } catch (err) { - // A missing file IS authoritative: no store yet means no marks yet, which - // is simply the first run. Anything else — permissions, IO, malformed - // JSON — leaves the real contents unknown. - const code = (err as NodeJS.ErrnoException | undefined)?.code; - return { marks: emptyMarks(), known: code === 'ENOENT' }; - } + const read = readStoreResult(filePath, normalizeMarks, emptyMarks); + return { marks: read.value, known: read.known }; }; export const readMarksFile = (filePath: string): SessionMarks => readMarksFileResult(filePath).marks; -export const writeMarksFile = (filePath: string, marks: SessionMarks): void => { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - // temp + rename so a crash mid-write can't corrupt the store - const tmp = `${filePath}.tmp-${process.pid}`; - fs.writeFileSync(tmp, JSON.stringify(marks, null, 2) + '\n'); - fs.renameSync(tmp, filePath); -}; +export const writeMarksFile = (filePath: string, marks: SessionMarks): void => + writeStoreFile(filePath, marks); const defaultMarksPath = (): string => path.join(os.homedir(), '.config', 'codev', MARKS_FILENAME); @@ -218,29 +182,18 @@ export const readSessionMarksResult = (): MarksRead => readMarksFileResult(defaultMarksPath()); /** - * Read-modify-write that REFUSES to write when the store could not be read. - * - * Every mutation here is read-modify-write over the whole file, so a read that - * silently degrades to empty marks turns the next pin or hide into a full - * overwrite: one keystroke against an unreadable store would erase every other - * pin and hidden id on disk. A missing file is still fine — ENOENT is - * authoritative (see MarksRead), so the first-ever pin creates the store as - * usual. - * - * Returns the resulting marks with `known: true` when the write happened, or - * the unknown read (`known: false`) when it was refused and nothing was - * touched. Collapsing the four callers onto this one path is deliberate: four - * copies of read-modify-write are four chances to forget the guard. + * Read-modify-write that REFUSES to write when the store could not be read — + * one keystroke against an unreadable store would otherwise erase every other + * pin and hidden id on disk. Collapsing the four callers onto this one path is + * deliberate: four copies of read-modify-write are four chances to forget the + * guard. */ export const mutateMarksFile = ( filePath: string, mutate: (marks: SessionMarks) => SessionMarks, ): MarksRead => { - const read = readMarksFileResult(filePath); - if (!read.known) return read; - const next = mutate(read.marks); - writeMarksFile(filePath, next); - return { marks: next, known: true }; + const read = mutateStoreFile(filePath, normalizeMarks, emptyMarks, mutate); + return { marks: read.value, known: read.known }; }; export const mutateSessionMarks = ( @@ -250,52 +203,18 @@ export const mutateSessionMarks = ( export const writeSessionMarks = (marks: SessionMarks): void => writeMarksFile(defaultMarksPath(), marks); -/** - * Watch a marks file for changes (path-based, testable). Watches the parent - * DIRECTORY: the rename-based write replaces the file inode, which would - * detach a plain file watcher. Events for sibling files (accounts.json, our - * own .tmp) are filtered out by name. - */ +/** Watch a marks file for changes (path-based, testable). */ export const watchMarksFile = ( filePath: string, onChange: (marks: SessionMarks) => void, onError?: (err: Error) => void, -): (() => void) => { - const dir = path.dirname(filePath); - const filename = path.basename(filePath); - fs.mkdirSync(dir, { recursive: true }); - - // Debounce: fs.watch on macOS fires several times per change - let debounceTimer: ReturnType | null = null; - const watcher = fs.watch(dir, { persistent: false }, (_event, changed) => { - if (changed && changed !== filename) return; - if (debounceTimer) clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => { - const read = readMarksFileResult(filePath); - // Never broadcast an unknown read. Announcing "the marks are now empty" - // because the file could not be parsed would push every listener into - // acting on state that is still intact on disk; staying silent leaves - // them on the last thing actually seen. - if (!read.known) return; - onChange(read.marks); - }, 50); - }); - - watcher.on('error', (err: Error) => { - // A dead watcher must not crash the main process (unhandled 'error' - // would) — close it and let the owner decide whether to recreate. - try { - watcher.close(); - } catch {} - if (debounceTimer) clearTimeout(debounceTimer); - onError?.(err); - }); - - return () => { - if (debounceTimer) clearTimeout(debounceTimer); - watcher.close(); - }; -}; +): (() => void) => + watchStoreFile( + filePath, + (p) => readStoreResult(p, normalizeMarks, emptyMarks), + onChange, + onError, + ); export const watchSessionMarks = ( onChange: (marks: SessionMarks) => void, diff --git a/src/session-search.test.ts b/src/session-search.test.ts index 2dde378..b60ac17 100644 --- a/src/session-search.test.ts +++ b/src/session-search.test.ts @@ -5,6 +5,8 @@ import { findPromptMatch, isMinorSession, matchesAllWords, + matchesAllWordsOrId, + matchesSessionId, truncateMiddle, windowAroundMatch, } from './session-search'; @@ -281,3 +283,33 @@ describe('truncateMiddle lower boundary', () => { expect(truncateMiddle('abcdef', 3).length).toBe(3); }); }); + +describe('matchesSessionId — the id is a prefix target, not a substring', () => { + const id = '4ed7505a-eae6-43a5-827b-465c8b5eb759'; + + it('matches a hex prefix of four or more characters, hyphens allowed', () => { + expect(matchesSessionId(id, '4ed7')).toBe(true); + expect(matchesSessionId(id, '4ed7505a-eae6')).toBe(true); + expect(matchesSessionId(id, id)).toBe(true); + expect(matchesSessionId(id, '4ED7'.toLowerCase())).toBe(true); + }); + + it('refuses short or non-hex words, and anything not at the start', () => { + // `de`, `cafe`-style fragments appear inside nearly every UUID; as + // substrings they would match the whole corpus regardless of content. + expect(matchesSessionId(id, 'de')).toBe(false); + expect(matchesSessionId(id, 'eae6')).toBe(false); // inside, not a prefix + expect(matchesSessionId(id, '4ed7x')).toBe(false); + expect(matchesSessionId(id, '')).toBe(false); + }); + + it('matchesAllWordsOrId lets each word hit the text or the id', () => { + const text = 'fred-ff nextjs backend and mcp arch'; + expect(matchesAllWordsOrId(text, id, ['nextjs', '4ed7'])).toBe(true); + expect(matchesAllWordsOrId(text, id, ['nextjs', 'eae6'])).toBe(false); + // Plain text search is unchanged by the id rule. + expect(matchesAllWordsOrId(text, id, ['mcp', 'arch'])).toBe( + matchesAllWords(text, ['mcp', 'arch']), + ); + }); +}); diff --git a/src/session-search.ts b/src/session-search.ts index 59f83d4..6a36e4c 100644 --- a/src/session-search.ts +++ b/src/session-search.ts @@ -19,6 +19,36 @@ export const matchesAllWords = ( wordsLower: string[], ): boolean => wordsLower.every((w) => haystackLower.includes(w)); +/** + * Does a query word name a session by its id? A PREFIX of at least four hex + * characters (hyphens allowed), never a substring: the id is searchable so + * the one a terminal status line shows can be typed in, and people type it + * from the start. A substring rule would make `de` or `cafe` match nearly + * every session on the machine through its id, regardless of content. + */ +export const matchesSessionId = ( + sessionId: string, + wordLower: string, +): boolean => + wordLower.length >= 4 && + /^[0-9a-f-]+$/.test(wordLower) && + sessionId.toLowerCase().startsWith(wordLower); + +/** + * `matchesAllWords` plus the id rule: every word must match the text OR + * the session id. One definition for both search paths (main-side prompt + * search and the renderer's field filter), so they cannot disagree about + * what an id query is. + */ +export const matchesAllWordsOrId = ( + haystackLower: string, + sessionId: string, + wordsLower: string[], +): boolean => + wordsLower.every( + (w) => haystackLower.includes(w) || matchesSessionId(sessionId, w), + ); + /** * Extract a snippet of `radius` chars on each side of the match, collapsing * whitespace/newlines so it renders as a single line. Ellipses mark truncation. diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 6be6867..0ae23ca 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -5,14 +5,30 @@ import Highlighter from 'react-highlight-words'; import Select, { components, OptionProps } from 'react-select'; import { HoverButton } from './HoverButton'; import PopupDefaultExample from './popup'; +import type { LiveRowInfo } from './session-list-view'; +import type { SessionList, SessionListMember } from './session-lists'; import { buildSessionListView, ListViewSession, mergeSessionsById, } from './session-list-view'; -import { truncateMiddle, windowAroundMatch } from './session-search'; +import { + matchesAllWordsOrId, + matchesSessionId, + truncateMiddle, + windowAroundMatch, +} from './session-search'; import TerminalTab from './terminal-tab'; +type LiveReport = Awaited>; +type ListsResponse = Awaited>; +/** Shape every list mutation returns (save / delete / rename). */ +type ListsWriteResult = { + ok: boolean; + error?: string; + lists?: { lists: SessionList[] }; +}; + type SwitcherMode = 'projects' | 'sessions' | 'terminal'; // import { fetchVSCodeBasedOpenedWindows, SERVER_URL, deleteRecentProjectRecord } from "./vscode-based-ide-utility" export const SERVER_URL = 'http://localhost:55688'; @@ -74,6 +90,10 @@ const MINOR_FOLD_BAR_STYLE = { flexShrink: 0, } as const; +// Referenced by style constants declared above THEME (which is defined later +// in this file); the value is THEME.text.primary. +const THEME_TEXT_PRIMARY = '#E9E9E9'; + // Header row of the pinned zone at the top of the Sessions list. It carries // two independent toggles on one line — the label groups/ungroups the zone, // the "only" chip scopes browsing and search to pins — so neither costs @@ -105,6 +125,126 @@ const PINNED_ONLY_CHIP_ACTIVE_STYLE = { backgroundColor: '#c9a227', } as const; +// Scope chips in the search row (issues #94 / #145). They sit beside the +// session count because that row has spare width and the list has none — +// every scope is one click away without costing a line. +const SCOPE_CHIP_STYLE = { + fontSize: '10px', + borderRadius: '3px', + padding: '1px 6px', + cursor: 'pointer', + border: '1px solid #3f5f4a', + color: '#7ec87e', + backgroundColor: 'transparent', + whiteSpace: 'nowrap', +} as const; + +const SCOPE_CHIP_ACTIVE_STYLE = { + ...SCOPE_CHIP_STYLE, + border: '1px solid #7ec87e', + color: '#1e1e1e', + backgroundColor: '#7ec87e', +} as const; + +const LISTS_CHIP_STYLE = { + ...SCOPE_CHIP_STYLE, + border: '1px solid #4a6a8a', + color: '#9DC8E0', +} as const; + +const LISTS_CHIP_ACTIVE_STYLE = { + ...LISTS_CHIP_STYLE, + border: '1px solid #9DC8E0', + color: '#1e1e1e', + backgroundColor: '#9DC8E0', +} as const; + +// Header of the saved-lists zone and of a list being viewed. Same geometry as +// the pinned header so the two zones read as siblings. +const LISTS_HEADER_STYLE = { + ...PINNED_HEADER_STYLE, + color: '#9DC8E0', +} as const; + +const LIST_ROW_STYLE = { + display: 'flex', + alignItems: 'center', + gap: '8px', + padding: '4px 10px 4px 24px', + margin: '1px 0', + borderRadius: '3px', + cursor: 'pointer', + fontSize: '12px', + color: THEME_TEXT_PRIMARY, +} as const; + +// A saved-list row is three SIBLINGS in a plain wrapper: the opener (a +// labelled button that fills the row, so a click on the padding opens it and +// it is the row's first Tab stop) and the rename / delete controls beside it. +// Nothing interactive sits inside a button — interactive descendants of a +// button are not reliably exposed by assistive tech. +// The wrapper carries NO gap or padding of its own: every pixel of the row +// belongs to the opener or to a control, so there is no dead area to click. +// (It still delegates a direct click, should one ever land on it.) +const LIST_WRAPPER_STYLE = { + display: 'flex', + alignItems: 'stretch', + margin: '1px 0', + borderRadius: '3px', + fontSize: '12px', + color: THEME_TEXT_PRIMARY, +} as const; + +const LIST_OPENER_STYLE = { + display: 'flex', + alignItems: 'center', + gap: '8px', + flex: 1, + minWidth: 0, + padding: '4px 8px 4px 24px', + cursor: 'pointer', + outlineOffset: '-2px', +} as const; + +// Each control pads itself; the last one carries the row's right edge. +const LIST_CONTROL_STYLE = { + display: 'flex', + alignItems: 'center', + padding: '4px 4px', + cursor: 'pointer', + fontSize: '11px', + flexShrink: 0, +} as const; + +// Per-row process facts in the live scope: memory, uptime, terminal. Muted — +// the row is still a session row first. +const LIVE_INFO_STYLE = { + fontSize: '10px', + color: '#8fbc8f', + border: '1px solid #3f5f4a', + borderRadius: '3px', + padding: '1px 5px', + whiteSpace: 'nowrap', +} as const; + +const LIVE_WARN_STYLE = { + ...LIVE_INFO_STYLE, + color: '#e0b060', + border: '1px solid #8a6a2a', +} as const; + +// The recap line on a saved-list member. Its own marker, its own colour +// family (the list blue), so it is never mistaken for the amber search +// snippet or the grey message lines. +const RECAP_MARKER_STYLE = { + color: '#1e1e1e', + backgroundColor: '#9DC8E0', + borderRadius: '2px', + padding: '0 4px', + fontSize: '10px', + fontWeight: 600, +} as const; + // Global styles for the switcher UI (moved from index.css) const globalStyles = ` body { @@ -138,6 +278,11 @@ const globalStyles = ` padding: 0; margin: 0; } + + /* The whole saved-list row is the click target; say so on hover. */ + .codev-list-row:hover { + background-color: #2a2a2a; + } `; // Apply global styles @@ -361,6 +506,38 @@ const formatRelativeTime = (timestamp: number | string): string => { return `${days}d ago`; }; +/** `17d` / `2d13h` / `3h05m` / `12m` — how long a process has been up. */ +const formatUptime = (sec: number): string => { + const d = Math.floor(sec / 86400); + const h = Math.floor((sec % 86400) / 3600); + const m = Math.floor((sec % 3600) / 60); + if (d >= 3) return `${d}d`; + if (d > 0) return `${d}d${h}h`; + if (h > 0) return `${h}h${String(m).padStart(2, '0')}m`; + return `${m}m`; +}; + +const formatMb = (kb: number): string => { + const mb = kb / 1024; + return mb >= 1024 ? `${(mb / 1024).toFixed(1)}GB` : `${Math.round(mb)}MB`; +}; + +/** + * Default name for a saved list: today's `MMDD`, the way the user names + * them, and `MMDD-2`, `MMDD-3`… once that is taken. A name is a label, not + * an identity (lists are keyed by a generated id), so duplicates are legal — + * they are just not what a second save in one day usually means. + */ +const nextListName = (existing: string[]): string => { + const d = new Date(); + const base = `${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`; + const taken = new Set(existing); + if (!taken.has(base)) return base; + for (let n = 2; ; n++) { + if (!taken.has(`${base}-${n}`)) return `${base}-${n}`; + } +}; + /** Caution it will be invoked twice due to !! */ let loadTimes = 0; function SwitcherApp() { @@ -493,6 +670,62 @@ function SwitcherApp() { // bitten by twice — see sessionSearchRef2). const extraPinnedSessionsRef = useRef([]); const extraPinnedKeyRef = useRef(''); + // The transcript's recap line per session (enrichment), captured into lists. + const [recaps, setRecaps] = useState>({}); + // Live scope (issue #94): only running sessions, with process facts. Not + // persisted — it describes this moment, not a preference. + const [liveOnly, setLiveOnly] = useState(false); + const liveOnlyRef = useRef(false); + // Per-row process figures inside the live scope. Off by default: memory + // and uptime track message count closely enough that on most rows they + // are noise (user verdict, second live test); the one number that says + // whether there is a problem is the total beside the search box, which + // stays. Persisted — a preference, not a moment. + const [liveStats, setLiveStats] = useState(() => { + try { + return localStorage.getItem('codev-live-stats') === '1'; + } catch { + return false; + } + }); + const [liveReport, setLiveReport] = useState(null); + // Saved session lists (issue #145), pushed from main via fs.watch. + const [sessionLists, setSessionLists] = useState([]); + const [listsLoaded, setListsLoaded] = useState(false); + const listsPushSeenRef = useRef(false); + const [listsExpanded, setListsExpanded] = useState(() => { + try { + return localStorage.getItem('codev-lists-expanded') === '1'; + } catch { + return false; + } + }); + const [viewingListId, setViewingListId] = useState(null); + const [confirmDeleteListId, setConfirmDeleteListId] = useState(null); + const [listsNotice, setListsNotice] = useState(null); + // Set when the lists store exists but cannot be trusted as written — a + // file an earlier build wrote, or hand-edited. Persistent until repaired + // or until a readable store is pushed; carries what a repair would keep. + const [listsStoreProblem, setListsStoreProblem] = useState<{ + parseable: boolean; + rawLists: number; + rawMembers: number; + keptLists: number; + keptMembers: number; + } | null>(null); + // One dialog for two jobs: naming a new list (`count` set) and renaming an + // existing one (`renameId` set). Same input, same keys, one code path. + const [saveListPrompt, setSaveListPrompt] = useState<{ + name: string; + count: number; + renameId?: string; + } | null>(null); + // Rows a scope needs that are outside the loaded list (list members, live + // sessions older than the window), fetched by id — same mechanism as pins. + const [extraScopeSessions, setExtraScopeSessions] = useState([]); + const extraScopeSessionsRef = useRef([]); + const extraScopeKeyRef = useRef(''); + const viewingListRef = useRef(null); // Keep the selection on the same session after pin/hide reshuffles the list const reanchorSelectionRef = useRef(null); const hoverSuppressTokenRef = useRef(0); @@ -534,8 +767,18 @@ function SwitcherApp() { return allItems.filter((s) => { const prInfo = prLinks[s.sessionId]; const terminalBadge = terminalApps[s.sessionId] || ((s as any).entrypoint === 'claude-vscode' ? 'vscode' : ''); - const searchTarget = `${s.projectName} ${s.project} ${s.firstUserMessage} ${s.lastUserMessage} ${customTitles[s.sessionId] || ''} ${branches[s.sessionId] || ''} ${prInfo ? `PR #${prInfo.prNumber} ${prInfo.prUrl}` : ''} ${assistantResponses[s.sessionId] || ''} ${terminalBadge}`.toLowerCase(); - return words.every((w: string) => searchTarget.includes(w)); + // The session id is searchable too — by the prefix rule in + // matchesSessionId, shared with the main-side searchClaudeSessions so + // both paths agree on what an id query is (#142: the id is the one + // field that stays unique when several sessions share a name). + // A saved-list member whose session is gone has only what was captured; + // search those fields too, or a query on its captured title drops it. + const captured = s.__listMember; + const capturedText = captured + ? `${captured.title || ''} ${captured.branch || ''} ${captured.recap?.text || ''} ${captured.lastUserMessage || ''} ${captured.lastAssistantMessage || ''}` + : ''; + const searchTarget = `${s.projectName} ${s.project} ${s.firstUserMessage} ${s.lastUserMessage} ${customTitles[s.sessionId] || ''} ${branches[s.sessionId] || ''} ${prInfo ? `PR #${prInfo.prNumber} ${prInfo.prUrl}` : ''} ${assistantResponses[s.sessionId] || ''} ${terminalBadge} ${capturedText}`.toLowerCase(); + return matchesAllWordsOrId(searchTarget, s.sessionId, words); }); }; @@ -551,8 +794,46 @@ function SwitcherApp() { // Only while a query is live: this runs on every keystroke including the // one that empties the box, and widening the browse list is not this // function's job. + // While viewing a saved list, members the by-id fetch could not resolve + // exist only as captured records; give them a placeholder row here so a + // query on a captured field keeps them, the way the list view itself + // renders them. mergeSessionsById keeps the first occurrence, so resolved + // members are untouched. + const members = viewingListRef.current?.members ?? []; + const listPlaceholders: ListViewSession[] = members.map((m) => ({ + sessionId: m.sessionId, + project: m.project, + projectName: m.projectName, + firstUserMessage: '', + lastUserMessage: m.lastUserMessage || '', + lastTimestamp: m.lastTimestamp, + messageCount: undefined, + isActive: false, + accountLabel: m.accountLabel, + __listMember: m, + })); + // A member that DID resolve to a real row must search its captured + // fields too (the list view shows the captured recap on that row), so + // the captured record is attached to the resolved candidate as well. + const memberById = new Map(members.map((m) => [m.sessionId, m])); + const withCaptured = (rows: ListViewSession[]) => + memberById.size === 0 + ? rows + : rows.map((s) => + memberById.has(s.sessionId) && !s.__listMember + ? { ...s, __listMember: memberById.get(s.sessionId) } + : s, + ); const candidates = query.trim() - ? mergeSessionsById(allItems, extraPinnedSessionsRef.current) + ? mergeSessionsById( + withCaptured( + mergeSessionsById( + mergeSessionsById(allItems, extraPinnedSessionsRef.current), + extraScopeSessionsRef.current, + ), + ), + listPlaceholders, + ) : allItems; const base = filterSessionsLocally(candidates, query); if (!query.trim() || deepMatchesRef.current.length === 0) return base; @@ -572,6 +853,29 @@ function SwitcherApp() { return merged; }; + // Every enrichment response lands the same way; one function so a field + // added to the response (recaps) cannot be picked up at three call sites + // and forgotten at the fourth. + const applyEnrichment = (enrichment: { + titles?: Record; + branches?: Record; + prLinks?: Record; + recaps?: Record; + }) => { + if (enrichment.titles && Object.keys(enrichment.titles).length > 0) { + setCustomTitles((prev: Record) => ({ ...prev, ...enrichment.titles })); + } + if (enrichment.branches && Object.keys(enrichment.branches).length > 0) { + setBranches((prev: Record) => ({ ...prev, ...enrichment.branches })); + } + if (enrichment.prLinks && Object.keys(enrichment.prLinks).length > 0) { + setPrLinks((prev) => ({ ...prev, ...enrichment.prLinks })); + } + if (enrichment.recaps && Object.keys(enrichment.recaps).length > 0) { + setRecaps((prev) => ({ ...prev, ...enrichment.recaps })); + } + }; + // Debounced main-side search over ALL sessions × ALL user prompts. const scheduleDeepSearch = (query: string) => { if (deepSearchTimerRef.current) clearTimeout(deepSearchTimerRef.current); @@ -602,17 +906,7 @@ function SwitcherApp() { (s: any) => !loaded.has(s.sessionId), ); if (appended.length > 0) { - window.electronAPI.loadSessionEnrichment(appended).then((enrichment) => { - if (enrichment.titles && Object.keys(enrichment.titles).length > 0) { - setCustomTitles((prev: Record) => ({ ...prev, ...enrichment.titles })); - } - if (enrichment.branches && Object.keys(enrichment.branches).length > 0) { - setBranches((prev: Record) => ({ ...prev, ...enrichment.branches })); - } - if (enrichment.prLinks && Object.keys(enrichment.prLinks).length > 0) { - setPrLinks((prev) => ({ ...prev, ...enrichment.prLinks })); - } - }); + window.electronAPI.loadSessionEnrichment(appended).then(applyEnrichment); window.electronAPI.loadLastAssistantResponses(appended).then((responses: Record) => { if (responses && Object.keys(responses).length > 0) { setAssistantResponses((prev: Record) => ({ ...prev, ...responses })); @@ -652,6 +946,7 @@ function SwitcherApp() { assistantResponses, terminalApps, extraPinnedSessions, + extraScopeSessions, ]); const isSearchingSessions = sessionSearchValue.trim().length > 0; @@ -668,9 +963,102 @@ function SwitcherApp() { : truncateMiddle(text, max); const hiddenSet = new Set(sessionMarks.hidden); const hasPins = Object.keys(sessionMarks.pins).length > 0; + const viewingList = + viewingListId ? sessionLists.find((l) => l.id === viewingListId) ?? null : null; + // Mirrored into a ref for applySearchFilter, which runs from debounced + // timeouts and setState updaters (the stale-closure trap, see above). + // Written in an effect, not during render: with concurrent rendering a + // render can be abandoned, and a ref written by it would leave those + // callbacks filtering against a list that was never committed. + useEffect(() => { + viewingListRef.current = viewingList; + }, [viewingList]); + // Process facts by session, plus a synthetic row for every running process + // that has no session row to carry them: no id at all (the "unregistered" + // case the live view exists for), or an id the session list does not know — + // a session with no prompt yet, or one the by-id fetch has not returned. + // Either way the live scope must show every process the chip counted; the + // first live test caught "33 live" beside a 32-row list. + const liveBySession: Record = {}; + const liveOrphans: ListViewSession[] = []; + const knownById = new Map(); + for (const s of allSessions) knownById.set(s.sessionId, s); + for (const s of extraScopeSessions) { + if (!knownById.has(s.sessionId)) knownById.set(s.sessionId, s); + } + // Which process represents a session that has several? The one the + // registration-based detection mapped to it — IF that pid is still in the + // live report; the detection's map is cached and can name a process that + // has since been replaced. Otherwise the first live process on that id. + // Choosing from the report, not the cache, is what keeps a stale map from + // classifying every current process as an "extra" row. + const livePidsById = new Map(); + for (const p of liveReport?.live ?? []) { + if (!p.sessionId) continue; + const pids = livePidsById.get(p.sessionId) ?? []; + pids.push(p.pid); + livePidsById.set(p.sessionId, pids); + } + const representativeOf = (id: string): number | undefined => { + const pids = livePidsById.get(id) ?? []; + const mapped = activeStateRef.current[id]; + return mapped !== undefined && pids.includes(mapped) ? mapped : pids[0]; + }; + for (const p of liveReport?.live ?? []) { + const info: LiveRowInfo = { + pid: p.pid, + rssKb: p.rssKb, + tty: p.tty, + uptimeSec: p.uptimeSec, + registered: p.registered, + }; + const id = p.sessionId; + const known = id ? knownById.get(id) : undefined; + if (id && known) { + // A real row represents ONE process. Any other process on the same id + // (a resumed copy, a /branch parent and child) gets a row of its own — + // the chip counts processes, so the list must show processes, and the + // memory total must add every one of them. + if (representativeOf(id) === p.pid) { + liveBySession[id] = info; + continue; + } + liveOrphans.push({ + ...known, + isActive: true, + activePid: p.pid, + __live: info, + __liveExtra: true, + }); + continue; + } + if (id && representativeOf(id) === p.pid) liveBySession[id] = info; + liveOrphans.push({ + sessionId: id || `pid:${p.pid}`, + project: p.cwd || '', + projectName: (p.cwd || '').split('/').filter(Boolean).pop() || `pid ${p.pid}`, + accountLabel: p.accountLabel, + accountIsAnchor: p.accountIsAnchor, + firstUserMessage: '', + lastUserMessage: '', + lastTimestamp: 0, + messageCount: undefined, + isActive: true, + activePid: p.pid, + __liveOrphan: !id, + __live: info, + }); + } + // A live session older than the loaded window is only in the by-id fetch; + // widen the browse list so the live scope can see it (search widens its + // own candidates the same way). + const scopedSessions = + liveOnly && !viewingList && !isSearchingSessions + ? mergeSessionsById(sessions, extraScopeSessions) + : sessions; // Which rows appear, in which group, in which order — one pure function so - // the browse-state matrix (search x pinned-only x grouped) is testable - // without running the app. See src/session-list-view.ts. + // the browse-state matrix (search x scopes x grouped) is testable without + // running the app. See src/session-list-view.ts. const { pinnedRows, visiblePinnedRows, @@ -679,9 +1067,11 @@ function SwitcherApp() { minorFoldHeaderIndex, hiddenMinorCount, pinnedOnlyActive, + liveOnlyActive, + listViewActive, canGroupPins, } = buildSessionListView({ - sessions, + sessions: scopedSessions, allSessions, extraPinnedSessions, pins: sessionMarks.pins, @@ -694,7 +1084,24 @@ function SwitcherApp() { pinnedCollapsed, minorsExpanded, activeDetectionReady, + liveOnly, + liveBySession, + liveOrphans, + viewingList, + extraListSessions: extraScopeSessions, }); + const liveCount = liveReport + ? liveReport.live.length + : Object.keys(activeStateRef.current).length; + const staleCount = liveReport?.staleRegistrations.length ?? 0; + // Memory of the rows on screen — not of every live process — so the figure + // beside a search result describes the result. + const displayedRssKb = liveOnlyActive + ? displayedSessions.reduce((sum, s) => { + const live = s.__live || liveBySession[s.sessionId]; + return sum + (live?.rssKb ?? 0); + }, 0) + : 0; // Manually hidden sessions may be titled/long — keep the fold label honest. const minorFoldSuffix = hiddenMinorCount > 0 @@ -801,6 +1208,246 @@ function SwitcherApp() { setSelectedSessionIndex(0); }; + // --- Live scope (issue #94) --- + useEffect(() => { + try { + localStorage.setItem('codev-live-stats', liveStats ? '1' : '0'); + } catch { + // Best effort, same as the pinned toggles. + } + }, [liveStats]); + // Requests overlap (every session refetch starts one, and `ps` can take + // half a second on a swapping machine); only the newest may land, or an + // older snapshot would overwrite a newer one. Same pattern as the deep + // search's deepSearchSeqRef. + const liveReportSeqRef = useRef(0); + const refreshLiveReport = async () => { + const seq = ++liveReportSeqRef.current; + try { + // null = "could not look" (a failed ps); keep the last good report. + const r = await window.electronAPI.getLiveSessions(); + if (seq !== liveReportSeqRef.current) return; // superseded meanwhile + if (r) setLiveReport(r); + } catch { + // Same: never replace a good report with nothing. + } + }; + const toggleLiveOnly = () => { + const next = !liveOnly; + liveOnlyRef.current = next; + setLiveOnly(next); + // Scopes are exclusive: entering one leaves the other. + if (next) setViewingListId(null); + setSelectedSessionIndex(0); + if (next) refreshLiveReport(); + }; + + // --- Saved lists (issue #145) --- + useEffect(() => { + try { + localStorage.setItem('codev-lists-expanded', listsExpanded ? '1' : '0'); + } catch { + // Best effort, same as the pinned toggles. + } + }, [listsExpanded]); + // A refused write must be visible. The store refuses (rather than + // overwrites) when it cannot trust what is on disk; the first live test + // hit that and saw nothing at all, which read as "the button is broken". + const listsNoticeTimerRef = useRef | null>(null); + const showListsNotice = (text: string) => { + setListsNotice(text); + if (listsNoticeTimerRef.current) clearTimeout(listsNoticeTimerRef.current); + listsNoticeTimerRef.current = setTimeout(() => setListsNotice(null), 6000); + }; + const applyListsResult = (r: ListsWriteResult | undefined) => { + if (r?.ok && r.lists) { + setSessionLists(r.lists.lists || []); + setListsLoaded(true); + } else if (r && !r.ok) { + showListsNotice( + r.error === 'lists store unreadable' + ? 'Not saved: ~/.config/codev/session-lists.json cannot be read as-is — fix or remove it' + : `Not saved: ${r.error || 'unknown error'}`, + ); + } + }; + const openList = (id: string) => { + liveOnlyRef.current = false; + setLiveOnly(false); + setViewingListId(id); + setConfirmDeleteListId(null); + setSelectedSessionIndex(0); + // Members resolve their running state from the ps join as well as the + // registration map (see activeFrom in session-list-view.ts), so viewing a + // list refreshes the report too — one `ps`, and a click switches instead + // of resuming a second copy. + refreshLiveReport(); + }; + const closeList = () => { + setViewingListId(null); + setSelectedSessionIndex(0); + }; + const deleteList = async (id: string) => { + try { + const r = await window.electronAPI.deleteSessionList(id); + applyListsResult(r); + setConfirmDeleteListId(null); + if (r?.ok && viewingListId === id) setViewingListId(null); + } catch { + showListsNotice('delete failed'); + } + }; + // What gets saved is exactly what is on screen, minus rows that are not + // sessions (orphan processes have no id to resume). Every field is what the + // renderer already holds for the row; nothing is re-read from disk. + const captureDisplayedSessions = (): SessionListMember[] => + displayedSessions + .filter((s) => !s.__liveOrphan) + .map((s) => ({ + sessionId: s.sessionId, + project: s.project || '', + projectName: s.projectName || '', + accountLabel: s.accountLabel, + title: customTitles[s.sessionId] || s.__listMember?.title, + branch: branches[s.sessionId] || s.__listMember?.branch, + pinned: !!sessionMarks.pins[s.sessionId], + lastTimestamp: s.lastTimestamp || 0, + recap: recaps[s.sessionId] || s.__listMember?.recap, + lastUserMessage: s.lastUserMessage || undefined, + lastAssistantMessage: + assistantResponses[s.sessionId] || s.__listMember?.lastAssistantMessage, + })); + const openSaveListPrompt = () => { + const count = displayedSessions.filter((s) => !s.__liveOrphan).length; + if (count === 0) return; + setSaveListPrompt({ name: nextListName(sessionLists.map((l) => l.name)), count }); + }; + // Closing the dialog unmounts the focused input, which drops focus to the + // body — arrow keys then go nowhere until a click lands on something (the + // document click handler is what refocuses the search box). Hand focus + // back explicitly, for Enter and for Esc alike. + const closeSaveListPrompt = () => { + setSaveListPrompt(null); + setTimeout(() => sessionSearchRef.current?.focus(), 0); + }; + const openRenameListPrompt = (id: string) => { + const current = sessionLists.find((l) => l.id === id); + if (!current) return; + setSaveListPrompt({ name: current.name, count: current.members.length, renameId: id }); + }; + const saveList = async () => { + if (!saveListPrompt) return; + if (saveListPrompt.renameId) { + // Rename: an empty name keeps the old one (the store does the same). + const id = saveListPrompt.renameId; + const name = saveListPrompt.name.trim(); + closeSaveListPrompt(); + if (!name) return; + try { + applyListsResult(await window.electronAPI.renameSessionList(id, name)); + } catch { + showListsNotice('rename failed'); + } + return; + } + const members = captureDisplayedSessions(); + const name = + saveListPrompt.name.trim() || nextListName(sessionLists.map((l) => l.name)); + closeSaveListPrompt(); + try { + const r = await window.electronAPI.saveSessionList(name, members); + applyListsResult(r); + if (r?.ok) setListsExpanded(true); + } catch { + showListsNotice('save failed'); + } + }; + + // Load lists once + subscribe to main-side pushes — the same shape, and the + // same two guards, as the marks effect below: a push that already landed + // outranks the in-flight snapshot, and an unknown read is never applied. + useEffect(() => { + window.electronAPI + .getSessionLists() + .then((r: ListsResponse | undefined) => { + if (!r || listsPushSeenRef.current) return; + if (r.known === false) { + // Do not apply the (empty) value — but do say why the list is + // empty, with the numbers a repair would keep. Silence here read + // as "my list was deleted" in the second live test. + setListsStoreProblem( + r.inspection ?? { + parseable: false, + rawLists: 0, + rawMembers: 0, + keptLists: 0, + keptMembers: 0, + }, + ); + return; + } + setSessionLists(r.lists || []); + setListsLoaded(true); + }) + .catch(() => {}); + const unsubscribe = window.electronAPI.onSessionListsUpdated( + (_event: unknown, r: { lists?: SessionList[] } | undefined) => { + if (!r) return; + listsPushSeenRef.current = true; + setSessionLists(r.lists || []); + setListsLoaded(true); + // A push is an authoritative read by construction. + setListsStoreProblem(null); + }, + ); + return unsubscribe; + }, []); + + // A list that was deleted (here or by hand) cannot stay open. + useEffect(() => { + if (!listsLoaded || !viewingListId) return; + if (!sessionLists.some((l) => l.id === viewingListId)) setViewingListId(null); + }, [listsLoaded, sessionLists, viewingListId]); + + // Fetch by id the rows a scope needs that the loaded list does not have: + // the members of the list being viewed, and live sessions older than the + // window. Same key-compare as the pins fetch, so a re-render is free. + useEffect(() => { + const loaded = new Set(allSessions.map((s: any) => s.sessionId)); + const wanted = new Set(); + for (const m of viewingList?.members ?? []) wanted.add(m.sessionId); + for (const p of liveReport?.live ?? []) { + if (p.sessionId) wanted.add(p.sessionId); + } + const missing = [...wanted].filter((id) => !loaded.has(id)); + const key = missing.sort().join(','); + if (key === extraScopeKeyRef.current) return; + extraScopeKeyRef.current = key; + if (missing.length === 0) { + extraScopeSessionsRef.current = []; + setExtraScopeSessions([]); + return; + } + window.electronAPI + .getSessionsByIds(missing) + .then((result: any[]) => { + if (extraScopeKeyRef.current !== key) return; + const found = result || []; + extraScopeSessionsRef.current = found; + setExtraScopeSessions(found); + if (found.length === 0) return; + window.electronAPI.loadSessionEnrichment(found).then(applyEnrichment); + window.electronAPI + .loadLastAssistantResponses(found) + .then((responses: Record) => { + if (responses && Object.keys(responses).length > 0) { + setAssistantResponses((prev: Record) => ({ ...prev, ...responses })); + } + }); + }) + .catch(() => {}); + }, [viewingList, liveReport, allSessions]); + // Pinning/hiding inserts or removes rows above the selection, shifting every // index — without re-anchoring, the next ⌘D would act on an unintended row. // Re-anchor to the same session's LAST occurrence (the timeline copy). @@ -894,17 +1541,7 @@ function SwitcherApp() { extraPinnedSessionsRef.current = found; setExtraPinnedSessions(found); if (found.length === 0) return; - window.electronAPI.loadSessionEnrichment(found).then((enrichment) => { - if (enrichment.titles && Object.keys(enrichment.titles).length > 0) { - setCustomTitles((prev: Record) => ({ ...prev, ...enrichment.titles })); - } - if (enrichment.branches && Object.keys(enrichment.branches).length > 0) { - setBranches((prev: Record) => ({ ...prev, ...enrichment.branches })); - } - if (enrichment.prLinks && Object.keys(enrichment.prLinks).length > 0) { - setPrLinks((prev) => ({ ...prev, ...enrichment.prLinks })); - } - }); + window.electronAPI.loadSessionEnrichment(found).then(applyEnrichment); window.electronAPI.loadLastAssistantResponses(found).then((responses: Record) => { if (responses && Object.keys(responses).length > 0) { setAssistantResponses((prev: Record) => ({ ...prev, ...responses })); @@ -1027,35 +1664,22 @@ function SwitcherApp() { } // Load enrichment for ALL VS Code sessions (active + closed) in one call if (allVSCode.length > 0) { - window.electronAPI.loadSessionEnrichment(allVSCode).then((enrichment) => { - if (enrichment.titles && Object.keys(enrichment.titles).length > 0) { - setCustomTitles((prev: Record) => ({ ...prev, ...enrichment.titles })); - } - if (enrichment.branches && Object.keys(enrichment.branches).length > 0) { - setBranches((prev: Record) => ({ ...prev, ...enrichment.branches })); - } - if (enrichment.prLinks && Object.keys(enrichment.prLinks).length > 0) { - setPrLinks((prev) => ({ ...prev, ...enrichment.prLinks })); - } - }); + window.electronAPI.loadSessionEnrichment(allVSCode).then(applyEnrichment); } }); }); // Step 4: Load custom titles + branches in background if (result && result.length > 0) { - window.electronAPI.loadSessionEnrichment(result.slice(0, 100)).then((enrichment) => { - if (enrichment.titles && Object.keys(enrichment.titles).length > 0) { - setCustomTitles((prev: Record) => ({ ...prev, ...enrichment.titles })); - } - if (enrichment.branches && Object.keys(enrichment.branches).length > 0) { - setBranches((prev: Record) => ({ ...prev, ...enrichment.branches })); - } - if (enrichment.prLinks && Object.keys(enrichment.prLinks).length > 0) { - setPrLinks((prev) => ({ ...prev, ...enrichment.prLinks })); - } - }); + window.electronAPI.loadSessionEnrichment(result.slice(0, 100)).then(applyEnrichment); } + // The live report describes processes, which change independently of + // history.jsonl. Refresh it with EVERY session refetch, not only in the + // live scope: rows resolve their running pid from the report (it beats + // the cached detection map), so a report older than the map would hand a + // click a dead pid. One `ps` per popup open — `detectTerminalApps` on the + // same trigger spawns a few hundred. + refreshLiveReport(); }; const fetchWorkingFolderAndUpdate = async () => { @@ -1309,6 +1933,12 @@ function SwitcherApp() { // Drop the stale filtered list immediately so the empty input and the // visible list agree before fetchClaudeSessions() resolves. setSessions(allSessionsRef.current); + // The search is cleared here; a scope (a saved list, the live + // view) deliberately is NOT. A query is a way of finding one + // session, but a scope is a place to work through several — + // resume one, come back, resume the next — and being dropped out + // of it on every return was the complaint. Its header / chip keeps + // it visible, and ✕ / the chip leave it. } fetchClaudeSessions(); } @@ -1767,7 +2397,7 @@ function SwitcherApp() { } else if (e.key === 'Enter') { const idx = selectedSessionIndex >= 0 ? selectedSessionIndex : 0; const s = displayedSessions[idx]; - if (s) { + if (s && !s.__liveOrphan) { // Arm before opening, in case the bridge triggers the focus cycle synchronously. clearSessionSearchOnShowRef.current = true; window.electronAPI.openClaudeSession(s.sessionId, s.project, s.isActive, s.activePid, customTitles[s.sessionId]); @@ -1781,7 +2411,8 @@ function SwitcherApp() { e.preventDefault(); if (selectedSessionIndex < 0) return; const s = displayedSessions[selectedSessionIndex]; - if (s) { + // A process with no session id has nothing to pin or hide. + if (s && !s.__liveOrphan) { // ⇧⌘D on a zone row = unpin + fold (pin/hide are exclusive) if (e.shiftKey) { toggleHide(s); @@ -1804,14 +2435,280 @@ function SwitcherApp() { outline: 'none', }} /> + {/* Scope chips: live processes (issue #94) and saved lists + (issue #145). Here, not in the list, because this row has + spare width and the list has no spare height. */} + 1 ? 's' : ''} in ~/.claude/sessions` : ''}` + } + onMouseDown={(e) => e.preventDefault()} + onClick={toggleLiveOnly} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleLiveOnly(); + } + }} + style={liveOnlyActive ? SCOPE_CHIP_ACTIVE_STYLE : SCOPE_CHIP_STYLE} + > + ● {liveCount} live{staleCount > 0 ? ` ⚠${staleCount}` : ''} + + {liveOnlyActive && ( + e.preventDefault()} + onClick={() => setLiveStats((v) => !v)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setLiveStats((v) => !v); + } + }} + style={liveStats ? SCOPE_CHIP_ACTIVE_STYLE : SCOPE_CHIP_STYLE} + > + stats + + )} + {(liveOnlyActive || pinnedOnlyActive || isSearchingSessions) && !listViewActive && ( + e.preventDefault()} + onClick={(e) => { + // The document-level click handler refocuses the search box + // (forceFocusOnInput); let the dialog's input keep focus. + e.stopPropagation(); + openSaveListPrompt(); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openSaveListPrompt(); + } + }} + style={LISTS_CHIP_STYLE} + > + save list… + + )} + e.preventDefault()} + onClick={() => { + setListsExpanded((v) => !v); + setConfirmDeleteListId(null); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setListsExpanded((v) => !v); + } + }} + style={listsExpanded && !listViewActive ? LISTS_CHIP_ACTIVE_STYLE : LISTS_CHIP_STYLE} + > + 🗂 {sessionLists.length} + {/* Scoped modes must report what is on screen — an unscoped - count next to a pin-filtered list reads as a bug. */} - {pinnedOnlyActive ? displayedSessions.length : sessions.length} sessions + count next to a filtered list reads as a bug. */} + {listViewActive && viewingList + ? `${displayedSessions.length} of ${viewingList.members.length} in list` + : liveOnlyActive + ? // The chip already carries the live count; repeating it + // here read as a second, disagreeing number. Show the + // memory of what is listed, and a count only when a + // search has narrowed the list. + isSearchingSessions + ? `${displayedSessions.length} of ${liveCount} · ${formatMb(displayedRssKb)}` + : formatMb(displayedRssKb) + : `${pinnedOnlyActive ? displayedSessions.length : sessions.length} sessions`} + {/* The store exists but cannot be trusted as written. Say so, with + what it holds — never rewrite it from here (same policy as the + marks store; a future format change gets a migration, not a + repair button). */} + {listsStoreProblem && ( +
+ {listsStoreProblem.parseable + ? `⚠ ~/.config/codev/session-lists.json cannot be trusted as written (${listsStoreProblem.rawLists} list${listsStoreProblem.rawLists === 1 ? '' : 's'} / ${listsStoreProblem.rawMembers} session${listsStoreProblem.rawMembers === 1 ? '' : 's'} inside) — fix or remove it; saved lists are unavailable until then` + : '⚠ ~/.config/codev/session-lists.json is not valid JSON — fix or remove it; saved lists are unavailable until then'} +
+ )} + {listsNotice && ( +
+ ⚠ {listsNotice} +
+ )}
- {pinnedRows.length > 0 && ( + {/* A list being viewed: its header replaces every other zone. */} + {listViewActive && viewingList && ( +
+ + 🗂 {viewingList.name} ({viewingList.members.length}) · saved {formatRelativeTime(viewingList.createdAt)} + {' '} + e.preventDefault()} + onClick={(e) => { + e.stopPropagation(); + openRenameListPrompt(viewingList.id); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openRenameListPrompt(viewingList.id); + } + }} + style={{ cursor: 'pointer', color: '#666', fontSize: '11px' }} + > + ✎ + + + e.preventDefault()} + onClick={closeList} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + closeList(); + } + }} + style={LISTS_CHIP_STYLE} + > + ✕ close + +
+ )} + {/* Saved lists zone: one row per list; click to view it. Opens + even when empty — a chip that does nothing reads as broken. */} + {!listViewActive && listsExpanded && ( + <> +
+ 🗂 Lists ({sessionLists.length}) +
+ {sessionLists.length === 0 && ( +
+ No saved lists yet — turn on ● live, only or type a search, then click save list… +
+ )} + {sessionLists.map((l) => ( + // See LIST_WRAPPER_STYLE: opener + two controls as siblings. +
{ + // Only a click that landed on the wrapper itself; the + // opener and the controls handle their own. + if (e.target === e.currentTarget) openList(l.id); + }} + > +
e.preventDefault()} + onClick={() => openList(l.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openList(l.id); + } + }} + style={LIST_OPENER_STYLE} + > + {l.name} + + {l.members.length} sessions · {formatRelativeTime(l.createdAt)} + {' · '} + {l.members.slice(0, 4).map((m) => m.title || m.projectName).join(' · ')} + {l.members.length > 4 ? ' …' : ''} + +
+ e.preventDefault()} + onClick={(e) => { + e.stopPropagation(); + openRenameListPrompt(l.id); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + openRenameListPrompt(l.id); + } + }} + style={{ ...LIST_CONTROL_STYLE, color: '#666' }} + > + ✎ + + e.preventDefault()} + onClick={(e) => { + e.stopPropagation(); + if (confirmDeleteListId === l.id) deleteList(l.id); + else setConfirmDeleteListId(l.id); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + if (confirmDeleteListId === l.id) deleteList(l.id); + else setConfirmDeleteListId(l.id); + } + }} + style={{ + ...LIST_CONTROL_STYLE, + paddingRight: '10px', + color: confirmDeleteListId === l.id ? '#e07a5f' : '#666', + }} + > + {confirmDeleteListId === l.id ? 'delete?' : '✕'} + +
+ ))} +
+ + )} + {pinnedRows.length > 0 && !liveOnlyActive && !listViewActive && (
- {pinnedOnlyActive - ? '⚠️ No pinned session matches — click "only" above to leave pinned-only' - : sessionSearchValue - ? '⚠️ No matching sessions found' - : '🤖 No Claude Code sessions found'} + {listViewActive + ? sessionSearchValue + ? '⚠️ No session in this list matches' + : '🗂 This list is empty' + : liveOnlyActive + ? liveReport + ? sessionSearchValue + ? '⚠️ No running session matches' + : '● No running Claude Code session' + : '● Looking for running sessions…' + : pinnedOnlyActive + ? '⚠️ No pinned session matches — click "only" above to leave pinned-only' + : sessionSearchValue + ? '⚠️ No matching sessions found' + : '🤖 No Claude Code sessions found'}
) : (<> {displayedSessions.map((session, index) => ( - + // A synthetic live row is keyed by pid: two processes can + // share one sessionId (a resumed copy, a /branch parent and + // child), and duplicate keys leave stale rows on screen. + {visiblePinnedRows.length > 0 && index === visiblePinnedRows.length && (
)} @@ -1900,6 +2810,8 @@ function SwitcherApp() {
{ + // A running process with no session id has nothing to resume. + if (session.__liveOrphan) return; clearSessionSearchOnShowRef.current = true; window.electronAPI.openClaudeSession(session.sessionId, session.project, session.isActive, session.activePid, customTitles[session.sessionId]); }} @@ -1952,9 +2864,12 @@ function SwitcherApp() { highlightStyle={SEARCH_HIGHLIGHT_STYLE} /> - {customTitles[session.sessionId] && ( + {/* Title and branch fall back to what a saved list + captured, so a member whose transcript is gone + still reads as the session it was. */} + {(customTitles[session.sessionId] || session.__listMember?.title) && ( )} - {branches[session.sessionId] && ( + {(branches[session.sessionId] || session.__listMember?.branch) && ( {' '}[
- {index === selectedSessionIndex && ( + {index === selectedSessionIndex && !session.__liveOrphan && ( <> )} + {/* Process facts, live scope only: memory · uptime · tty, + and a warning when the process is running but not + registered — the case that hides from every other view. */} + {(() => { + if (!liveOnlyActive) return null; + // A synthetic row carries its own process; a real + // row looks its process up by id. Own facts first, + // or two processes on one id would show one set. + const live = session.__live || liveBySession[session.sessionId]; + if (!live) return null; + // Figures only behind the `stats` toggle; the + // warning below is not a figure and always shows. + // The tty is for the app (window switching, #142) + // and the tooltip — on the row it was width spent + // on something a person cannot act on. + return ( + <> + {liveStats && ( + + {formatMb(live.rssKb)} · {formatUptime(live.uptimeSec)} + + )} + {!live.registered && ( + + ⚠ unregistered + + )} + {session.__liveExtra && ( + + ⚠ 2nd process + + )} + + ); + })()} + {/* The id is searchable but never otherwise on screen, + so a hit on it gets its own marker (the same rule + as the `match #N` line — show what the row matched + on when the match is not already visible). */} + {isSearchingSessions && + !session.__liveOrphan && + searchWordsLower.some((w) => matchesSessionId(session.sessionId, w)) && ( + + id {session.sessionId.slice(0, 8)} + + )} {prLinks[session.sessionId] && (() => { const prInfo = prLinks[session.sessionId]; const searchWords = sessionSearchValue.split(/\s+/).filter(Boolean); @@ -2155,22 +3135,58 @@ function SwitcherApp() {
); })()} - {/* Line 3: Last assistant response */} - {assistantResponses[session.sessionId] && ( -
- - ◀ - -
- )} + {/* Line 3: on a saved-list member, the recap captured + with it — Claude Code's own "where we are, what's + next" line, which is what a snapshot is for. Otherwise + the last assistant response. One line either way. */} + {(() => { + const captured = session.__listMember; + const recap = captured?.recap; + if (recap) { + // A recap never repeats back-to-back, so it can predate + // the session's last turn by a lot; say so when it does, + // because its last sentence is usually "next: …". + const writtenAt = recap.at ? new Date(recap.at).getTime() : 0; + const lagMs = writtenAt ? (captured.lastTimestamp || 0) - writtenAt : 0; + const stale = lagMs > 30 * 60 * 1000; + return ( +
+ + + recap{stale ? ' ⏱' : ''} + {' '} + + +
+ ); + } + const reply = assistantResponses[session.sessionId] || captured?.lastAssistantMessage; + if (!reply) return null; + return ( +
+ + ◀ + +
+ ); + })()}
@@ -2543,6 +3559,74 @@ function SwitcherApp() { )}
))} + {/* Save-list dialog. Top level, outside the mode branches: it is opened + from the Sessions tab, and a modal nested inside the Projects branch + never renders there (the first live test found exactly that). */} + {saveListPrompt && ( +
+
e.stopPropagation()} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === 'Escape') { + closeSaveListPrompt(); + } else if (e.key === 'Enter') { + e.preventDefault(); + saveList(); + } + }} + style={{ + marginTop: '120px', + minWidth: '360px', + background: '#252526', + border: '1px solid #454545', + borderRadius: '8px', + padding: '10px', + boxShadow: '0 8px 24px rgba(0, 0, 0, 0.5)', + }} + > +
+ {saveListPrompt.renameId + ? `Rename this list (${saveListPrompt.count} session${saveListPrompt.count === 1 ? '' : 's'}) — Enter · Esc` + : `Save ${saveListPrompt.count} session${saveListPrompt.count === 1 ? '' : 's'} as a list (Enter · Esc)`} +
+ setSaveListPrompt({ ...saveListPrompt, name: e.target.value })} + onFocus={(e) => e.target.select()} + placeholder={ + saveListPrompt.renameId + ? sessionLists.find((l) => l.id === saveListPrompt.renameId)?.name + : nextListName(sessionLists.map((l) => l.name)) + } + style={{ + width: '100%', + boxSizing: 'border-box', + backgroundColor: '#2d2d2d', + border: '1px solid #444', + borderRadius: '4px', + padding: '8px 10px', + color: THEME.text.primary, + fontSize: '13px', + outline: 'none', + }} + /> +
+
+ )}
); }