From 66376cf890e592ed0d8bd292585e2f91bd83d973 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sat, 5 Sep 2026 05:13:37 +0800 Subject: [PATCH 01/14] feat(sessions): saved session lists + live-process view Session Buddy model (issues #145, #94; tracking #144): a 'live' scope showing running sessions with memory/uptime/tty, joined from ps against ~/.claude/sessions so an unregistered process or a stale registration is shown for what it is; 'save list' captures what is on screen as a named list, each member carrying title/branch/pin state/last messages and the transcript's away_summary recap; lists are browsable and resumable per row. No 'open all' by design. Shared atomic-json-store extracted from the marks store; enrichment grows a recap pattern; list-view scopes with precedence; docs 4.8 plus the 5.2/7.4 corrections cubic flagged on #143. 39 new tests. --- CHANGELOG.md | 11 + docs/session-finding-plan.md | 75 +++- package.json | 2 +- src/atomic-json-store.ts | 163 +++++++ src/claude-session-utility.ts | 42 +- src/electron-api.d.ts | 66 ++- src/live-sessions.test.ts | 205 +++++++++ src/live-sessions.ts | 284 ++++++++++++ src/main.ts | 170 ++++++-- src/preload.ts | 13 + src/session-list-view.test.ts | 126 ++++++ src/session-list-view.ts | 139 +++++- src/session-lists.test.ts | 188 ++++++++ src/session-lists.ts | 230 ++++++++++ src/session-marks.ts | 155 ++----- src/switcher-ui.tsx | 796 ++++++++++++++++++++++++++++++---- 16 files changed, 2405 insertions(+), 260 deletions(-) create mode 100644 src/atomic-json-store.ts create mode 100644 src/live-sessions.test.ts create mode 100644 src/live-sessions.ts create mode 100644 src/session-lists.test.ts create mode 100644 src/session-lists.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 67db9da..6f41932 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # 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 and shows each one's **memory, uptime and terminal** — the question "what is actually running and what is it costing" now has an answer in the app. 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 + - **`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. 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 + - 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. 39 new unit tests (lists normalize / transitions / file roundtrip, `ps` parsing and the live join, list-view scopes) — 124 total + ## 1.0.86 - Feat: session rows are readable again when titles are long diff --git a/docs/session-finding-plan.md b/docs/session-finding-plan.md index 7cf1447..ce6e584 100644 --- a/docs/session-finding-plan.md +++ b/docs/session-finding-plan.md @@ -326,6 +326,64 @@ 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. + +**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 stores sessionIds, so §4.7 applies +unchanged: after a branch, the member 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. + ## 5. Batch 2 — structural investments ### 5.1 C4: preview / detail (v1 card → v2 pane) @@ -367,8 +425,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 +458,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..39a540b --- /dev/null +++ b/src/atomic-json-store.ts @@ -0,0 +1,163 @@ +/** + * 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 + const tmp = `${filePath}.tmp-${process.pid}`; + fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n'); + 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..ba0e191 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -96,6 +96,7 @@ export const invalidateSessionCache = () => { cachedCustomTitles = null; cachedBranches = null; cachedPRLinks = null; + cachedRecaps = null; enrichedFileState.clear(); }; @@ -1709,15 +1710,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 +1789,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 +1856,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 +1912,29 @@ 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 {} + } + // 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 +1945,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..7c98333 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,42 @@ 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; + }>; + 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; + }[]; + staleRegistrations: { pid: number; sessionId: string; cwd: string }[]; + totalRssKb: number; + measuredAt: number; + }>; + // Claude Code sessions getClaudeSessions: (limit?: number) => Promise; searchClaudeSessions: (query: string) => Promise<{ @@ -170,7 +228,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..492445d --- /dev/null +++ b/src/live-sessions.test.ts @@ -0,0 +1,205 @@ +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 and non-claude processes', () => { + for (const pid of [22140, 22197, 22211, 1447]) { + expect(isSessionProcess(by(pid))).toBe(false); + } + }); +}); + +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('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..8b7613b --- /dev/null +++ b/src/live-sessions.ts @@ -0,0 +1,284 @@ +/** + * 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; +} + +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; +} + +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', +]); + +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. */ +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 true; +}; + +/** `--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, + }); + } + 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; + } + 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, + }); + } 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 || ''), + ); + }); + +/** 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='], 3000)); + const readRegs = deps.readRegistrations ?? readSessionRegistrations; + const cwdOf = deps.cwdOf ?? lsofCwd; + + const report = joinLiveSessions(parsePsOutput(await ps()), 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..127ace9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -41,6 +41,17 @@ import { withoutPin, withPin, } from './session-marks'; +import { + mutateSessionLists, + normalizeList, + readSessionListsResult, + watchSessionLists, + withList, + withoutList, + withRenamedList, +} from './session-lists'; +import { collectLiveSessions } from './live-sessions'; +import { randomUUID } from 'crypto'; import { installHooks, removeHooks, @@ -2346,42 +2357,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 +2498,71 @@ 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(); + return { ...read.value, known: read.known }; +}); + +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) { + console.error('[live-sessions] collect failed:', err); + return { live: [], staleRegistrations: [], totalRssKb: 0, measuredAt: Date.now() }; + } +}); + ipcMain.handle('detect-active-sessions', async () => { const { activeMap, vscodeSessions, entrypoints } = await detectActiveSessions(); return { @@ -2520,11 +2619,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..aca9025 100644 --- a/src/session-list-view.test.ts +++ b/src/session-list-view.test.ts @@ -168,6 +168,132 @@ 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('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); + }); +}); + 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..5ea8d91 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,10 @@ 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; [key: string]: unknown; } @@ -53,6 +66,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 +95,16 @@ 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 with no session (`__liveOrphan`). */ + 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 +121,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; } @@ -148,6 +182,36 @@ const resolvePinnedRow = ( }; }; +/** + * 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, +): 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, + isActive: s.sessionId in activePids || s.isActive, + activePid: activePids[s.sessionId] ?? s.activePid, + }; +}; + export const buildSessionListView = ({ sessions, allSessions, @@ -162,12 +226,23 @@ 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. @@ -177,11 +252,15 @@ export const buildSessionListView = ({ for (const s of sessions) { 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 && // 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. @@ -238,22 +317,44 @@ 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)); + } else if (liveOnlyActive) { + // Orphans have nothing a query could match, so they step aside while + // searching rather than sitting under every result as noise. + displayedSessions = isSearching + ? majorSessions + : [...majorSessions, ...liveOrphans]; + } 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 +365,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..20d4b08 --- /dev/null +++ b/src/session-lists.test.ts @@ -0,0 +1,188 @@ +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, + 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('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 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('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..94283f6 --- /dev/null +++ b/src/session-lists.ts @@ -0,0 +1,230 @@ +/** + * 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 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: [] }); + +const capText = (value: unknown, max: number): string | undefined => { + if (typeof value !== 'string') return undefined; + const t = value.trim(); + if (!t) return undefined; + return t.length > max ? t.slice(0, max) : t; +}; + +/** 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) || + project.split('/').filter(Boolean).pop() || + 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); + +const defaultListsPath = (): string => + path.join(os.homedir(), '.config', 'codev', LISTS_FILENAME); + +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/switcher-ui.tsx b/src/switcher-ui.tsx index 6be6867..654335d 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -5,11 +5,15 @@ 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'; + +type LiveReport = Awaited>; import { truncateMiddle, windowAroundMatch } from './session-search'; import TerminalTab from './terminal-tab'; @@ -74,6 +78,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 +113,88 @@ 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; + +// 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 { @@ -361,6 +451,28 @@ 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: the date, the way the user names them. */ +const defaultListName = (): string => { + const d = new Date(); + return `${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`; +}; + /** Caution it will be invoked twice due to !! */ let loadTimes = 0; function SwitcherApp() { @@ -493,6 +605,32 @@ 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); + 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 [saveListPrompt, setSaveListPrompt] = useState<{ name: string; count: number } | 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(''); // Keep the selection on the same session after pin/hide reshuffles the list const reanchorSelectionRef = useRef(null); const hoverSuppressTokenRef = useRef(0); @@ -552,7 +690,10 @@ function SwitcherApp() { // one that empties the box, and widening the browse list is not this // function's job. const candidates = query.trim() - ? mergeSessionsById(allItems, extraPinnedSessionsRef.current) + ? mergeSessionsById( + mergeSessionsById(allItems, extraPinnedSessionsRef.current), + extraScopeSessionsRef.current, + ) : allItems; const base = filterSessionsLocally(candidates, query); if (!query.trim() || deepMatchesRef.current.length === 0) return base; @@ -572,6 +713,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 +766,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 +806,7 @@ function SwitcherApp() { assistantResponses, terminalApps, extraPinnedSessions, + extraScopeSessions, ]); const isSearchingSessions = sessionSearchValue.trim().length > 0; @@ -668,9 +823,48 @@ 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; + // Process facts by session, plus a synthetic row per running process that + // no session explains — the "unregistered" case the live view exists for. + const liveBySession: Record = {}; + const liveOrphans: ListViewSession[] = []; + for (const p of liveReport?.live ?? []) { + const info: LiveRowInfo = { + pid: p.pid, + rssKb: p.rssKb, + tty: p.tty, + uptimeSec: p.uptimeSec, + registered: p.registered, + }; + if (p.sessionId) { + liveBySession[p.sessionId] = info; + } else { + liveOrphans.push({ + sessionId: `pid:${p.pid}`, + project: p.cwd || '', + projectName: (p.cwd || '').split('/').filter(Boolean).pop() || `pid ${p.pid}`, + firstUserMessage: '', + lastUserMessage: '', + lastTimestamp: 0, + messageCount: undefined, + isActive: true, + activePid: p.pid, + __liveOrphan: true, + __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 +873,11 @@ function SwitcherApp() { minorFoldHeaderIndex, hiddenMinorCount, pinnedOnlyActive, + liveOnlyActive, + listViewActive, canGroupPins, } = buildSessionListView({ - sessions, + sessions: scopedSessions, allSessions, extraPinnedSessions, pins: sessionMarks.pins, @@ -694,7 +890,16 @@ 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; // Manually hidden sessions may be titled/long — keep the fold label honest. const minorFoldSuffix = hiddenMinorCount > 0 @@ -801,6 +1006,167 @@ function SwitcherApp() { setSelectedSessionIndex(0); }; + // --- Live scope (issue #94) --- + const refreshLiveReport = () => { + window.electronAPI + .getLiveSessions() + .then((r) => { + if (r) setLiveReport(r); + }) + .catch(() => {}); + }; + 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]); + const applyListsResult = (r: any) => { + if (r?.ok && r.lists) { + setSessionLists(r.lists.lists || []); + setListsLoaded(true); + } + }; + const openList = (id: string) => { + liveOnlyRef.current = false; + setLiveOnly(false); + setViewingListId(id); + setConfirmDeleteListId(null); + setSelectedSessionIndex(0); + }; + const closeList = () => { + setViewingListId(null); + setSelectedSessionIndex(0); + }; + const deleteList = (id: string) => { + window.electronAPI + .deleteSessionList(id) + .then((r) => { + applyListsResult(r); + setConfirmDeleteListId(null); + if (viewingListId === id) setViewingListId(null); + }) + .catch(() => {}); + }; + // 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: defaultListName(), count }); + }; + const saveList = () => { + if (!saveListPrompt) return; + const members = captureDisplayedSessions(); + const name = saveListPrompt.name.trim() || defaultListName(); + setSaveListPrompt(null); + window.electronAPI + .saveSessionList(name, members) + .then((r) => { + applyListsResult(r); + if (r?.ok) setListsExpanded(true); + }) + .catch(() => {}); + }; + + // 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: any) => { + if (!r || listsPushSeenRef.current || r.known === false) return; + setSessionLists(r.lists || []); + setListsLoaded(true); + }) + .catch(() => {}); + const unsubscribe = window.electronAPI.onSessionListsUpdated( + (_event: any, r: any) => { + if (!r) return; + listsPushSeenRef.current = true; + setSessionLists(r.lists || []); + setListsLoaded(true); + }, + ); + 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 +1260,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 +1383,18 @@ 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 while in scope. + if (liveOnlyRef.current) refreshLiveReport(); }; const fetchWorkingFolderAndUpdate = async () => { @@ -1309,6 +1648,11 @@ function SwitcherApp() { // Drop the stale filtered list immediately so the empty input and the // visible list agree before fetchClaudeSessions() resolves. setSessions(allSessionsRef.current); + // A scope is a way of finding one session; once it is opened, the + // next show starts from the full list, like the search does. + setViewingListId(null); + liveOnlyRef.current = false; + setLiveOnly(false); } fetchClaudeSessions(); } @@ -1767,7 +2111,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]); @@ -1804,14 +2148,171 @@ 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 || pinnedOnlyActive || isSearchingSessions) && !listViewActive && ( + e.preventDefault()} + onClick={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 && sessionLists.length > 0 ? 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 + ? `${displayedSessions.length} live${liveReport ? ` · ${formatMb(liveReport.totalRssKb)}` : ''}` + : `${pinnedOnlyActive ? displayedSessions.length : sessions.length} sessions`}
- {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={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. */} + {!listViewActive && listsExpanded && sessionLists.length > 0 && ( + <> +
+ 🗂 Lists ({sessionLists.length}) +
+ {sessionLists.map((l) => ( +
e.preventDefault()} + onClick={() => openList(l.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openList(l.id); + } + }} + style={LIST_ROW_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(); + 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={{ + cursor: 'pointer', + fontSize: '11px', + flexShrink: 0, + 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) => ( @@ -1900,6 +2411,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 +2465,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) && ( {' '}[ )} + {/* 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; + const live = + liveBySession[session.sessionId] || + (session.__live as LiveRowInfo | undefined); + if (!live) return null; + return ( + <> + + {formatMb(live.rssKb)} · {formatUptime(live.uptimeSec)} + {live.tty ? ` · ${live.tty}` : ''} + + {!live.registered && ( + + ⚠ unregistered + + )} + + ); + })()} {prLinks[session.sessionId] && (() => { const prInfo = prLinks[session.sessionId]; const searchWords = sessionSearchValue.split(/\s+/).filter(Boolean); @@ -2155,22 +2700,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 ( +
+ + ◀ + +
+ ); + })()}
@@ -2461,6 +3042,65 @@ function SwitcherApp() { }), }} /> + {saveListPrompt && ( +
setSaveListPrompt(null)} + style={{ + position: 'fixed', + inset: 0, + background: 'rgba(0, 0, 0, 0.45)', + zIndex: 1000, + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'center', + }} + > +
e.stopPropagation()} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === 'Escape') { + setSaveListPrompt(null); + } 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)', + }} + > +
+ 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={defaultListName()} + 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', + }} + /> +
+
+ )} {launchPicker && (
Date: Sat, 5 Sep 2026 14:50:37 +0800 Subject: [PATCH 02/14] fix(sessions): render the save-list dialog, list every live process First live test: the dialog was nested in the Projects branch so it never rendered from Sessions; the live chip said 33 while the list had 32 rows (a live id the loaded list did not know got no row); the lists chip did nothing at zero lists; the memory figure was the total, not the rows shown. Also: sessionId is searchable on both paths, with an id marker on a hit. --- CHANGELOG.md | 1 + src/claude-session-utility.ts | 4 +- src/session-list-view.test.ts | 13 ++ src/session-list-view.ts | 20 ++- src/switcher-ui.tsx | 226 +++++++++++++++++++++------------- 5 files changed, 175 insertions(+), 89 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f41932..d58df7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - **`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. 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), 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)). A row that matched on its id shows an `id 4ed7505a` marker, since the id is not otherwise on screen - 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. 39 new unit tests (lists normalize / transitions / file roundtrip, `ps` parsing and the live join, list-view scopes) — 124 total diff --git a/src/claude-session-utility.ts b/src/claude-session-utility.ts index ba0e191..d19561e 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -228,8 +228,10 @@ export const searchClaudeSessions = ( for (const s of allSessions) { const sessionPrompts = promptsBySession.get(s.sessionId) || []; + // sessionId included so the id shown in a terminal status line finds the + // session; the renderer's filterSessionsLocally matches the same field. const target = - `${s.projectName} ${s.project} ${sessionPrompts.join('\n')}`.toLowerCase(); + `${s.sessionId} ${s.projectName} ${s.project} ${sessionPrompts.join('\n')}`.toLowerCase(); if (!matchesAllWords(target, words)) continue; sessions.push(s); diff --git a/src/session-list-view.test.ts b/src/session-list-view.test.ts index aca9025..5da4ba5 100644 --- a/src/session-list-view.test.ts +++ b/src/session-list-view.test.ts @@ -196,6 +196,19 @@ describe('buildSessionListView — live scope', () => { 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. + const synthetic = { sessionId: 'active', lastTimestamp: 0, __live: {} }; + const v = build({ + sessions: [active, 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('recognises liveness from the live report, not only from the active map', () => { const v = build({ sessions: [recent, middle], diff --git a/src/session-list-view.ts b/src/session-list-view.ts index 5ea8d91..eab1f2f 100644 --- a/src/session-list-view.ts +++ b/src/session-list-view.ts @@ -99,7 +99,12 @@ export interface BuildListViewArgs { liveOnly?: boolean; /** sessionId -> process facts, from the live-sessions report. */ liveBySession?: Record; - /** Rows synthesized for running processes with no session (`__liveOrphan`). */ + /** + * 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; @@ -340,11 +345,18 @@ export const buildSessionListView = ({ .filter((m) => !matched || matched.has(m.sessionId)) .map((m) => resolveMemberRow(m, byId, activePids)); } else if (liveOnlyActive) { - // Orphans have nothing a query could match, so they step aside while - // searching rather than sitting under every result as noise. + // 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 whose id a real row already covers is dropped — the + // renderer builds them from a snapshot that can lag the loaded list by + // one render. + const shown = new Set(majorSessions.map((s) => s.sessionId)); displayedSessions = isSearching ? majorSessions - : [...majorSessions, ...liveOrphans]; + : [ + ...majorSessions, + ...liveOrphans.filter((s) => !shown.has(s.sessionId)), + ]; } 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. diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 654335d..d7e976c 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -672,7 +672,11 @@ 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(); + // sessionId is searchable so a session can be found from the id a + // terminal status line shows — the one field that is unique when + // several sessions share a name (#142). Must stay in step with the + // main-side target in searchClaudeSessions. + const searchTarget = `${s.sessionId} ${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)); }); }; @@ -825,10 +829,17 @@ function SwitcherApp() { const hasPins = Object.keys(sessionMarks.pins).length > 0; const viewingList = viewingListId ? sessionLists.find((l) => l.id === viewingListId) ?? null : null; - // Process facts by session, plus a synthetic row per running process that - // no session explains — the "unregistered" case the live view exists for. + // 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 knownIds = new Set(); + for (const s of allSessions) knownIds.add(s.sessionId); + for (const s of extraScopeSessions) knownIds.add(s.sessionId); for (const p of liveReport?.live ?? []) { const info: LiveRowInfo = { pid: p.pid, @@ -837,23 +848,21 @@ function SwitcherApp() { uptimeSec: p.uptimeSec, registered: p.registered, }; - if (p.sessionId) { - liveBySession[p.sessionId] = info; - } else { - liveOrphans.push({ - sessionId: `pid:${p.pid}`, - project: p.cwd || '', - projectName: (p.cwd || '').split('/').filter(Boolean).pop() || `pid ${p.pid}`, - firstUserMessage: '', - lastUserMessage: '', - lastTimestamp: 0, - messageCount: undefined, - isActive: true, - activePid: p.pid, - __liveOrphan: true, - __live: info, - }); - } + if (p.sessionId) liveBySession[p.sessionId] = info; + if (p.sessionId && knownIds.has(p.sessionId)) continue; + liveOrphans.push({ + sessionId: p.sessionId || `pid:${p.pid}`, + project: p.cwd || '', + projectName: (p.cwd || '').split('/').filter(Boolean).pop() || `pid ${p.pid}`, + firstUserMessage: '', + lastUserMessage: '', + lastTimestamp: 0, + messageCount: undefined, + isActive: true, + activePid: p.pid, + __liveOrphan: !p.sessionId, + __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 @@ -900,6 +909,14 @@ function SwitcherApp() { ? 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 = liveBySession[s.sessionId] || (s.__live as LiveRowInfo | undefined); + return sum + (live?.rssKb ?? 0); + }, 0) + : 0; // Manually hidden sessions may be titled/long — keep the fold label honest. const minorFoldSuffix = hiddenMinorCount > 0 @@ -2178,7 +2195,12 @@ function SwitcherApp() { tabIndex={0} title="Save the sessions shown as a named list" onMouseDown={(e) => e.preventDefault()} - onClick={openSaveListPrompt} + 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(); @@ -2212,7 +2234,7 @@ function SwitcherApp() { setListsExpanded((v) => !v); } }} - style={listsExpanded && sessionLists.length > 0 ? LISTS_CHIP_ACTIVE_STYLE : LISTS_CHIP_STYLE} + style={listsExpanded && !listViewActive ? LISTS_CHIP_ACTIVE_STYLE : LISTS_CHIP_STYLE} > 🗂 {sessionLists.length} @@ -2222,7 +2244,13 @@ function SwitcherApp() { {listViewActive && viewingList ? `${displayedSessions.length} of ${viewingList.members.length} in list` : liveOnlyActive - ? `${displayedSessions.length} live${liveReport ? ` · ${formatMb(liveReport.totalRssKb)}` : ''}` + ? // 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`}
@@ -2251,12 +2279,18 @@ function SwitcherApp() { )} - {/* Saved lists zone: one row per list; click to view it. */} - {!listViewActive && listsExpanded && sessionLists.length > 0 && ( + {/* 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) => (
); })()} + {/* 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) => session.sessionId.toLowerCase().includes(w)) && ( + + id {session.sessionId.slice(0, 8)} + + )} {prLinks[session.sessionId] && (() => { const prInfo = prLinks[session.sessionId]; const searchWords = sessionSearchValue.split(/\s+/).filter(Boolean); @@ -3042,65 +3097,6 @@ function SwitcherApp() { }), }} /> - {saveListPrompt && ( -
setSaveListPrompt(null)} - style={{ - position: 'fixed', - inset: 0, - background: 'rgba(0, 0, 0, 0.45)', - zIndex: 1000, - display: 'flex', - alignItems: 'flex-start', - justifyContent: 'center', - }} - > -
e.stopPropagation()} - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === 'Escape') { - setSaveListPrompt(null); - } 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)', - }} - > -
- 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={defaultListName()} - 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', - }} - /> -
-
- )} {launchPicker && (
))} + {/* 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 && ( +
setSaveListPrompt(null)} + style={{ + position: 'fixed', + inset: 0, + background: 'rgba(0, 0, 0, 0.45)', + zIndex: 1000, + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'center', + }} + > +
e.stopPropagation()} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === 'Escape') { + setSaveListPrompt(null); + } 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)', + }} + > +
+ 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={defaultListName()} + 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', + }} + /> +
+
+ )}
); } From ccf4d450827016f9412806858dc593f6df8e8fa4 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sat, 5 Sep 2026 15:54:53 +0800 Subject: [PATCH 03/14] fix(sessions): lists store fixed point, ps failure, member liveness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second live test round. (1) The store's normalizer was not a fixed point: a text cap landing on a space wrote a trailing blank the next read trimmed, so the file the app had just written was refused as non-authoritative and every later save/delete was silently rejected — cap then trim again, with a test that fails against the old code, and refusals now show in the UI. (2) A timed-out ps read as 'nothing running, every registration stale' ("0 live ⚠33"); empty ps output is now a failure that keeps the previous report. (3) A saved-list member (or pin placeholder) with no history row took its running state only from the registration map, so a click resumed a second copy instead of switching; rows now consult the ps join too, and viewing a list refreshes it. (4) Synthetic live rows keyed by pid, so two processes on one id cannot leave stale rows. Also: tty moved off the row into the tooltip, MMDD-2 default names, list rows highlight on hover. --- CHANGELOG.md | 6 +-- docs/session-finding-plan.md | 20 ++++++++- src/live-sessions.ts | 20 ++++++++- src/main.ts | 4 +- src/session-list-view.test.ts | 27 ++++++++++++ src/session-list-view.ts | 33 ++++++++++++--- src/session-lists.test.ts | 25 ++++++++++++ src/session-lists.ts | 11 ++++- src/switcher-ui.tsx | 77 +++++++++++++++++++++++++++++------ 9 files changed, 196 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d58df7a..39d65da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,14 +3,14 @@ ## 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 and shows each one's **memory, uptime and terminal** — the question "what is actually running and what is it costing" now has an answer in the app. 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 + - **`● N live` chip** next to the search box scopes the list to sessions with a running process and shows each one's **memory and uptime** (pid and terminal device in the tooltip) — the question "what is actually running and what is it costing" now has an answer in the app. 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. 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), 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)). A row that matched on its id shows an `id 4ed7505a` marker, since the id is not otherwise on screen - 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. 39 new unit tests (lists normalize / transitions / file roundtrip, `ps` parsing and the live join, list-view scopes) — 124 total + - 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. 42 new unit tests (lists normalize / transitions / file roundtrip / normalizer fixed point, `ps` parsing and the live join, list-view scopes) — 127 total ## 1.0.86 diff --git a/docs/session-finding-plan.md b/docs/session-finding-plan.md index ce6e584..0fc7c54 100644 --- a/docs/session-finding-plan.md +++ b/docs/session-finding-plan.md @@ -382,7 +382,25 @@ 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. +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.** Memory and uptime, not the tty: a person cannot act on a tty name, +and the width was coming out of the title. The tty stays in the tooltip and in the data, where +the future window-switching (#142 C0) needs it. A pure "running sessions only" browse with no +figures at all is a different scope, and the cheap form of it is a `is:live` term in #140's +field-scoped search, not a second chip. ## 5. Batch 2 — structural investments diff --git a/src/live-sessions.ts b/src/live-sessions.ts index 8b7613b..1bac3de 100644 --- a/src/live-sessions.ts +++ b/src/live-sessions.ts @@ -244,6 +244,16 @@ const execFileP = ( ); }); +/** + * `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( @@ -266,11 +276,17 @@ export const collectLiveSessions = async ( ): Promise => { const ps = deps.ps ?? - (() => execFileP('ps', ['-Ao', 'pid=,rss=,tty=,etime=,args='], 3000)); + (() => + execFileP('ps', ['-Ao', 'pid=,rss=,tty=,etime=,args='], PS_TIMEOUT_MS)); const readRegs = deps.readRegistrations ?? readSessionRegistrations; const cwdOf = deps.cwdOf ?? lsofCwd; - const report = joinLiveSessions(parsePsOutput(await ps()), readRegs()); + 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( diff --git a/src/main.ts b/src/main.ts index 127ace9..2f281e3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2558,8 +2558,10 @@ 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 { live: [], staleRegistrations: [], totalRssKb: 0, measuredAt: Date.now() }; + return null; } }); diff --git a/src/session-list-view.test.ts b/src/session-list-view.test.ts index 5da4ba5..43e1412 100644 --- a/src/session-list-view.test.ts +++ b/src/session-list-view.test.ts @@ -305,6 +305,33 @@ describe('buildSessionListView — saved-list scope', () => { 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', () => { diff --git a/src/session-list-view.ts b/src/session-list-view.ts index eab1f2f..cb1d609 100644 --- a/src/session-list-view.ts +++ b/src/session-list-view.ts @@ -159,6 +159,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 @@ -182,11 +183,29 @@ 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, either wins: + * the registration-based detection (`activePids`) and the `ps` join + * (`liveBySession`). The second exists because the first 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: + activePids[s.sessionId] ?? liveBySession[s.sessionId]?.pid ?? s.activePid, +}); + /** * 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 @@ -197,6 +216,7 @@ const resolveMemberRow = ( member: SessionListMember, byId: Map, activePids: Record, + liveBySession: Record, ): ListViewSession => { const s = byId.get(member.sessionId) ?? { sessionId: member.sessionId, @@ -212,8 +232,7 @@ const resolveMemberRow = ( return { ...s, __listMember: member, - isActive: s.sessionId in activePids || s.isActive, - activePid: activePids[s.sessionId] ?? s.activePid, + ...activeFrom(s, activePids, liveBySession), }; }; @@ -296,7 +315,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 @@ -343,7 +364,7 @@ export const buildSessionListView = ({ : null; displayedSessions = viewingList.members .filter((m) => !matched || matched.has(m.sessionId)) - .map((m) => resolveMemberRow(m, byId, activePids)); + .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 diff --git a/src/session-lists.test.ts b/src/session-lists.test.ts index 20d4b08..7a80c93 100644 --- a/src/session-lists.test.ts +++ b/src/session-lists.test.ts @@ -107,6 +107,31 @@ describe('normalizeLists', () => { 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 = { diff --git a/src/session-lists.ts b/src/session-lists.ts index 94283f6..5d4d978 100644 --- a/src/session-lists.ts +++ b/src/session-lists.ts @@ -74,11 +74,20 @@ export interface SessionLists { 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; - return t.length > max ? t.slice(0, max) : t; + const capped = t.length > max ? t.slice(0, max).trim() : t; + return capped || undefined; }; /** Coerce one unknown member record; null when it cannot be a member at all. */ diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index d7e976c..c974ab9 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -228,6 +228,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 @@ -467,10 +472,20 @@ const formatMb = (kb: number): string => { return mb >= 1024 ? `${(mb / 1024).toFixed(1)}GB` : `${Math.round(mb)}MB`; }; -/** Default name for a saved list: the date, the way the user names them. */ -const defaultListName = (): string => { +/** + * 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(); - return `${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`; + 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 !! */ @@ -625,6 +640,7 @@ function SwitcherApp() { }); const [viewingListId, setViewingListId] = useState(null); const [confirmDeleteListId, setConfirmDeleteListId] = useState(null); + const [listsNotice, setListsNotice] = useState(null); const [saveListPrompt, setSaveListPrompt] = useState<{ name: string; count: number } | 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. @@ -1050,10 +1066,25 @@ function SwitcherApp() { // 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: any) => { 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 could not be read — fix or remove it' + : `Not saved: ${r.error || 'unknown error'}`, + ); } }; const openList = (id: string) => { @@ -1062,6 +1093,11 @@ function SwitcherApp() { 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); @@ -1073,9 +1109,9 @@ function SwitcherApp() { .then((r) => { applyListsResult(r); setConfirmDeleteListId(null); - if (viewingListId === id) setViewingListId(null); + if (r?.ok && viewingListId === id) setViewingListId(null); }) - .catch(() => {}); + .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 @@ -1100,12 +1136,13 @@ function SwitcherApp() { const openSaveListPrompt = () => { const count = displayedSessions.filter((s) => !s.__liveOrphan).length; if (count === 0) return; - setSaveListPrompt({ name: defaultListName(), count }); + setSaveListPrompt({ name: nextListName(sessionLists.map((l) => l.name)), count }); }; const saveList = () => { if (!saveListPrompt) return; const members = captureDisplayedSessions(); - const name = saveListPrompt.name.trim() || defaultListName(); + const name = + saveListPrompt.name.trim() || nextListName(sessionLists.map((l) => l.name)); setSaveListPrompt(null); window.electronAPI .saveSessionList(name, members) @@ -1113,7 +1150,7 @@ function SwitcherApp() { applyListsResult(r); if (r?.ok) setListsExpanded(true); }) - .catch(() => {}); + .catch(() => showListsNotice('save failed')); }; // Load lists once + subscribe to main-side pushes — the same shape, and the @@ -2254,6 +2291,11 @@ function SwitcherApp() { : `${pinnedOnlyActive ? displayedSessions.length : sessions.length} sessions`}
+ {listsNotice && ( +
+ ⚠ {listsNotice} +
+ )}
{/* A list being viewed: its header replaces every other zone. */} {listViewActive && viewingList && ( @@ -2305,6 +2347,7 @@ function SwitcherApp() { openList(l.id); } }} + className="codev-list-row" style={LIST_ROW_STYLE} > {l.name} @@ -2418,7 +2461,10 @@ function SwitcherApp() {
) : (<> {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 && (
)} @@ -2562,10 +2608,16 @@ function SwitcherApp() { 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 = - liveBySession[session.sessionId] || - (session.__live as LiveRowInfo | undefined); + (session.__live as LiveRowInfo | undefined) || + liveBySession[session.sessionId]; if (!live) return null; + // The tty is for the app (window switching, #142) and + // for the tooltip; on the row it was width spent on + // something a person cannot act on. return ( <> {formatMb(live.rssKb)} · {formatUptime(live.uptimeSec)} - {live.tty ? ` · ${live.tty}` : ''} {!live.registered && ( setSaveListPrompt({ ...saveListPrompt, name: e.target.value })} onFocus={(e) => e.target.select()} - placeholder={defaultListName()} + placeholder={nextListName(sessionLists.map((l) => l.name))} style={{ width: '100%', boxSizing: 'border-box', From 697f9fdaedd3e95f015bfad44659c92d8f206704 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sat, 5 Sep 2026 16:23:51 +0800 Subject: [PATCH 04/14] fix(sessions): report an untrusted lists store, stats toggle off by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third live test round. A lists file the app cannot trust as written (the first dev build wrote one) showed as zero lists with no explanation, which read as data loss; the load now reports what the file holds and says to fix or remove it — never rewritten from the UI (the repair path drafted for this was dropped: no released build ever wrote that format, and a real format change is a versioned migration, not a button). Per-row memory/uptime in the live scope are now behind a 'stats' toggle, off by default and remembered: on most rows they track message count closely enough to be noise (user verdict); the total beside the search box stays. Docs and test plan updated; #148 filed for a resizable window. --- CHANGELOG.md | 4 +- docs/session-finding-plan.md | 20 +++++-- src/electron-api.d.ts | 9 +++ src/main.ts | 10 +++- src/session-lists.test.ts | 41 ++++++++++++++ src/session-lists.ts | 79 ++++++++++++++++++++++++++ src/switcher-ui.tsx | 106 +++++++++++++++++++++++++++++++---- 7 files changed, 250 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39d65da..2628062 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,14 +3,14 @@ ## 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 and shows each one's **memory and uptime** (pid and terminal device in the tooltip) — the question "what is actually running and what is it costing" now has an answer in the app. Measured while building it: 36 `claude` processes held 4.66GB while the terminal app itself held 368MB + - **`● 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. 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), 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)). A row that matched on its id shows an `id 4ed7505a` marker, since the id is not otherwise on screen - 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. 42 new unit tests (lists normalize / transitions / file roundtrip / normalizer fixed point, `ps` parsing and the live join, list-view scopes) — 127 total + - 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. 44 new unit tests (lists normalize / transitions / file roundtrip / normalizer fixed point / untrusted-file inspection, `ps` parsing and the live join, list-view scopes) — 129 total ## 1.0.86 diff --git a/docs/session-finding-plan.md b/docs/session-finding-plan.md index 0fc7c54..419cc3a 100644 --- a/docs/session-finding-plan.md +++ b/docs/session-finding-plan.md @@ -396,11 +396,21 @@ a click *resumes* it — a second process for the same id. Saved-list members an 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.** Memory and uptime, not the tty: a person cannot act on a tty name, -and the width was coming out of the title. The tty stays in the tooltip and in the data, where -the future window-switching (#142 C0) needs it. A pure "running sessions only" browse with no -figures at all is a different scope, and the cheap form of it is a `is:live` term in #140's -field-scoped search, not a second chip. +**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. + +**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 diff --git a/src/electron-api.d.ts b/src/electron-api.d.ts index 7c98333..b57c208 100644 --- a/src/electron-api.d.ts +++ b/src/electron-api.d.ts @@ -177,6 +177,15 @@ interface IElectronAPI { 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, diff --git a/src/main.ts b/src/main.ts index 2f281e3..e40bb84 100644 --- a/src/main.ts +++ b/src/main.ts @@ -42,6 +42,7 @@ import { withPin, } from './session-marks'; import { + inspectSessionLists, mutateSessionLists, normalizeList, readSessionListsResult, @@ -2503,7 +2504,14 @@ ipcMain.handle('unhide-session', (_event, sessionId: string) => { ipcMain.handle('get-session-lists', () => { ensureListsWatcher(); const read = readSessionListsResult(); - return { ...read.value, known: read.known }; + // 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) => { diff --git a/src/session-lists.test.ts b/src/session-lists.test.ts index 7a80c93..dd5969f 100644 --- a/src/session-lists.test.ts +++ b/src/session-lists.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { isAuthoritativeRead } from './atomic-json-store'; import { emptyLists, + inspectListsFile, LIST_TEXT_CAPS, mutateListsFile, normalizeLists, @@ -200,6 +201,46 @@ describe('lists file roundtrip', () => { 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, diff --git a/src/session-lists.ts b/src/session-lists.ts index 5d4d978..075b44a 100644 --- a/src/session-lists.ts +++ b/src/session-lists.ts @@ -19,6 +19,7 @@ * (`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'; @@ -223,9 +224,87 @@ export const watchListsFile = ( ): (() => 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()); diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index c974ab9..125ebb6 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -626,6 +626,18 @@ function SwitcherApp() { // 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([]); @@ -641,6 +653,16 @@ function SwitcherApp() { 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); const [saveListPrompt, setSaveListPrompt] = useState<{ name: string; count: number } | 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. @@ -1040,6 +1062,13 @@ function SwitcherApp() { }; // --- Live scope (issue #94) --- + useEffect(() => { + try { + localStorage.setItem('codev-live-stats', liveStats ? '1' : '0'); + } catch { + // Best effort, same as the pinned toggles. + } + }, [liveStats]); const refreshLiveReport = () => { window.electronAPI .getLiveSessions() @@ -1082,7 +1111,7 @@ function SwitcherApp() { } else if (r && !r.ok) { showListsNotice( r.error === 'lists store unreadable' - ? 'Not saved: ~/.config/codev/session-lists.json could not be read — fix or remove it' + ? 'Not saved: ~/.config/codev/session-lists.json cannot be read as-is — fix or remove it' : `Not saved: ${r.error || 'unknown error'}`, ); } @@ -1160,7 +1189,22 @@ function SwitcherApp() { window.electronAPI .getSessionLists() .then((r: any) => { - if (!r || listsPushSeenRef.current || r.known === false) return; + 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); }) @@ -1171,6 +1215,8 @@ function SwitcherApp() { listsPushSeenRef.current = true; setSessionLists(r.lists || []); setListsLoaded(true); + // A push is an authoritative read by construction. + setListsStoreProblem(null); }, ); return unsubscribe; @@ -2226,6 +2272,29 @@ function SwitcherApp() { > ● {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 && (
+ {/* 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} @@ -2615,17 +2695,21 @@ function SwitcherApp() { (session.__live as LiveRowInfo | undefined) || liveBySession[session.sessionId]; if (!live) return null; - // The tty is for the app (window switching, #142) and - // for the tooltip; on the row it was width spent on - // something a person cannot act on. + // 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 ( <> - - {formatMb(live.rssKb)} · {formatUptime(live.uptimeSec)} - + {liveStats && ( + + {formatMb(live.rssKb)} · {formatUptime(live.uptimeSec)} + + )} {!live.registered && ( Date: Sat, 5 Sep 2026 16:54:02 +0800 Subject: [PATCH 05/14] fix(sessions): hand focus back to the search box when the save dialog closes Closing the dialog unmounted the focused input and left focus on the body, so arrow keys went nowhere until a click landed somewhere (the document click handler is what refocuses the search box). Seen live as 'up/down stop working until I use the mouse'. --- src/switcher-ui.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 125ebb6..3b31d1d 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -1167,12 +1167,20 @@ function SwitcherApp() { 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 saveList = () => { if (!saveListPrompt) return; const members = captureDisplayedSessions(); const name = saveListPrompt.name.trim() || nextListName(sessionLists.map((l) => l.name)); - setSaveListPrompt(null); + closeSaveListPrompt(); window.electronAPI .saveSessionList(name, members) .then((r) => { @@ -3320,7 +3328,7 @@ function SwitcherApp() { {saveListPrompt && (
setSaveListPrompt(null)} + onClick={closeSaveListPrompt} style={{ position: 'fixed', inset: 0, @@ -3336,7 +3344,7 @@ function SwitcherApp() { onKeyDown={(e) => { e.stopPropagation(); if (e.key === 'Escape') { - setSaveListPrompt(null); + closeSaveListPrompt(); } else if (e.key === 'Enter') { e.preventDefault(); saveList(); From 434103bd7395908a52c929a89e1b22233896d248 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sat, 5 Sep 2026 17:00:24 +0800 Subject: [PATCH 06/14] docs: why the main list still omits a branch child with no prompt --- docs/session-finding-plan.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/session-finding-plan.md b/docs/session-finding-plan.md index 419cc3a..f0a8c56 100644 --- a/docs/session-finding-plan.md +++ b/docs/session-finding-plan.md @@ -405,6 +405,16 @@ first" moment, which is occasional. The tty is never on the row: a person cannot 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 From e78b9fded176791d7d0835ed49258180e1a26f29 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sat, 5 Sep 2026 17:02:37 +0800 Subject: [PATCH 07/14] feat(sessions): rename a saved list The store, IPC and preload for renaming already existed; this adds the UI: a pencil on each list row and in the viewed-list header opens the same name dialog prefilled with the current name. Empty keeps the old name. Held locally to ride the next review-round push. --- CHANGELOG.md | 2 +- src/switcher-ui.tsx | 75 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2628062..d9cdad0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - **`● 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. A member whose transcript is gone still reads as the session it was, because the list stored its title, branch and last messages + - **`🗂 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), 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)). A row that matched on its id shows an `id 4ed7505a` marker, since the id is not otherwise on screen - 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 diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 3b31d1d..4e4a91a 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -663,7 +663,13 @@ function SwitcherApp() { keptLists: number; keptMembers: number; } | null>(null); - const [saveListPrompt, setSaveListPrompt] = useState<{ name: string; count: 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([]); @@ -1175,8 +1181,25 @@ function SwitcherApp() { 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 = () => { 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; + window.electronAPI + .renameSessionList(id, name) + .then(applyListsResult) + .catch(() => showListsNotice('rename failed')); + return; + } const members = captureDisplayedSessions(); const name = saveListPrompt.name.trim() || nextListName(sessionLists.map((l) => l.name)); @@ -2390,6 +2413,26 @@ function SwitcherApp() {
🗂 {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' }} + > + ✎ + 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={{ cursor: 'pointer', fontSize: '11px', flexShrink: 0, color: '#666' }} + > + ✎ +
- Save {saveListPrompt.count} session{saveListPrompt.count === 1 ? '' : 's'} as a list (Enter · Esc) + {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={nextListName(sessionLists.map((l) => l.name))} + placeholder={ + saveListPrompt.renameId + ? sessionLists.find((l) => l.id === saveListPrompt.renameId)?.name + : nextListName(sessionLists.map((l) => l.name)) + } style={{ width: '100%', boxSizing: 'border-box', From fb7518e8755db8131bbfd61b446902662ae38f88 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sat, 5 Sep 2026 17:40:03 +0800 Subject: [PATCH 08/14] =?UTF-8?q?fix(sessions):=20review=20round=201=20?= =?UTF-8?q?=E2=80=94=20per-process=20live=20rows,=20id=20prefix=20search,?= =?UTF-8?q?=20store=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit + cubic on #147 (20 threads + 3 nitpicks): - Two processes on one sessionId now each get a row (the second marked '2nd process'), keyed by pid, with the dedupe in session-list-view pid-aware; chip count, rows and memory total agree. - Session-id search is a prefix rule (>=4 hex chars) shared by both paths via matchesSessionId, not a substring — 'de' no longer matches the corpus. - Saved-list scope no longer renders the browse list's minor fold; ⌘D and the hover icons skip orphan rows; synthetic rows carry the account. - Flag-style one-shots (--version, --help, --mcp-serve, --helper) are not sessions; a registration whose body pid differs from its filename is skipped; capText caps in code points and caps the derived projectName. - Store temp files are written 0600. Empty catch documented. Nested role=button removed from list rows. __live typed on ListViewSession, IPC results typed, three renderer functions async/await. Declined with reasons on-thread: prettier on pre-existing files (repo norm, no CI gate), a live-scope timer (user decision), a store lock (#150). --- CHANGELOG.md | 4 +- docs/session-finding-plan.md | 5 +- src/atomic-json-store.ts | 8 +- src/claude-session-utility.ts | 16 ++-- src/electron-api.d.ts | 1 + src/live-sessions.test.ts | 43 ++++++++- src/live-sessions.ts | 34 ++++++- src/session-list-view.test.ts | 51 ++++++++++- src/session-list-view.ts | 27 ++++-- src/session-lists.test.ts | 15 +++ src/session-lists.ts | 8 +- src/session-search.test.ts | 32 +++++++ src/session-search.ts | 30 ++++++ src/switcher-ui.tsx | 167 +++++++++++++++++++++------------- 14 files changed, 349 insertions(+), 92 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9cdad0..777a60d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,9 @@ - **`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), 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)). A row that matched on its id shows an `id 4ed7505a` marker, since the id is not otherwise on screen + - **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 - 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. 44 new unit tests (lists normalize / transitions / file roundtrip / normalizer fixed point / untrusted-file inspection, `ps` parsing and the live join, list-view scopes) — 129 total diff --git a/docs/session-finding-plan.md b/docs/session-finding-plan.md index f0a8c56..34b69f6 100644 --- a/docs/session-finding-plan.md +++ b/docs/session-finding-plan.md @@ -374,8 +374,9 @@ sessions is ~3GB of processes — the very problem the feature exists to relieve 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 stores sessionIds, so §4.7 applies -unchanged: after a branch, the member points at the ancestor. That is one more consumer of +**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. diff --git a/src/atomic-json-store.ts b/src/atomic-json-store.ts index 39a540b..0fef78f 100644 --- a/src/atomic-json-store.ts +++ b/src/atomic-json-store.ts @@ -80,9 +80,13 @@ export const readStoreResult = ( 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 + // 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'); + fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { + encoding: 'utf-8', + mode: 0o600, + }); fs.renameSync(tmp, filePath); }; diff --git a/src/claude-session-utility.ts b/src/claude-session-utility.ts index d19561e..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'; @@ -228,11 +228,12 @@ export const searchClaudeSessions = ( for (const s of allSessions) { const sessionPrompts = promptsBySession.get(s.sessionId) || []; - // sessionId included so the id shown in a terminal status line finds the - // session; the renderer's filterSessionsLocally matches the same field. + // 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.sessionId} ${s.projectName} ${s.project} ${sessionPrompts.join('\n')}`.toLowerCase(); - if (!matchesAllWords(target, words)) continue; + `${s.projectName} ${s.project} ${sessionPrompts.join('\n')}`.toLowerCase(); + if (!matchesAllWordsOrId(target, s.sessionId, words)) continue; sessions.push(s); const match = findPromptMatch(sessionPrompts, words); @@ -1927,7 +1928,10 @@ export const loadSessionEnrichment = async ( at: typeof parsed.timestamp === 'string' ? parsed.timestamp : '', }); } - } catch {} + } 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 diff --git a/src/electron-api.d.ts b/src/electron-api.d.ts index b57c208..a2d1331 100644 --- a/src/electron-api.d.ts +++ b/src/electron-api.d.ts @@ -210,6 +210,7 @@ interface IElectronAPI { registered: boolean; entrypoint?: string; accountLabel?: string; + accountIsAnchor?: boolean; }[]; staleRegistrations: { pid: number; sessionId: string; cwd: string }[]; totalRssKb: number; diff --git a/src/live-sessions.test.ts b/src/live-sessions.test.ts index 492445d..b734dc1 100644 --- a/src/live-sessions.test.ts +++ b/src/live-sessions.test.ts @@ -88,11 +88,32 @@ describe('isSessionProcess', () => { } }); - it('rejects the daemon family and non-claude processes', () => { - for (const pid of [22140, 22197, 22211, 1447]) { + 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', + ].join('\n'), + ); + expect(procs.map((p) => isSessionProcess(p))).toEqual([ + false, + false, + false, + false, + true, + true, + ]); + }); }); describe('sessionIdFromArgs', () => { @@ -180,6 +201,24 @@ describe('joinLiveSessions', () => { }); }); +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[] = []; diff --git a/src/live-sessions.ts b/src/live-sessions.ts index 1bac3de..b356a93 100644 --- a/src/live-sessions.ts +++ b/src/live-sessions.ts @@ -41,6 +41,8 @@ export interface SessionRegistration { entrypoint: string; accountLabel: string; accountDir: string; + /** The anchor (~/.claude) account shows no account badge. */ + accountIsAnchor?: boolean; } export interface LiveSession { @@ -55,6 +57,7 @@ export interface LiveSession { registered: boolean; entrypoint?: string; accountLabel?: string; + accountIsAnchor?: boolean; } export interface LiveSessionsReport { @@ -120,6 +123,23 @@ const NON_SESSION_SUBCOMMANDS = new Set([ '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([ + '--version', + '-v', + '--help', + '-h', + '--update', + '--install', + '--mcp-serve', + '--helper', +]); + const isClaudeBinary = (token: string): boolean => path.basename(token) === 'claude' || /\/share\/claude\/versions\/[^/]+$/.test(token) || @@ -130,10 +150,9 @@ 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 true; + if (!first) return true; + if (first.startsWith('-')) return !NON_SESSION_FLAGS.has(first); + return !NON_SESSION_SUBCOMMANDS.has(first); }; /** `--resume ` / `-r ` / `--session-id ` on the command line. */ @@ -177,6 +196,7 @@ export const joinLiveSessions = ( registered: !!reg, entrypoint: reg?.entrypoint, accountLabel: reg?.accountLabel, + accountIsAnchor: reg?.accountIsAnchor, }); } live.sort((a, b) => b.rssKb - a.rssKb); @@ -213,6 +233,11 @@ export const readSessionRegistrations = (): SessionRegistration[] => { ) { 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, @@ -221,6 +246,7 @@ export const readSessionRegistrations = (): SessionRegistration[] => { 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 diff --git a/src/session-list-view.test.ts b/src/session-list-view.test.ts index 43e1412..1a685e4 100644 --- a/src/session-list-view.test.ts +++ b/src/session-list-view.test.ts @@ -199,9 +199,15 @@ describe('buildSessionListView — live scope', () => { 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. - const synthetic = { sessionId: 'active', lastTimestamp: 0, __live: {} }; + // 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: [active, recent], + sessions: [real, recent], liveOnly: true, liveOrphans: [synthetic, orphan], }); @@ -209,6 +215,47 @@ describe('buildSessionListView — live scope', () => { 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('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], diff --git a/src/session-list-view.ts b/src/session-list-view.ts index cb1d609..76e2c1e 100644 --- a/src/session-list-view.ts +++ b/src/session-list-view.ts @@ -57,6 +57,10 @@ export interface ListViewSession { __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; } @@ -285,6 +289,10 @@ export const buildSessionListView = ({ // 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. @@ -368,16 +376,19 @@ export const buildSessionListView = ({ } 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 whose id a real row already covers is dropped — the - // renderer builds them from a snapshot that can lag the loaded list by - // one render. - const shown = new Set(majorSessions.map((s) => s.sessionId)); + // 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) => !shown.has(s.sessionId)), - ]; + : [...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. diff --git a/src/session-lists.test.ts b/src/session-lists.test.ts index dd5969f..3e9d438 100644 --- a/src/session-lists.test.ts +++ b/src/session-lists.test.ts @@ -63,6 +63,21 @@ describe('normalizeMember', () => { 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', diff --git a/src/session-lists.ts b/src/session-lists.ts index 075b44a..24ad6de 100644 --- a/src/session-lists.ts +++ b/src/session-lists.ts @@ -87,7 +87,11 @@ const capText = (value: unknown, max: number): string | undefined => { if (typeof value !== 'string') return undefined; const t = value.trim(); if (!t) return undefined; - const capped = t.length > max ? t.slice(0, max).trim() : t; + // 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; }; @@ -102,7 +106,7 @@ export const normalizeMember = (raw: unknown): SessionListMember | null => { project, projectName: capText(m.projectName, LIST_TEXT_CAPS.title) || - project.split('/').filter(Boolean).pop() || + capText(project.split('/').filter(Boolean).pop(), LIST_TEXT_CAPS.title) || m.sessionId.slice(0, 8), pinned: m.pinned === true, lastTimestamp: 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 4e4a91a..db8763f 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -14,7 +14,19 @@ import { } from './session-list-view'; type LiveReport = Awaited>; -import { truncateMiddle, windowAroundMatch } from './session-search'; +type ListsResponse = Awaited>; +/** Shape every list mutation returns (save / delete / rename). */ +type ListsWriteResult = { + ok: boolean; + error?: string; + lists?: { lists: SessionList[] }; +}; +import { + matchesAllWordsOrId, + matchesSessionId, + truncateMiddle, + windowAroundMatch, +} from './session-search'; import TerminalTab from './terminal-tab'; type SwitcherMode = 'projects' | 'sessions' | 'terminal'; @@ -716,12 +728,12 @@ function SwitcherApp() { return allItems.filter((s) => { const prInfo = prLinks[s.sessionId]; const terminalBadge = terminalApps[s.sessionId] || ((s as any).entrypoint === 'claude-vscode' ? 'vscode' : ''); - // sessionId is searchable so a session can be found from the id a - // terminal status line shows — the one field that is unique when - // several sessions share a name (#142). Must stay in step with the - // main-side target in searchClaudeSessions. - const searchTarget = `${s.sessionId} ${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). + 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 matchesAllWordsOrId(searchTarget, s.sessionId, words); }); }; @@ -881,9 +893,11 @@ function SwitcherApp() { // first live test caught "33 live" beside a 32-row list. const liveBySession: Record = {}; const liveOrphans: ListViewSession[] = []; - const knownIds = new Set(); - for (const s of allSessions) knownIds.add(s.sessionId); - for (const s of extraScopeSessions) knownIds.add(s.sessionId); + 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); + } for (const p of liveReport?.live ?? []) { const info: LiveRowInfo = { pid: p.pid, @@ -892,19 +906,42 @@ function SwitcherApp() { uptimeSec: p.uptimeSec, registered: p.registered, }; - if (p.sessionId) liveBySession[p.sessionId] = info; - if (p.sessionId && knownIds.has(p.sessionId)) continue; + const id = p.sessionId; + const known = id ? knownById.get(id) : undefined; + if (id && known) { + // A real row represents ONE process: the pid the registration-based + // detection mapped to this id, else the first one seen. 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. + const representative = activeStateRef.current[id] ?? liveBySession[id]?.pid; + if (representative === undefined || representative === p.pid) { + liveBySession[id] = info; + continue; + } + liveOrphans.push({ + ...known, + isActive: true, + activePid: p.pid, + __live: info, + __liveExtra: true, + }); + continue; + } + if (id) liveBySession[id] ??= info; liveOrphans.push({ - sessionId: p.sessionId || `pid:${p.pid}`, + 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: !p.sessionId, + __liveOrphan: !id, __live: info, }); } @@ -957,7 +994,7 @@ function SwitcherApp() { // beside a search result describes the result. const displayedRssKb = liveOnlyActive ? displayedSessions.reduce((sum, s) => { - const live = liveBySession[s.sessionId] || (s.__live as LiveRowInfo | undefined); + const live = s.__live || liveBySession[s.sessionId]; return sum + (live?.rssKb ?? 0); }, 0) : 0; @@ -1075,13 +1112,14 @@ function SwitcherApp() { // Best effort, same as the pinned toggles. } }, [liveStats]); - const refreshLiveReport = () => { - window.electronAPI - .getLiveSessions() - .then((r) => { - if (r) setLiveReport(r); - }) - .catch(() => {}); + const refreshLiveReport = async () => { + try { + // null = "could not look" (a failed ps); keep the last good report. + const r = await window.electronAPI.getLiveSessions(); + if (r) setLiveReport(r); + } catch { + // Same: never replace a good report with nothing. + } }; const toggleLiveOnly = () => { const next = !liveOnly; @@ -1110,7 +1148,7 @@ function SwitcherApp() { if (listsNoticeTimerRef.current) clearTimeout(listsNoticeTimerRef.current); listsNoticeTimerRef.current = setTimeout(() => setListsNotice(null), 6000); }; - const applyListsResult = (r: any) => { + const applyListsResult = (r: ListsWriteResult | undefined) => { if (r?.ok && r.lists) { setSessionLists(r.lists.lists || []); setListsLoaded(true); @@ -1138,15 +1176,15 @@ function SwitcherApp() { setViewingListId(null); setSelectedSessionIndex(0); }; - const deleteList = (id: string) => { - window.electronAPI - .deleteSessionList(id) - .then((r) => { - applyListsResult(r); - setConfirmDeleteListId(null); - if (r?.ok && viewingListId === id) setViewingListId(null); - }) - .catch(() => showListsNotice('delete failed')); + 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 @@ -1186,7 +1224,7 @@ function SwitcherApp() { if (!current) return; setSaveListPrompt({ name: current.name, count: current.members.length, renameId: id }); }; - const saveList = () => { + const saveList = async () => { if (!saveListPrompt) return; if (saveListPrompt.renameId) { // Rename: an empty name keeps the old one (the store does the same). @@ -1194,23 +1232,24 @@ function SwitcherApp() { const name = saveListPrompt.name.trim(); closeSaveListPrompt(); if (!name) return; - window.electronAPI - .renameSessionList(id, name) - .then(applyListsResult) - .catch(() => showListsNotice('rename failed')); + 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(); - window.electronAPI - .saveSessionList(name, members) - .then((r) => { - applyListsResult(r); - if (r?.ok) setListsExpanded(true); - }) - .catch(() => showListsNotice('save failed')); + 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 @@ -1219,7 +1258,7 @@ function SwitcherApp() { useEffect(() => { window.electronAPI .getSessionLists() - .then((r: any) => { + .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 @@ -1241,7 +1280,7 @@ function SwitcherApp() { }) .catch(() => {}); const unsubscribe = window.electronAPI.onSessionListsUpdated( - (_event: any, r: any) => { + (_event: unknown, r: { lists?: SessionList[] } | undefined) => { if (!r) return; listsPushSeenRef.current = true; setSessionLists(r.lists || []); @@ -2256,7 +2295,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); @@ -2288,8 +2328,8 @@ function SwitcherApp() { aria-pressed={liveOnlyActive} title={ liveOnlyActive - ? 'Show every session again' - : `Show only sessions with a running process, with memory and uptime${staleCount ? ` · ${staleCount} stale registration${staleCount > 1 ? 's' : ''} in ~/.claude/sessions` : ''}` + ? `Show every session again${liveReport ? ` · measured ${formatRelativeTime(liveReport.measuredAt)}; re-read on each open and on toggling` : ''}` + : `Show only sessions with a running process${staleCount ? ` · ${staleCount} stale registration${staleCount > 1 ? 's' : ''} in ~/.claude/sessions` : ''}` } onMouseDown={(e) => e.preventDefault()} onClick={toggleLiveOnly} @@ -2465,19 +2505,14 @@ function SwitcherApp() {
)} {sessionLists.map((l) => ( + // A plain clickable row, like the session rows: the + // rename / delete controls inside it are the buttons, and + // a button nested in a button is invalid ARIA.
e.preventDefault()} onClick={() => openList(l.id)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - openList(l.id); - } - }} className="codev-list-row" style={LIST_ROW_STYLE} > @@ -2615,7 +2650,7 @@ function SwitcherApp() { // 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 && (
)} @@ -2734,7 +2769,7 @@ function SwitcherApp() { )}
- {index === selectedSessionIndex && ( + {index === selectedSessionIndex && !session.__liveOrphan && ( <> )} + {session.__liveExtra && ( + + ⚠ 2nd process + + )} ); })()} @@ -2798,7 +2839,7 @@ function SwitcherApp() { on when the match is not already visible). */} {isSearchingSessions && !session.__liveOrphan && - searchWordsLower.some((w) => session.sessionId.toLowerCase().includes(w)) && ( + searchWordsLower.some((w) => matchesSessionId(session.sessionId, w)) && ( Date: Sat, 5 Sep 2026 18:12:38 +0800 Subject: [PATCH 09/14] =?UTF-8?q?fix(sessions):=20review=20round=202=20?= =?UTF-8?q?=E2=80=94=20representative=20pid=20from=20the=20live=20report?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cubic on fb7518e (5 threads): - The process that represents a session with several is chosen from the live report (the detection-mapped pid if still running, else the first live one), never from the cached map alone; activeFrom prefers the join's pid over the map's, and timeline rows go through it too, so a row whose process was replaced shows its dot and switches to the live pid. - Synthetic-row dedupe sees the join pid on real rows (the same fix). - --print / -p one-shots are not sessions. - Temp store files are chmod'ed 0600 (mode only applies on create). - Saved-list rows are keyboard-activatable again without role=button. --- src/atomic-json-store.ts | 3 +++ src/live-sessions.test.ts | 4 +++ src/live-sessions.ts | 3 +++ src/session-list-view.test.ts | 51 +++++++++++++++++++++++++++++++++++ src/session-list-view.ts | 37 +++++++++++++++++++------ src/switcher-ui.tsx | 49 ++++++++++++++++++++++++--------- 6 files changed, 127 insertions(+), 20 deletions(-) diff --git a/src/atomic-json-store.ts b/src/atomic-json-store.ts index 0fef78f..1c08983 100644 --- a/src/atomic-json-store.ts +++ b/src/atomic-json-store.ts @@ -87,6 +87,9 @@ export const writeStoreFile = (filePath: string, value: unknown): void => { 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); }; diff --git a/src/live-sessions.test.ts b/src/live-sessions.test.ts index b734dc1..848c050 100644 --- a/src/live-sessions.test.ts +++ b/src/live-sessions.test.ts @@ -103,6 +103,8 @@ describe('isSessionProcess', () => { ' 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', ].join('\n'), ); expect(procs.map((p) => isSessionProcess(p))).toEqual([ @@ -112,6 +114,8 @@ describe('isSessionProcess', () => { false, true, true, + false, + false, ]); }); }); diff --git a/src/live-sessions.ts b/src/live-sessions.ts index b356a93..5ce4980 100644 --- a/src/live-sessions.ts +++ b/src/live-sessions.ts @@ -130,6 +130,9 @@ const NON_SESSION_SUBCOMMANDS = new Set([ * `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', diff --git a/src/session-list-view.test.ts b/src/session-list-view.test.ts index 1a685e4..2e8864c 100644 --- a/src/session-list-view.test.ts +++ b/src/session-list-view.test.ts @@ -240,6 +240,57 @@ describe('buildSessionListView — live scope', () => { 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. diff --git a/src/session-list-view.ts b/src/session-list-view.ts index 76e2c1e..3efc9ec 100644 --- a/src/session-list-view.ts +++ b/src/session-list-view.ts @@ -192,12 +192,16 @@ const resolvePinnedRow = ( }; /** - * Is this session running, and under which pid? Two sources, either wins: - * the registration-based detection (`activePids`) and the `ps` join - * (`liveBySession`). The second exists because the first 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. + * 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, @@ -207,9 +211,22 @@ const activeFrom = ( isActive: s.sessionId in activePids || s.sessionId in liveBySession || !!s.isActive, activePid: - activePids[s.sessionId] ?? liveBySession[s.sessionId]?.pid ?? s.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 @@ -277,7 +294,11 @@ export const buildSessionListView = ({ // 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; diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index db8763f..df38589 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -898,6 +898,24 @@ function SwitcherApp() { 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, @@ -909,13 +927,11 @@ function SwitcherApp() { const id = p.sessionId; const known = id ? knownById.get(id) : undefined; if (id && known) { - // A real row represents ONE process: the pid the registration-based - // detection mapped to this id, else the first one seen. 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. - const representative = activeStateRef.current[id] ?? liveBySession[id]?.pid; - if (representative === undefined || representative === p.pid) { + // 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; } @@ -928,7 +944,7 @@ function SwitcherApp() { }); continue; } - if (id) liveBySession[id] ??= info; + if (id && representativeOf(id) === p.pid) liveBySession[id] = info; liveOrphans.push({ sessionId: id || `pid:${p.pid}`, project: p.cwd || '', @@ -2505,14 +2521,23 @@ function SwitcherApp() {
)} {sessionLists.map((l) => ( - // A plain clickable row, like the session rows: the - // rename / delete controls inside it are the buttons, and - // a button nested in a button is invalid ARIA. + // Keyboard-activatable row (Tab to it, Enter / Space opens) + // without role="button": the rename / delete controls inside + // it are the buttons, and a button nested in a button is + // invalid ARIA.
e.preventDefault()} onClick={() => openList(l.id)} + onKeyDown={(e) => { + if (e.target !== e.currentTarget) return; // a control inside handled it + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openList(l.id); + } + }} className="codev-list-row" style={LIST_ROW_STYLE} > From 7426ecb1e6ec27083b116bede1fc6773f22ed818 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sat, 5 Sep 2026 18:42:12 +0800 Subject: [PATCH 10/14] =?UTF-8?q?fix(sessions):=20review=20round=203=20?= =?UTF-8?q?=E2=80=94=20fresh=20live=20report,=20one-shot=20flags=20anywher?= =?UTF-8?q?e,=20list-row=20a11y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cubic + CodeRabbit on 001f2b4 (3 threads + 2 body items): - The live report is refreshed on every session refetch, so the join pid a row prefers is never older than the detection map it beats. - isSessionProcess walks the leading options: a one-shot flag at any position (-c -p, --model x -p) is a one-shot; a prompt ends the walk. - Saved-list row: the clickable part is a labelled button; rename/delete are siblings, not children. - Unresolved list members get placeholder search candidates and their captured fields are searched, so a query on a captured title keeps them. - Type declarations moved below the import block. --- src/live-sessions.test.ts | 15 +++++ src/live-sessions.ts | 38 ++++++++++++- src/switcher-ui.tsx | 115 +++++++++++++++++++++++++------------- 3 files changed, 126 insertions(+), 42 deletions(-) diff --git a/src/live-sessions.test.ts b/src/live-sessions.test.ts index 848c050..8b985bc 100644 --- a/src/live-sessions.test.ts +++ b/src/live-sessions.test.ts @@ -105,6 +105,15 @@ describe('isSessionProcess', () => { ' 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', + // …while interactive invocations with the same leading options stay sessions, + ' 12 1 ttys001 00:01 claude -c', + ' 13 1 ttys001 00:01 claude --model opus --resume f339c186-ba82-4362-a901-2938323c0198', + // and a prompt that merely mentions a flag is not parsed as one. + ' 14 1 ttys001 00:01 claude explain the -p flag', ].join('\n'), ); expect(procs.map((p) => isSessionProcess(p))).toEqual([ @@ -116,6 +125,12 @@ describe('isSessionProcess', () => { true, false, false, + false, + false, + false, + true, + true, + true, ]); }); }); diff --git a/src/live-sessions.ts b/src/live-sessions.ts index 5ce4980..9857e9b 100644 --- a/src/live-sessions.ts +++ b/src/live-sessions.ts @@ -148,14 +148,46 @@ const isClaudeBinary = (token: string): boolean => /\/share\/claude\/versions\/[^/]+$/.test(token) || /ClaudeCode\.app\/Contents\/MacOS\/claude$/.test(token); +/** + * Options that take a value, so the token after them is not an option even + * when the walk below is looking for one. A value that itself starts with + * `-` is not consumed — `-r -p x` is a print run, not a resume of "-p". + */ +const VALUE_FLAGS = new Set([ + '-n', + '--name', + '-r', + '--resume', + '--session-id', + '--model', + '--add-dir', + '--settings', + '--mcp-config', + '--permission-mode', + '--agent', + '--effort', +]); + /** A `claude` process that is (or could be) an interactive session. */ 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) return true; - if (first.startsWith('-')) return !NON_SESSION_FLAGS.has(first); - return !NON_SESSION_SUBCOMMANDS.has(first); + if (first && !first.startsWith('-') && NON_SESSION_SUBCOMMANDS.has(first)) { + return false; + } + // Walk the leading option sequence: a one-shot flag anywhere in it makes + // the whole invocation a one-shot (`claude -c -p "query"` is print mode). + // The first positional token — a prompt — ends the walk, so words inside + // a prompt can never be mistaken for flags. + for (let i = 1; i < tokens.length; i++) { + const t = tokens[i]; + if (!t.startsWith('-')) break; + if (NON_SESSION_FLAGS.has(t)) return false; + const next = tokens[i + 1]; + if (VALUE_FLAGS.has(t) && next && !next.startsWith('-')) i++; + } + return true; }; /** `--resume ` / `-r ` / `--session-id ` on the command line. */ diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index df38589..0153b51 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -12,6 +12,13 @@ import { ListViewSession, mergeSessionsById, } from './session-list-view'; +import { + matchesAllWordsOrId, + matchesSessionId, + truncateMiddle, + windowAroundMatch, +} from './session-search'; +import TerminalTab from './terminal-tab'; type LiveReport = Awaited>; type ListsResponse = Awaited>; @@ -21,13 +28,6 @@ type ListsWriteResult = { error?: string; lists?: { lists: SessionList[] }; }; -import { - matchesAllWordsOrId, - matchesSessionId, - truncateMiddle, - windowAroundMatch, -} from './session-search'; -import TerminalTab from './terminal-tab'; type SwitcherMode = 'projects' | 'sessions' | 'terminal'; // import { fetchVSCodeBasedOpenedWindows, SERVER_URL, deleteRecentProjectRecord } from "./vscode-based-ide-utility" @@ -687,6 +687,7 @@ function SwitcherApp() { 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); @@ -732,7 +733,13 @@ function SwitcherApp() { // 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). - 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(); + // 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); }); }; @@ -749,10 +756,32 @@ 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 listPlaceholders: ListViewSession[] = ( + viewingListRef.current?.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, + })); const candidates = query.trim() ? mergeSessionsById( - mergeSessionsById(allItems, extraPinnedSessionsRef.current), - extraScopeSessionsRef.current, + mergeSessionsById( + mergeSessionsById(allItems, extraPinnedSessionsRef.current), + extraScopeSessionsRef.current, + ), + listPlaceholders, ) : allItems; const base = filterSessionsLocally(candidates, query); @@ -885,6 +914,9 @@ function SwitcherApp() { 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). + viewingListRef.current = 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 — @@ -1579,8 +1611,12 @@ function SwitcherApp() { 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 while in scope. - if (liveOnlyRef.current) refreshLiveReport(); + // 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 () => { @@ -2521,33 +2557,34 @@ function SwitcherApp() {
)} {sessionLists.map((l) => ( - // Keyboard-activatable row (Tab to it, Enter / Space opens) - // without role="button": the rename / delete controls inside - // it are the buttons, and a button nested in a button is - // invalid ARIA. -
e.preventDefault()} - onClick={() => openList(l.id)} - onKeyDown={(e) => { - if (e.target !== e.currentTarget) return; // a control inside handled it - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - openList(l.id); - } - }} - className="codev-list-row" - style={LIST_ROW_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 ? ' …' : ''} - + // The clickable part is a real button with a name; the + // rename / delete controls are its SIBLINGS, so nothing is + // nested in a button and every control is reachable by + // keyboard and announced by a screen reader. +
+
e.preventDefault()} + onClick={() => openList(l.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openList(l.id); + } + }} + style={{ display: 'flex', alignItems: 'center', gap: '8px', flex: 1, minWidth: 0, cursor: 'pointer' }} + > + {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 ? ' …' : ''} + +
Date: Sat, 5 Sep 2026 18:56:30 +0800 Subject: [PATCH 11/14] =?UTF-8?q?fix(sessions):=20review=20round=204=20?= =?UTF-8?q?=E2=80=94=20scan=20all=20tokens=20for=20one-shot=20flags,=20fin?= =?UTF-8?q?al=20list-row=20a11y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cubic on 7426ecb (4 threads): - isSessionProcess rejects a one-shot flag anywhere among the tokens; the leading-options walk and its value-flag table are gone (ps drops shell quoting, so a path with a space stopped the walk before -p). The one known cost — an inline prompt whose token equals a flag — is documented. - Saved-list row: the whole row is the labelled button; rename/delete are focusable labelled spans without a button role; the row ignores their key events. Padding clicks open the list again. - Captured member fields are searched for resolved members too. --- src/live-sessions.test.ts | 13 ++++-- src/live-sessions.ts | 44 ++++++------------- src/switcher-ui.tsx | 89 +++++++++++++++++++++++---------------- 3 files changed, 75 insertions(+), 71 deletions(-) diff --git a/src/live-sessions.test.ts b/src/live-sessions.test.ts index 8b985bc..ed4ee47 100644 --- a/src/live-sessions.test.ts +++ b/src/live-sessions.test.ts @@ -109,11 +109,14 @@ describe('isSessionProcess', () => { ' 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, - ' 12 1 ttys001 00:01 claude -c', - ' 13 1 ttys001 00:01 claude --model opus --resume f339c186-ba82-4362-a901-2938323c0198', - // and a prompt that merely mentions a flag is not parsed as one. - ' 14 1 ttys001 00:01 claude explain the -p flag', + ' 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([ @@ -128,6 +131,8 @@ describe('isSessionProcess', () => { false, false, false, + false, + false, true, true, true, diff --git a/src/live-sessions.ts b/src/live-sessions.ts index 9857e9b..6becac8 100644 --- a/src/live-sessions.ts +++ b/src/live-sessions.ts @@ -149,26 +149,19 @@ const isClaudeBinary = (token: string): boolean => /ClaudeCode\.app\/Contents\/MacOS\/claude$/.test(token); /** - * Options that take a value, so the token after them is not an option even - * when the walk below is looking for one. A value that itself starts with - * `-` is not consumed — `-r -p x` is a print run, not a resume of "-p". + * 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. */ -const VALUE_FLAGS = new Set([ - '-n', - '--name', - '-r', - '--resume', - '--session-id', - '--model', - '--add-dir', - '--settings', - '--mcp-config', - '--permission-mode', - '--agent', - '--effort', -]); - -/** A `claude` process that is (or could be) an interactive session. */ export const isSessionProcess = (p: ClaudeProcess): boolean => { const tokens = p.args.split(/\s+/); if (!tokens[0] || !isClaudeBinary(tokens[0])) return false; @@ -176,18 +169,7 @@ export const isSessionProcess = (p: ClaudeProcess): boolean => { if (first && !first.startsWith('-') && NON_SESSION_SUBCOMMANDS.has(first)) { return false; } - // Walk the leading option sequence: a one-shot flag anywhere in it makes - // the whole invocation a one-shot (`claude -c -p "query"` is print mode). - // The first positional token — a prompt — ends the walk, so words inside - // a prompt can never be mistaken for flags. - for (let i = 1; i < tokens.length; i++) { - const t = tokens[i]; - if (!t.startsWith('-')) break; - if (NON_SESSION_FLAGS.has(t)) return false; - const next = tokens[i + 1]; - if (VALUE_FLAGS.has(t) && next && !next.startsWith('-')) i++; - } - return true; + return !tokens.slice(1).some((t) => NON_SESSION_FLAGS.has(t)); }; /** `--resume ` / `-r ` / `--session-id ` on the command line. */ diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 0153b51..83bc9a3 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -761,9 +761,8 @@ function SwitcherApp() { // 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 listPlaceholders: ListViewSession[] = ( - viewingListRef.current?.members ?? [] - ).map((m) => ({ + const members = viewingListRef.current?.members ?? []; + const listPlaceholders: ListViewSession[] = members.map((m) => ({ sessionId: m.sessionId, project: m.project, projectName: m.projectName, @@ -775,11 +774,25 @@ function SwitcherApp() { 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( - mergeSessionsById( - mergeSessionsById(allItems, extraPinnedSessionsRef.current), - extraScopeSessionsRef.current, + withCaptured( + mergeSessionsById( + mergeSessionsById(allItems, extraPinnedSessionsRef.current), + extraScopeSessionsRef.current, + ), ), listPlaceholders, ) @@ -2557,37 +2570,41 @@ function SwitcherApp() {
)} {sessionLists.map((l) => ( - // The clickable part is a real button with a name; the - // rename / delete controls are its SIBLINGS, so nothing is - // nested in a button and every control is reachable by - // keyboard and announced by a screen reader. -
-
e.preventDefault()} - onClick={() => openList(l.id)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - openList(l.id); - } - }} - style={{ display: 'flex', alignItems: 'center', gap: '8px', flex: 1, minWidth: 0, cursor: 'pointer' }} - > - {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 ? ' …' : ''} - -
+ // Final shape, after three rounds of contradictory review + // on this row: the WHOLE row is the button (role, name, Tab + // stop, Enter/Space, click anywhere including the padding), + // and the rename / delete controls inside it are focusable, + // labelled spans WITHOUT a button role — reachable and + // announced, but not a button nested in a button. The row + // ignores key events that originated in those controls. +
e.preventDefault()} + onClick={() => openList(l.id)} + onKeyDown={(e) => { + if (e.target !== e.currentTarget) return; // a control inside handles its own keys + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openList(l.id); + } + }} + className="codev-list-row" + style={LIST_ROW_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) => { @@ -2606,8 +2623,8 @@ function SwitcherApp() { ✎ e.preventDefault()} onClick={(e) => { From d4a1c13784198e32fa3dc574d66c964acfe704cf Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sat, 5 Sep 2026 19:13:49 +0800 Subject: [PATCH 12/14] =?UTF-8?q?fix(sessions):=20saved-list=20row=20as=20?= =?UTF-8?q?three=20siblings=20=E2=80=94=20opener=20fills=20the=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cubic on 9842bb6 (1 thread, the fifth on this row): a role=button must not contain focusable controls. Final shape: a plain wrapper with the labelled opener button filling the row (padding click opens, first Tab stop) and rename/delete as sibling buttons. Also: CHANGELOG test count. --- CHANGELOG.md | 2 +- src/switcher-ui.tsx | 86 ++++++++++++++++++++++++++++----------------- 2 files changed, 55 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 777a60d..8dbd485 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ - 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 - 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. 44 new unit tests (lists normalize / transitions / file roundtrip / normalizer fixed point / untrusted-file inspection, `ps` parsing and the live join, list-view scopes) — 129 total + - 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 diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 83bc9a3..ddac21e 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -178,6 +178,33 @@ const LIST_ROW_STYLE = { 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. +const LIST_WRAPPER_STYLE = { + display: 'flex', + alignItems: 'center', + gap: '8px', + paddingRight: '10px', + 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 10px 4px 24px', + cursor: 'pointer', + outlineOffset: '-2px', +} 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 = { @@ -2570,39 +2597,33 @@ function SwitcherApp() {
)} {sessionLists.map((l) => ( - // Final shape, after three rounds of contradictory review - // on this row: the WHOLE row is the button (role, name, Tab - // stop, Enter/Space, click anywhere including the padding), - // and the rename / delete controls inside it are focusable, - // labelled spans WITHOUT a button role — reachable and - // announced, but not a button nested in a button. The row - // ignores key events that originated in those controls. -
e.preventDefault()} - onClick={() => openList(l.id)} - onKeyDown={(e) => { - if (e.target !== e.currentTarget) return; // a control inside handles its own keys - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - openList(l.id); - } - }} - className="codev-list-row" - style={LIST_ROW_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 ? ' …' : ''} - + // See LIST_WRAPPER_STYLE: opener + two controls as siblings. +
+
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 ? ' …' : ''} + +
Date: Sat, 5 Sep 2026 19:23:26 +0800 Subject: [PATCH 13/14] fix(sessions): no dead area on a saved-list row; a scope survives resuming from it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cubic on d4a1c13: the wrapper's gap and right padding belonged to no control. Spacing now lives on the opener and the controls, and a direct wrapper click is delegated to openList. User request: opening a session from a saved list or the live scope no longer drops the scope on the next show — a scope is a place to work through several sessions. Only the search box is cleared, as before. --- CHANGELOG.md | 1 + src/switcher-ui.tsx | 48 ++++++++++++++++++++++++++++++++------------- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dbd485..1f82c5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - **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 diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index ddac21e..b08e591 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -183,11 +183,12 @@ const LIST_ROW_STYLE = { // 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: 'center', - gap: '8px', - paddingRight: '10px', + alignItems: 'stretch', margin: '1px 0', borderRadius: '3px', fontSize: '12px', @@ -200,11 +201,21 @@ const LIST_OPENER_STYLE = { gap: '8px', flex: 1, minWidth: 0, - padding: '4px 10px 4px 24px', + 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 = { @@ -1910,11 +1921,12 @@ function SwitcherApp() { // Drop the stale filtered list immediately so the empty input and the // visible list agree before fetchClaudeSessions() resolves. setSessions(allSessionsRef.current); - // A scope is a way of finding one session; once it is opened, the - // next show starts from the full list, like the search does. - setViewingListId(null); - liveOnlyRef.current = false; - setLiveOnly(false); + // 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(); } @@ -2598,7 +2610,16 @@ function SwitcherApp() { )} {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); + }} + >
✎ @@ -2663,9 +2684,8 @@ function SwitcherApp() { } }} style={{ - cursor: 'pointer', - fontSize: '11px', - flexShrink: 0, + ...LIST_CONTROL_STYLE, + paddingRight: '10px', color: confirmDeleteListId === l.id ? '#e07a5f' : '#666', }} > From 63701faaa460d112327db691c9f45df8ccb0c030 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sat, 5 Sep 2026 19:38:15 +0800 Subject: [PATCH 14/14] fix(sessions): keep only the newest live report; README for #139 and #147 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit on 604dc58: overlapping live-report requests could let an older ps snapshot overwrite a newer one — a request sequence now commits only the latest; viewingListRef is written in an effect, not in render. README: the Sessions section documents #139 (middle-ellipsis titles, match-aware windows, the match #N chip) and this PR (live scope, stats, saved lists, rename/delete, recap on a member, session-id search, scope survives resuming). Docs §4.8: why a scope survives and a query does not. --- README.md | 14 +++++++++++++- docs/session-finding-plan.md | 7 +++++++ src/switcher-ui.tsx | 14 +++++++++++++- 3 files changed, 33 insertions(+), 2 deletions(-) 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 34b69f6..9325a34 100644 --- a/docs/session-finding-plan.md +++ b/docs/session-finding-plan.md @@ -355,6 +355,13 @@ spare width), and a scope replaces the list rather than adding to it. Scopes ran 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 diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index b08e591..0ae23ca 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -967,7 +967,12 @@ function SwitcherApp() { 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). - viewingListRef.current = viewingList; + // 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 — @@ -1211,10 +1216,17 @@ function SwitcherApp() { // 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.