diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 9106d58..ffaf513 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -3,8 +3,8 @@ set -euo pipefail echo "Running pre-commit checks..." -echo ">> bun test" -bun test +echo ">> bun test (backend)" +bun test ./src echo ">> typecheck" bun run typecheck diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3850aa0..a59f223 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,22 @@ jobs: - run: bun install --frozen-lockfile - run: bun test + web: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + # web/ is not a workspace of the root package, so its dependencies have + # to be installed separately before vitest can run. + - run: bun install --frozen-lockfile + working-directory: web + - run: bunx vitest run + working-directory: web + typecheck: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index 5f190f2..e91d949 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Engineering Notebook -A CLI tool that ingests [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and [Codex](https://openai.com/index/introducing-codex/) session transcripts, generates LLM-powered daily summaries, and serves a web UI for browsing your engineering journal. +A CLI tool that ingests [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://openai.com/index/introducing-codex/), and [Cursor](https://cursor.com) session transcripts, generates LLM-powered daily summaries, and serves a web UI for browsing your engineering journal. Think of it as an automatic engineering diary — it watches your AI coding sessions and distills them into a searchable, browsable narrative of what you built, what problems you hit, and what decisions you made. @@ -8,7 +8,7 @@ Think of it as an automatic engineering diary — it watches your AI coding sess ## How It Works -1. **Ingest** — Scans directories of Claude Code and Codex JSONL session files, parses out the human-readable conversation (stripping tool calls, thinking blocks, etc.), and stores them in SQLite. +1. **Ingest** — Scans directories of Claude Code, Codex, and Cursor JSONL session files, parses out the human-readable conversation (stripping tool calls, thinking blocks, etc.), and stores them in SQLite. 2. **Summarize** — Groups sessions by date and project, then uses Claude to write concise engineering journal entries with headlines, summaries, topics, and open questions. 3. **Serve** — Runs a web server with a browsable UI: daily journal, project timelines, calendar/Gantt view, session transcripts, full-text search, and an iCal feed. @@ -114,6 +114,57 @@ Config lives at `~/.config/engineering-notebook/config.json`: | `summary_instructions` | Custom instructions appended to the LLM summarization prompt | `""` | | `remote_sources` | SSH remote sources to sync before ingesting | `[]` | | `auto_sync_interval` | Seconds between auto-syncs when serving | `60` | +| `opencode` | OpenCode session import (opt-in) | absent | +| `summary_provider` | Which model writes summaries and session titles | Claude Haiku | + +### OpenCode sessions + +OpenCode keeps its sessions in a single SQLite database rather than one file per +session, so they cannot be scanned like Claude Code and Codex sources. Enabling +this block exports each session to a staging directory of JSONL files during +`ingest`, which the normal scanner then picks up: + +```json +{ + "opencode": { + "enabled": true, + "staging_dir": "~/.cache/engineering-notebook/opencode", + "max_count": 200 + } +} +``` + +Sessions are enumerated from OpenCode's database (`opencode session list` only +reports the current directory's project, so it cannot see them all) and their +transcripts are exported with `opencode export`. A manifest of last-seen update +times keeps repeat syncs cheap — only changed sessions are re-exported. Omit +`max_count` to take everything. + +Projects are keyed off each session's working directory, so OpenCode and Claude +Code work in the same repo on the same day lands in one journal entry. + +### Choosing a model + +Journal summaries and session titles are written by Claude Haiku through the +Agent SDK by default, which needs no API key. Any OpenAI-compatible endpoint can be used +instead — including a local llama.cpp server: + +```json +{ + "summary_provider": { + "type": "openai", + "base_url": "http://your-host:8001/v1", + "model": "gemma-4", + "api_key_env": "SPARK_API_KEY", + "max_tokens": 4000 + } +} +``` + +`api_key_env` names the environment variable holding the key — the key itself is +never stored in the config file. Reasoning models spend completion tokens +thinking before emitting any content, so keep `max_tokens` generous (it defaults +to 4000); too small a budget returns an empty response rather than a summary. ### Remote Sources @@ -142,6 +193,37 @@ webcal://localhost:3000/api/calendar.ics This creates calendar events for each journal entry, viewable in Apple Calendar, Google Calendar, Outlook, etc. +## Cursor support + +Cursor sessions live under `~/.cursor/projects` (Cursor's `agent-transcripts/` +layout). This source is **not** scanned by default — add it explicitly: + +```sh +engineering-notebook ingest --source ~/.cursor/projects +``` + +Or add `~/.cursor/projects` to the `sources` array in your config. + +Cursor's transcript format is leaner than Claude Code's or Codex's, so a few +caveats apply: + +- **Timestamps come from file modification times.** Cursor transcripts contain no + per-message timestamps, so a session's start and end are taken from the file's + creation and last-modified times, and per-message times are approximate. Copying + or restoring transcript files can reset these. +- **Project names are the raw encoded directory string** (for example + `Users-username-GitRepos-my-repo`). Cursor does not record the working directory, + and its directory names encode the path lossily — both `/` and `.` collapse to + `-` — so the name is shown verbatim rather than guessed at. +- **Cursor sessions do not auto-merge** with the same repository's Claude Code or + Codex sessions, which group by the real working-directory name. +- **Some Cursor projects appear under an opaque numeric id** (for example + `1700000000000`) when Cursor stored no recoverable path. + +Planned improvements (not yet implemented): stripping `` and +terminal-selection wrappers from Cursor messages, and recovering real project +names via Cursor's `workspaceStorage` mapping. + ## Development ```sh diff --git a/bun.lock b/bun.lock index f9541e9..6974f59 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@anthropic-ai/claude-agent-sdk": "^0.2.50", "@anthropic-ai/sdk": "^0.78.0", "@hono/node-server": "^1.19.9", + "classic-level": "^3.0.0", "hono": "^4.12.1", }, "devDependencies": { @@ -63,12 +64,36 @@ "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + "abstract-level": ["abstract-level@3.1.1", "", { "dependencies": { "buffer": "^6.0.3", "is-buffer": "^2.0.5", "level-supports": "^6.2.0", "level-transcoder": "^1.0.1", "maybe-combine-errors": "^1.0.0", "module-error": "^1.0.1" } }, "sha512-CW2gKbJFTuX1feMvOrvsVMmijAOgI9kg2Ie9Dq3gOcMt/dVVoVmqNlLcEUCT13NxHFMEajcUcVBIplbyDroDiw=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + "classic-level": ["classic-level@3.0.0", "", { "dependencies": { "abstract-level": "^3.1.0", "module-error": "^1.0.1", "napi-macros": "^2.2.2", "node-gyp-build": "^4.3.0" } }, "sha512-yGy8j8LjPbN0Bh3+ygmyYvrmskVita92pD/zCoalfcC9XxZj6iDtZTAnz+ot7GG8p9KLTG+MZ84tSA4AhkgVZQ=="], + "hono": ["hono@4.12.1", "", {}, "sha512-hi9afu8g0lfJVLolxElAZGANCTTl6bewIdsRNhaywfP9K8BPf++F2z6OLrYGIinUwpRKzbZHMhPwvc0ZEpAwGw=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "is-buffer": ["is-buffer@2.0.5", "", {}, "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ=="], + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + "level-supports": ["level-supports@6.2.0", "", {}, "sha512-QNxVXP0IRnBmMsJIh+sb2kwNCYcKciQZJEt+L1hPCHrKNELllXhvrlClVHXBYZVT+a7aTSM6StgNXdAldoab3w=="], + + "level-transcoder": ["level-transcoder@1.0.1", "", { "dependencies": { "buffer": "^6.0.3", "module-error": "^1.0.1" } }, "sha512-t7bFwFtsQeD8cl8NIoQ2iwxA0CL/9IFw7/9gAjOonH0PWTTiRfY7Hq+Ejbsxh86tXobDQ6IOiddjNYIfOBs06w=="], + + "maybe-combine-errors": ["maybe-combine-errors@1.0.0", "", {}, "sha512-eefp6IduNPT6fVdwPp+1NgD0PML1NU5P6j1Mj5nz1nidX8/sWY7119WL8vTAHgqfsY74TzW0w1XPgdYEKkGZ5A=="], + + "module-error": ["module-error@1.0.2", "", {}, "sha512-0yuvsqSCv8LbaOKhnsQ/T5JhyFlCYLPXK3U2sgV10zoKQwzs/MyfuQUOZQ1V/6OCOJsK/TRgNVrPuPDqtdMFtA=="], + + "napi-macros": ["napi-macros@2.2.2", "", {}, "sha512-hmEVtAGYzVQpCKdbQea4skABsdXW4RUh5t5mJ2zzqowJS2OyXZTU1KhDVFhx+NlWZ4ap9mqR9TcDO3LTTttd+g=="], + + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..88c7db5 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,6 @@ +# Bun's test runner owns the backend suite only. The web/ tests are React +# components that need a DOM, which is configured for vitest in web/vite.config.ts +# and is not visible to `bun test` — without this scope a bare `bun test` collects +# them and fails with "document is not defined". Run them with `bun run test:web`. +[test] +root = "src" diff --git a/docs/superpowers/plans/2026-07-19-desktop-group-import.md b/docs/superpowers/plans/2026-07-19-desktop-group-import.md new file mode 100644 index 0000000..a7cbe5f --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-desktop-group-import.md @@ -0,0 +1,1010 @@ +# Desktop Group Import (Phase 1) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Import the user's Claude Desktop groups (Tolaria/OpenBB/Cashins Comments) and their session assignments into the notebook, add an Ungrouped view, and block group edits while Claude Desktop is running. + +**Architecture:** A new `src/desktop-groups.ts` reads Claude Desktop's Chromium LocalStorage (`dframe-group-scopes`) from a snapshot copy using `classic-level`, normalizes it, and joins Desktop session ids to notebook session ids. `importDesktopGroups` in `src/groups.ts` reconciles those into the existing `groups`/`session_groups` tables (mirroring Desktop by `desktop_id`, leaving manual groups untouched). New routes/CLI expose a "Sync from Claude Desktop" action and an Ungrouped page. A single policy switch blocks group-mutating routes while Desktop runs. + +**Tech Stack:** Bun, bun:sqlite, Hono, TypeScript, `classic-level` (new dep), `bun test`. + +## Global Constraints + +- Runtime **Bun**; verify with `bun run check` (`bun test` + `bun --bun tsc --noEmit`). Pre-commit hook runs both. +- New dependency **`classic-level`** is permitted for this feature (a maintained LevelDB binding; confirmed working under Bun v1.3.14, opens a snapshot copy of the Desktop store). No other new deps. +- DB uses `PRAGMA foreign_keys = ON`. `session_groups.group_id` has `ON DELETE CASCADE`; `session_groups.session_id` has NO FK. Keep it that way. +- `groups.name` is UNIQUE — **keep it**. Import matches groups by `desktop_id`; a Desktop group whose name collides with a different-identity group is skipped and counted, not force-inserted. +- **Phase 1 is read-only toward Desktop.** The reader operates only on a temp **copy** of the Desktop store and never writes to `~/Library/Application Support/Claude`. +- **Desktop data shape** (pinned against the real store): the localStorage key contains `dframe-group-scopes`; value is `0x01` + Latin-1 JSON (byte `0x00` would mean UTF-16LE). Decoded JSON: + ``` + { "value": { "/": { + "groups": [ { "id": "cg-", "name": "Tolaria" }, ... ], + "assignments": { "code:local_": "cg-", ... } } }, + "tabId": "...", "timestamp": ... } + ``` +- **Join:** an assignment key `code:local_` → file `claude-code-sessions/**/local_.json` → its `cliSessionId` → notebook `sessions.id`. +- **Guard:** `GROUP_EDIT_POLICY` = `"block"` this phase; group-mutating routes are blocked (no mutation, banner shown) while Desktop runs; the import action is exempt. The running-probe is overridable for tests. +- All user text via `escapeHtml`. Follow existing patterns (inline `db.query`, Hono form-POST + redirect, string-concat views). +- Tests must assert real behavior and be green before each commit. Branch: `feature/session-groups`. + +--- + +### Task 1: Migration — `desktop_id` column on `groups` + +**Files:** +- Modify: `src/db.ts` (add a partial unique index to the schema `db.exec` block; add one `ALTER TABLE` in the migrations try/catch section near lines 71-81) +- Test: `src/db.test.ts` + +**Interfaces:** +- Produces: `groups.desktop_id TEXT` (nullable) + `CREATE UNIQUE INDEX idx_groups_desktop_id ON groups(desktop_id) WHERE desktop_id IS NOT NULL`. + +- [ ] **Step 1: Write the failing test** + +Add to `src/db.test.ts` inside `describe("db", ...)`: + +```ts +test("groups has a nullable desktop_id column with a partial unique index", () => { + const db = initDb(dbPath); + const cols = db.query("PRAGMA table_info(groups)").all() as { name: string; notnull: number }[]; + const desktop = cols.find((c) => c.name === "desktop_id"); + expect(desktop).toBeTruthy(); + expect(desktop!.notnull).toBe(0); + // two manual groups (NULL desktop_id) coexist; two rows sharing a non-null desktop_id are rejected + db.query("INSERT INTO groups (name, created_at) VALUES ('m1', datetime('now'))").run(); + db.query("INSERT INTO groups (name, created_at) VALUES ('m2', datetime('now'))").run(); + db.query("INSERT INTO groups (name, created_at, desktop_id) VALUES ('d1', datetime('now'), 'cg-x')").run(); + expect(() => + db.query("INSERT INTO groups (name, created_at, desktop_id) VALUES ('d2', datetime('now'), 'cg-x')").run() + ).toThrow(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/db.test.ts` +Expected: FAIL — no `desktop_id` column. + +- [ ] **Step 3: Implement** + +3a. In `src/db.ts`, add the partial index to the `db.exec(\`...\`)` schema block, right after the existing `idx_session_groups_group` index line: + +```sql + CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_desktop_id ON groups(desktop_id) WHERE desktop_id IS NOT NULL; +``` + +3b. Add a migration in the try/catch section (after the `is_subagent` migration near line 81): + +```ts + try { + db.exec(`ALTER TABLE groups ADD COLUMN desktop_id TEXT`); + } catch { + // Column already exists — ignore + } +``` + +Note: the `ALTER TABLE` must run before the index is used, but since both are idempotent and `initDb` runs the schema `exec` (creating the index only if the column exists on fresh DBs) — for existing DBs the ALTER adds the column, and the `CREATE INDEX IF NOT EXISTS` in the schema block runs on the next `initDb`. To guarantee order on a fresh AND existing DB, also create the index in the migration block right after the ALTER: + +```ts + try { + db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_desktop_id ON groups(desktop_id) WHERE desktop_id IS NOT NULL`); + } catch { + // Index already exists — ignore + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/db.test.ts && bun run typecheck` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/db.ts src/db.test.ts +git commit -m "feat(desktop-import): add groups.desktop_id column + partial unique index" +``` + +--- + +### Task 2: Desktop reader module — `src/desktop-groups.ts` + +**Files:** +- Create: `src/desktop-groups.ts` +- Test: `src/desktop-groups.test.ts` +- Modify: `package.json` (add `classic-level` dependency via `bun add`) + +**Interfaces:** +- Produces: + - `type DesktopGroup = { desktopId: string; name: string }` + - `type DesktopAssignment = { cliSessionId: string; desktopGroupId: string }` + - `type DesktopGroupsData = { groups: DesktopGroup[]; assignments: DesktopAssignment[] }` + - `class DesktopGroupsFormatError extends Error` + - `defaultLeveldbDir(): string`, `defaultSessionsDir(): string` + - `readDesktopGroups(opts?: { leveldbDir?: string; sessionsDir?: string }): Promise` + - `isClaudeDesktopRunning(): boolean`, `__setDesktopRunningProbe(fn: (() => boolean) | null): void` + - `GROUP_EDIT_POLICY: "block" | "warn"`, `groupEditBlocked(): boolean` + +- [ ] **Step 1: Add the dependency** + +Run: `bun add classic-level` +Expected: `classic-level` added to `package.json` dependencies; `bun install` succeeds. + +- [ ] **Step 2: Write the failing test** + +Create `src/desktop-groups.test.ts`. It builds a synthetic Desktop store with `classic-level` (same lib the reader uses) and synthetic `local_*.json` files, so no real data is needed: + +```ts +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { ClassicLevel } from "classic-level"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { + readDesktopGroups, isClaudeDesktopRunning, __setDesktopRunningProbe, + groupEditBlocked, GROUP_EDIT_POLICY, DesktopGroupsFormatError, +} from "./desktop-groups"; + +async function writeStore(dir: string, valueObj: unknown, enc: 0 | 1 = 1) { + const db = new ClassicLevel(dir, { keyEncoding: "binary", valueEncoding: "binary" }); + await db.open(); + const key = Buffer.concat([ + Buffer.from("_https://claude.ai", "latin1"), + Buffer.from([0x00, 0x01]), + Buffer.from("LSS-persisted.dframe-group-scopes", "latin1"), + ]); + const json = JSON.stringify(valueObj); + const body = enc === 0 ? Buffer.from(json, "utf16le") : Buffer.from(json, "latin1"); + await db.put(key, Buffer.concat([Buffer.from([enc]), body])); + await db.close(); +} + +function writeSession(sessionsDir: string, uuid: string, cliSessionId: string) { + const scope = join(sessionsDir, "acct", "org"); + mkdirSync(scope, { recursive: true }); + writeFileSync(join(scope, `local_${uuid}.json`), JSON.stringify({ sessionId: `local_${uuid}`, cliSessionId })); +} + +describe("desktop-groups reader", () => { + let tmp: string, leveldbDir: string, sessionsDir: string; + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "dg-test-")); + leveldbDir = join(tmp, "leveldb"); + sessionsDir = join(tmp, "sessions"); + }); + afterEach(() => { __setDesktopRunningProbe(null); rmSync(tmp, { recursive: true, force: true }); }); + + const sample = { + value: { + "acct/org": { + groups: [ + { id: "cg-1", name: "Tolaria" }, + { id: "cg-2", name: "OpenBB" }, + ], + assignments: { + "code:local_uuidA": "cg-1", + "code:local_uuidB": "cg-2", + "code:local_missing": "cg-1", + }, + }, + }, + tabId: "t", timestamp: 1, + }; + + test("reads and normalizes groups + assignments, joining to cliSessionId", async () => { + await writeStore(leveldbDir, sample); + writeSession(sessionsDir, "uuidA", "cli-A"); + writeSession(sessionsDir, "uuidB", "cli-B"); + // 'missing' has no local_ file → dropped + const data = (await readDesktopGroups({ leveldbDir, sessionsDir }))!; + expect(data.groups).toEqual([ + { desktopId: "cg-1", name: "Tolaria" }, + { desktopId: "cg-2", name: "OpenBB" }, + ]); + expect(data.assignments.sort((a, b) => a.cliSessionId.localeCompare(b.cliSessionId))).toEqual([ + { cliSessionId: "cli-A", desktopGroupId: "cg-1" }, + { cliSessionId: "cli-B", desktopGroupId: "cg-2" }, + ]); + }); + + test("returns null when the leveldb dir does not exist", async () => { + expect(await readDesktopGroups({ leveldbDir: join(tmp, "nope"), sessionsDir })).toBeNull(); + }); + + test("returns null when the key is absent", async () => { + const db = new ClassicLevel(leveldbDir, { keyEncoding: "binary", valueEncoding: "binary" }); + await db.open(); await db.put(Buffer.from("other"), Buffer.from("x")); await db.close(); + expect(await readDesktopGroups({ leveldbDir, sessionsDir })).toBeNull(); + }); + + test("throws DesktopGroupsFormatError on unrecognized shape", async () => { + await writeStore(leveldbDir, { value: { "acct/org": { nope: true } }, tabId: "t", timestamp: 1 }); + await expect(readDesktopGroups({ leveldbDir, sessionsDir })).rejects.toBeInstanceOf(DesktopGroupsFormatError); + }); + + test("guard: probe override drives isClaudeDesktopRunning / groupEditBlocked", () => { + __setDesktopRunningProbe(() => true); + expect(isClaudeDesktopRunning()).toBe(true); + expect(groupEditBlocked()).toBe(GROUP_EDIT_POLICY === "block"); + __setDesktopRunningProbe(() => false); + expect(isClaudeDesktopRunning()).toBe(false); + expect(groupEditBlocked()).toBe(false); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `bun test src/desktop-groups.test.ts` +Expected: FAIL — `Cannot find module './desktop-groups'`. + +- [ ] **Step 4: Implement `src/desktop-groups.ts`** + +```ts +import { ClassicLevel } from "classic-level"; +import { homedir, tmpdir } from "os"; +import { join } from "path"; +import { cpSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "fs"; + +export type DesktopGroup = { desktopId: string; name: string }; +export type DesktopAssignment = { cliSessionId: string; desktopGroupId: string }; +export type DesktopGroupsData = { groups: DesktopGroup[]; assignments: DesktopAssignment[] }; + +export class DesktopGroupsFormatError extends Error {} + +export function defaultLeveldbDir(): string { + return join(homedir(), "Library/Application Support/Claude/Local Storage/leveldb"); +} +export function defaultSessionsDir(): string { + return join(homedir(), "Library/Application Support/Claude/claude-code-sessions"); +} + +/** Map Desktop session uuid (from local_.json) -> cliSessionId. */ +function buildSessionIdMap(sessionsDir: string): Map { + const map = new Map(); + if (!existsSync(sessionsDir)) return map; + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, entry.name); + if (entry.isDirectory()) walk(p); + else if (entry.isFile() && entry.name.startsWith("local_") && entry.name.endsWith(".json")) { + const uuid = entry.name.slice("local_".length, -".json".length); + try { + const data = JSON.parse(readFileSync(p, "utf-8")); + if (typeof data.cliSessionId === "string") map.set(uuid, data.cliSessionId); + } catch { /* skip unreadable session file */ } + } + } + }; + walk(sessionsDir); + return map; +} + +export async function readDesktopGroups( + opts: { leveldbDir?: string; sessionsDir?: string } = {} +): Promise { + const leveldbDir = opts.leveldbDir ?? defaultLeveldbDir(); + const sessionsDir = opts.sessionsDir ?? defaultSessionsDir(); + if (!existsSync(leveldbDir)) return null; + + const temp = mkdtempSync(join(tmpdir(), "notebook-ldb-")); + try { + cpSync(leveldbDir, temp, { recursive: true }); + try { rmSync(join(temp, "LOCK")); } catch { /* copy may not have one */ } + + const db = new ClassicLevel(temp, { keyEncoding: "binary", valueEncoding: "binary" }); + await db.open(); + let raw: Buffer | null = null; + try { + for await (const [k, v] of db.iterator()) { + if (Buffer.from(k as unknown as Uint8Array).toString("latin1").includes("dframe-group-scopes")) { + raw = Buffer.from(v as unknown as Uint8Array); + break; + } + } + } finally { + await db.close(); + } + if (!raw) return null; + + const enc = raw[0]; + const text = enc === 0 ? raw.subarray(1).toString("utf16le") : raw.subarray(1).toString("latin1"); + let obj: any; + try { obj = JSON.parse(text); } + catch { throw new DesktopGroupsFormatError("dframe-group-scopes value is not valid JSON"); } + + const scopes = obj?.value; + if (!scopes || typeof scopes !== "object") { + throw new DesktopGroupsFormatError("unexpected dframe-group-scopes shape (missing .value)"); + } + + const sessionMap = buildSessionIdMap(sessionsDir); + const groups: DesktopGroup[] = []; + const assignments: DesktopAssignment[] = []; + const seen = new Set(); + + for (const scope of Object.values(scopes)) { + if (!scope || !Array.isArray(scope.groups) || typeof scope.assignments !== "object" || scope.assignments === null) { + throw new DesktopGroupsFormatError("unexpected scope shape (groups/assignments)"); + } + for (const g of scope.groups) { + if (g && typeof g.id === "string" && typeof g.name === "string" && !seen.has(g.id)) { + seen.add(g.id); + groups.push({ desktopId: g.id, name: g.name }); + } + } + for (const [assignKey, groupId] of Object.entries(scope.assignments)) { + const m = /local_([0-9a-fA-F-]+)$/.exec(assignKey); + if (!m || typeof groupId !== "string") continue; + const cliSessionId = sessionMap.get(m[1]); + if (cliSessionId) assignments.push({ cliSessionId, desktopGroupId: groupId }); + } + } + return { groups, assignments }; + } finally { + try { rmSync(temp, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } + } +} + +function defaultDesktopProbe(): boolean { + try { + const res = Bun.spawnSync(["pgrep", "-f", "Claude.app/Contents/MacOS/Claude"]); + return res.exitCode === 0 && res.stdout.toString().trim().length > 0; + } catch { + return false; // fail-open: never wedge the notebook on a detection error + } +} + +let _probe: () => boolean = defaultDesktopProbe; +export function isClaudeDesktopRunning(): boolean { return _probe(); } +export function __setDesktopRunningProbe(fn: (() => boolean) | null): void { + _probe = fn ?? defaultDesktopProbe; +} + +export const GROUP_EDIT_POLICY: "block" | "warn" = "block"; +export function groupEditBlocked(): boolean { + return GROUP_EDIT_POLICY === "block" && isClaudeDesktopRunning(); +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `bun test src/desktop-groups.test.ts && bun run typecheck` +Expected: PASS. + +- [ ] **Step 6: Spike — verify against the REAL store (manual, no commit of real data)** + +Run this one-off (does NOT get committed; confirms the reader works on the live store): + +```bash +bun -e 'import { readDesktopGroups } from "./src/desktop-groups"; const d = await readDesktopGroups(); console.log("groups:", d?.groups); console.log("assignments:", d?.assignments.length);' +``` + +Expected: prints groups including `Tolaria`, `OpenBB`, `Cashins Comments`, and a non-zero assignment count. Record the output in the task report. Do not add real data to any test fixture. + +- [ ] **Step 7: Commit** + +```bash +git add src/desktop-groups.ts src/desktop-groups.test.ts package.json bun.lock +git commit -m "feat(desktop-import): add Desktop LocalStorage reader + running-probe" +``` + +--- + +### Task 3: Import / reconcile — `importDesktopGroups` in `src/groups.ts` + +**Files:** +- Modify: `src/groups.ts` (add `importDesktopGroups` + `ImportSummary`) +- Test: `src/groups.test.ts` + +**Interfaces:** +- Consumes: `DesktopGroupsData` from `./desktop-groups`. +- Produces: + - `type ImportSummary = { groupsAdded: number; groupsRenamed: number; groupsRemoved: number; sessionsAssigned: number; skippedNoSession: number; skippedNameCollision: number }` + - `importDesktopGroups(db: Database, data: DesktopGroupsData): ImportSummary` + +- [ ] **Step 1: Write the failing test** + +Add to `src/groups.test.ts` (reuse the existing `seedProject`/`seedSession` helpers in that file): + +```ts +import { importDesktopGroups } from "./groups"; +import type { DesktopGroupsData } from "./desktop-groups"; + +describe("importDesktopGroups", () => { + let tempDir: string; + let db: ReturnType; + function seedProject(id = "p") { + db.query("INSERT OR IGNORE INTO projects (id, path, display_name) VALUES (?, ?, ?)").run(id, "/tmp/" + id, id); + } + function seedSession(id: string) { + db.query( + `INSERT INTO sessions (id, project_id, project_path, source_path, started_at, message_count, ingested_at) + VALUES (?, 'p', '/tmp/p', '/tmp/s.jsonl', '2026-07-10T00:00:00Z', 3, datetime('now'))` + ).run(id); + } + beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "imp-test-")); db = initDb(join(tempDir, "t.db")); seedProject(); }); + afterEach(() => { closeDb(); rmSync(tempDir, { recursive: true, force: true }); }); + + const data = (): DesktopGroupsData => ({ + groups: [{ desktopId: "cg-1", name: "Tolaria" }, { desktopId: "cg-2", name: "OpenBB" }], + assignments: [ + { cliSessionId: "s1", desktopGroupId: "cg-1" }, + { cliSessionId: "s2", desktopGroupId: "cg-2" }, + { cliSessionId: "sMissing", desktopGroupId: "cg-1" }, + ], + }); + + test("creates desktop groups, assigns existing sessions, skips missing", () => { + seedSession("s1"); seedSession("s2"); + const sum = importDesktopGroups(db, data()); + expect(sum.groupsAdded).toBe(2); + expect(sum.sessionsAssigned).toBe(2); + expect(sum.skippedNoSession).toBe(1); + const tol = db.query("SELECT id FROM groups WHERE desktop_id='cg-1'").get() as { id: number }; + expect((db.query("SELECT group_id FROM session_groups WHERE session_id='s1'").get() as any).group_id).toBe(tol.id); + }); + + test("is idempotent and renames when Desktop renames", () => { + seedSession("s1"); seedSession("s2"); + importDesktopGroups(db, data()); + const second = { ...data(), groups: [{ desktopId: "cg-1", name: "Tolaria2" }, { desktopId: "cg-2", name: "OpenBB" }] }; + const sum = importDesktopGroups(db, second); + expect(sum.groupsAdded).toBe(0); + expect(sum.groupsRenamed).toBe(1); + expect((db.query("SELECT name FROM groups WHERE desktop_id='cg-1'").get() as any).name).toBe("Tolaria2"); + expect((db.query("SELECT COUNT(*) c FROM groups WHERE desktop_id IS NOT NULL").get() as any).c).toBe(2); + }); + + test("removes desktop groups dropped from Desktop; leaves manual groups untouched", () => { + seedSession("s1"); seedSession("s2"); + createGroup(db, "MyManual"); // desktop_id NULL + importDesktopGroups(db, data()); + const only = { groups: [{ desktopId: "cg-1", name: "Tolaria" }], assignments: [{ cliSessionId: "s1", desktopGroupId: "cg-1" }] }; + const sum = importDesktopGroups(db, only); + expect(sum.groupsRemoved).toBe(1); // cg-2 removed + expect(db.query("SELECT id FROM groups WHERE desktop_id='cg-2'").get()).toBeNull(); + expect(db.query("SELECT id FROM groups WHERE name='MyManual'").get()).toBeTruthy(); + }); + + test("mirror: session hand-moved off a desktop group snaps back on re-import", () => { + seedSession("s1"); + importDesktopGroups(db, { groups: [{ desktopId: "cg-1", name: "Tolaria" }], assignments: [{ cliSessionId: "s1", desktopGroupId: "cg-1" }] }); + assignSession(db, "s1", null); // user unassigns + const sum = importDesktopGroups(db, { groups: [{ desktopId: "cg-1", name: "Tolaria" }], assignments: [{ cliSessionId: "s1", desktopGroupId: "cg-1" }] }); + expect(sum.sessionsAssigned).toBe(1); + expect(getSessionGroupId(db, "s1")).toBe((db.query("SELECT id FROM groups WHERE desktop_id='cg-1'").get() as any).id); + }); + + test("skips a desktop group whose name collides with a manual group", () => { + seedSession("s1"); + createGroup(db, "Tolaria"); // manual, same name + const sum = importDesktopGroups(db, { groups: [{ desktopId: "cg-1", name: "Tolaria" }], assignments: [] }); + expect(sum.skippedNameCollision).toBe(1); + expect(sum.groupsAdded).toBe(0); + expect(db.query("SELECT desktop_id FROM groups WHERE name='Tolaria'").get()).toEqual({ desktop_id: null }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/groups.test.ts` +Expected: FAIL — `importDesktopGroups` not exported. + +- [ ] **Step 3: Implement in `src/groups.ts`** + +Add the import type and function (append to the module): + +```ts +import type { DesktopGroupsData } from "./desktop-groups"; + +export type ImportSummary = { + groupsAdded: number; + groupsRenamed: number; + groupsRemoved: number; + sessionsAssigned: number; + skippedNoSession: number; + skippedNameCollision: number; +}; + +export function importDesktopGroups(db: Database, data: DesktopGroupsData): ImportSummary { + const summary: ImportSummary = { + groupsAdded: 0, groupsRenamed: 0, groupsRemoved: 0, + sessionsAssigned: 0, skippedNoSession: 0, skippedNameCollision: 0, + }; + + db.transaction(() => { + const desktopIdToGroupId = new Map(); + + // Upsert desktop groups by desktop_id. + for (const g of data.groups) { + const existing = db.query("SELECT id, name FROM groups WHERE desktop_id = ?").get(g.desktopId) as + | { id: number; name: string } | null; + if (existing) { + if (existing.name !== g.name) { + const clash = db.query("SELECT id FROM groups WHERE name = ? AND id != ?").get(g.name, existing.id) as { id: number } | null; + if (!clash) { db.query("UPDATE groups SET name = ? WHERE id = ?").run(g.name, existing.id); summary.groupsRenamed++; } + } + desktopIdToGroupId.set(g.desktopId, existing.id); + } else { + const clash = db.query("SELECT id FROM groups WHERE name = ?").get(g.name) as { id: number } | null; + if (clash) { summary.skippedNameCollision++; continue; } + const row = db.query( + "INSERT INTO groups (name, created_at, desktop_id) VALUES (?, datetime('now'), ?) RETURNING id" + ).get(g.name, g.desktopId) as { id: number }; + desktopIdToGroupId.set(g.desktopId, row.id); + summary.groupsAdded++; + } + } + + // Remove desktop-sourced groups no longer present in Desktop (cascade clears memberships). + const present = new Set(data.groups.map((g) => g.desktopId)); + for (const eg of db.query("SELECT id, desktop_id FROM groups WHERE desktop_id IS NOT NULL").all() as { id: number; desktop_id: string }[]) { + if (!present.has(eg.desktop_id)) { db.query("DELETE FROM groups WHERE id = ?").run(eg.id); summary.groupsRemoved++; } + } + + // Mirror membership for desktop groups: clear all desktop-group memberships, then re-add from Desktop. + db.query("DELETE FROM session_groups WHERE group_id IN (SELECT id FROM groups WHERE desktop_id IS NOT NULL)").run(); + for (const a of data.assignments) { + const gid = desktopIdToGroupId.get(a.desktopGroupId); + if (gid === undefined) continue; // group skipped/unknown + const sess = db.query("SELECT id FROM sessions WHERE id = ?").get(a.cliSessionId); + if (!sess) { summary.skippedNoSession++; continue; } + db.query( + `INSERT INTO session_groups (session_id, group_id, assigned_at) VALUES (?, ?, datetime('now')) + ON CONFLICT(session_id) DO UPDATE SET group_id = excluded.group_id, assigned_at = excluded.assigned_at` + ).run(a.cliSessionId, gid); + summary.sessionsAssigned++; + } + })(); + + return summary; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/groups.test.ts && bun run typecheck` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/groups.ts src/groups.test.ts +git commit -m "feat(desktop-import): reconcile Desktop groups into notebook (mirror by desktop_id)" +``` + +--- + +### Task 4: Ungrouped view + Groups-index entry + +**Files:** +- Modify: `src/web/views/groups.ts` (add `renderUngrouped`; add an "Ungrouped (N)" entry to `renderGroupsIndex`) +- Test: `src/web/views/groups.test.ts` + +**Interfaces:** +- Produces: `renderUngrouped(db: Database, page: number): string` (100/page, most-recent first, "N of M" count, prev/next). +- `renderGroupsIndex` output additionally contains an `href="/groups/ungrouped"` entry with the ungrouped count. + +- [ ] **Step 1: Write the failing test** + +Add to `src/web/views/groups.test.ts` (reuse its `seed` helper; add more sessions): + +```ts +import { renderUngrouped } from "./groups"; + +describe("ungrouped view", () => { + let tempDir: string; + let db: ReturnType; + function seedSession(id: string, started: string) { + db.query("INSERT OR IGNORE INTO projects (id, path, display_name) VALUES ('p','/tmp/p','Proj')").run(); + db.query( + `INSERT INTO sessions (id, project_id, project_path, source_path, started_at, message_count, ingested_at) + VALUES (?, 'p', '/tmp/p', '/tmp/s.jsonl', ?, 3, datetime('now'))` + ).run(id, started); + } + beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "ung-test-")); db = initDb(join(tempDir, "t.db")); }); + afterEach(() => { closeDb(); rmSync(tempDir, { recursive: true, force: true }); }); + + test("index shows an Ungrouped entry with the count", () => { + seedSession("s1", "2026-07-01T00:00:00Z"); + seedSession("s2", "2026-07-02T00:00:00Z"); + const g = createGroup(db, "G"); + assignSession(db, "s1", g); + const html = renderGroupsIndex(db); // from existing import + expect(html).toContain('href="/groups/ungrouped"'); + expect(html).toMatch(/Ungrouped[\s\S]*1/); // one ungrouped (s2) + }); + + test("renderUngrouped lists only ungrouped sessions, most-recent first", () => { + seedSession("s1", "2026-07-01T00:00:00Z"); + seedSession("s2", "2026-07-03T00:00:00Z"); + seedSession("s3", "2026-07-02T00:00:00Z"); + const g = createGroup(db, "G"); + assignSession(db, "s2", g); // grouped → excluded + const html = renderUngrouped(db, 1); + expect(html).toContain('href="/session/s1"'); + expect(html).toContain('href="/session/s3"'); + expect(html).not.toContain('href="/session/s2"'); + // most-recent first: s3 (07-02) before s1 (07-01) + expect(html.indexOf("/session/s3")).toBeLessThan(html.indexOf("/session/s1")); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/web/views/groups.test.ts` +Expected: FAIL — `renderUngrouped` missing and no Ungrouped entry. + +- [ ] **Step 3: Implement in `src/web/views/groups.ts`** + +3a. In `renderGroupsIndex`, after the group list loop (before the closing ``), add an Ungrouped entry: + +```ts + const ungroupedCount = (db.query( + `SELECT COUNT(*) AS c FROM sessions s WHERE NOT EXISTS (SELECT 1 FROM session_groups sg WHERE sg.session_id = s.id)` + ).get() as { c: number }).c; + html += `
`; + html += `Ungrouped`; + html += `${ungroupedCount} session(s)`; + html += `
`; +``` + +3b. Add `renderUngrouped`: + +```ts +export function renderUngrouped(db: Database, page: number): string { + const perPage = 100; + const p = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1; + const total = (db.query( + `SELECT COUNT(*) AS c FROM sessions s WHERE NOT EXISTS (SELECT 1 FROM session_groups sg WHERE sg.session_id = s.id)` + ).get() as { c: number }).c; + const rows = db.query( + `SELECT s.id, p.display_name, s.started_at, s.message_count + FROM sessions s JOIN projects p ON p.id = s.project_id + WHERE NOT EXISTS (SELECT 1 FROM session_groups sg WHERE sg.session_id = s.id) + ORDER BY s.started_at DESC LIMIT ? OFFSET ?` + ).all(perPage, (p - 1) * perPage) as { id: string; display_name: string; started_at: string; message_count: number }[]; + + const from = total === 0 ? 0 : (p - 1) * perPage + 1; + const to = (p - 1) * perPage + rows.length; + let html = `
`; + html += ``; + html += `
Ungrouped
`; + html += `
${from}–${to} of ${total}
`; + if (rows.length === 0) { + html += `
No ungrouped sessions.
`; + } else { + for (const s of rows) { + html += `
`; + html += `${escapeHtml(s.display_name)}`; + html += `
${s.started_at.slice(0, 10)} · ${s.message_count} messages
`; + html += `
`; + } + html += `
`; + if (p > 1) html += `← Prev`; + if (to < total) html += `Next →`; + html += `
`; + } + html += `
`; + return html; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/web/views/groups.test.ts && bun run typecheck` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/web/views/groups.ts src/web/views/groups.test.ts +git commit -m "feat(desktop-import): add Ungrouped view + index entry" +``` + +--- + +### Task 5: Desktop-open guard on the mutating routes + +**Files:** +- Modify: `src/web/server.ts` (guard the four group-mutating routes) +- Test: `src/web/server.test.ts` + +**Interfaces:** +- Consumes: `groupEditBlocked`, `__setDesktopRunningProbe` from `../desktop-groups`; existing `renderGroupsIndex`, `renderSessionDetail`, `renderLayout`. + +- [ ] **Step 1: Write the failing test** + +Add to `src/web/server.test.ts` inside `describe("server", ...)`: + +```ts +describe("Desktop-open guard", () => { + test("blocks group create while Desktop is running, then allows when not", async () => { + const { __setDesktopRunningProbe } = await import("../desktop-groups"); + const app = createApp(db, syncManager); + __setDesktopRunningProbe(() => true); + const f1 = new FormData(); f1.append("name", "Blocked"); + const blocked = await app.request("/groups", { method: "POST", body: f1 }); + expect(blocked.status).toBe(200); + expect(await blocked.text()).toContain("Claude Desktop is open"); + expect(db.query("SELECT id FROM groups WHERE name='Blocked'").get()).toBeNull(); + + __setDesktopRunningProbe(() => false); + const f2 = new FormData(); f2.append("name", "Allowed"); + const ok = await app.request("/groups", { method: "POST", body: f2 }); + expect(ok.status).toBe(302); + expect(db.query("SELECT id FROM groups WHERE name='Allowed'").get()).toBeTruthy(); + __setDesktopRunningProbe(null); + }); + + test("blocks session assign while Desktop is running", async () => { + const { __setDesktopRunningProbe } = await import("../desktop-groups"); + db.query("INSERT OR IGNORE INTO projects (id, path, display_name) VALUES ('p','/tmp/p','P')").run(); + db.query(`INSERT INTO sessions (id, project_id, project_path, source_path, started_at, message_count, ingested_at) + VALUES ('s1','p','/tmp/p','/tmp/s.jsonl','2026-07-10T00:00:00Z',3,datetime('now'))`).run(); + const gid = (db.query("INSERT INTO groups (name, created_at) VALUES ('G', datetime('now')) RETURNING id").get() as any).id; + const app = createApp(db, syncManager); + __setDesktopRunningProbe(() => true); + const f = new FormData(); f.append("group_id", String(gid)); + const res = await app.request("/sessions/s1/group", { method: "POST", body: f }); + expect(res.status).toBe(200); + expect(await res.text()).toContain("Claude Desktop is open"); + expect(db.query("SELECT group_id FROM session_groups WHERE session_id='s1'").get()).toBeNull(); + __setDesktopRunningProbe(null); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/web/server.test.ts` +Expected: FAIL — edits go through while "running." + +- [ ] **Step 3: Implement in `src/web/server.ts`** + +3a. Add to the imports: + +```ts +import { groupEditBlocked } from "../desktop-groups"; +``` + +3b. In each of the four mutating routes — `POST /groups`, `POST /groups/:id/rename`, `POST /groups/:id/delete`, `POST /sessions/:id/group` — add a guard as the first line of the handler body. For the two that render the Groups index on error, block by re-rendering the index with the banner: + +```ts + if (groupEditBlocked()) { + return c.html(renderLayout("Groups — Engineering Notebook", { + body: renderGroupsIndex(db, "Claude Desktop is open — quit it before editing groups."), + activeTab: "groups", + })); + } +``` + +For `POST /sessions/:id/group`, block by re-rendering the session view with the banner. Since `renderSessionDetail` has no error param, prepend a banner in the layout body: + +```ts + if (groupEditBlocked()) { + const sessionId = c.req.param("id"); + const banner = `
Claude Desktop is open — quit it before editing groups.
`; + return c.html(renderLayout("Session — Engineering Notebook", { + activeTab: "journal", + panel1: renderJournalDateIndex(db, undefined), + panel2: '
Blocked.
', + panel3: banner + renderSessionDetail(db, sessionId), + })); + } +``` + +(`renderJournalDateIndex` is already imported in server.ts.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/web/server.test.ts && bun run typecheck` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/web/server.ts src/web/server.test.ts +git commit -m "feat(desktop-import): block group edits while Claude Desktop is running" +``` + +--- + +### Task 6: Import route + button + CLI subcommand + +**Files:** +- Modify: `src/web/server.ts` (add `POST /groups/import-desktop`, `GET /groups/ungrouped`) +- Modify: `src/web/views/groups.ts` (add a "Sync from Claude Desktop" button to the index) +- Modify: `src/index.ts` (add `import-desktop-groups` CLI subcommand) +- Test: `src/web/server.test.ts` + +**Interfaces:** +- Consumes: `readDesktopGroups` from `../desktop-groups`; `importDesktopGroups` from `../groups`; `renderUngrouped` from `./views/groups`. + +- [ ] **Step 1: Write the failing test** + +Add to `src/web/server.test.ts`: + +```ts +describe("Groups import + ungrouped routes", () => { + test("GET /groups/ungrouped renders the ungrouped page", async () => { + db.query("INSERT OR IGNORE INTO projects (id, path, display_name) VALUES ('p','/tmp/p','P')").run(); + db.query(`INSERT INTO sessions (id, project_id, project_path, source_path, started_at, message_count, ingested_at) + VALUES ('s1','p','/tmp/p','/tmp/s.jsonl','2026-07-10T00:00:00Z',3,datetime('now'))`).run(); + const app = createApp(db, syncManager); + const res = await app.request("/groups/ungrouped"); + expect(res.status).toBe(200); + expect(await res.text()).toContain("Ungrouped"); + }); + + test("POST /groups/import-desktop redirects to /groups", async () => { + const app = createApp(db, syncManager); + // No Desktop store on CI/temp env → import is a no-op but must not 500. + const res = await app.request("/groups/import-desktop", { method: "POST", body: new FormData() }); + expect(res.status).toBe(302); + }); + + test("Groups index shows the Sync from Claude Desktop button", async () => { + const app = createApp(db, syncManager); + const html = await (await app.request("/groups")).text(); + expect(html).toContain("Sync from Claude Desktop"); + expect(html).toContain('action="/groups/import-desktop"'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/web/server.test.ts` +Expected: FAIL — routes/button missing. + +- [ ] **Step 3: Implement** + +3a. `src/web/server.ts` imports: + +```ts +import { readDesktopGroups } from "../desktop-groups"; +import { importDesktopGroups } from "../groups"; +import { renderUngrouped } from "./views/groups"; +``` + +3b. Add routes (near the other Groups routes, before `return app;`): + +```ts + app.get("/groups/ungrouped", (c) => { + const page = parseInt(c.req.query("page") || "1", 10); + return c.html(renderLayout("Ungrouped — Engineering Notebook", { + body: renderUngrouped(db, isNaN(page) ? 1 : page), + activeTab: "groups", + })); + }); + + app.post("/groups/import-desktop", async (c) => { + let banner: string; + try { + const data = await readDesktopGroups(); + if (!data) { + banner = "No Claude Desktop groups found."; + } else { + const s = importDesktopGroups(db, data); + banner = `Imported from Desktop — +${s.groupsAdded} groups, ${s.sessionsAssigned} sessions assigned` + + (s.skippedNoSession ? `, ${s.skippedNoSession} skipped (not ingested)` : "") + + (s.skippedNameCollision ? `, ${s.skippedNameCollision} name-collision(s) skipped` : "") + "."; + } + } catch (err) { + banner = `Import failed: ${err instanceof Error ? err.message : String(err)}`; + } + return c.html(renderLayout("Groups — Engineering Notebook", { + body: renderGroupsIndex(db, banner), + activeTab: "groups", + })); + }); +``` + +Note: `renderGroupsIndex`'s second param is styled as an error (red). That's acceptable for the summary banner in Phase 1; the reviewer may suggest a neutral variant — keep as-is unless the review requires otherwise. + +3c. `src/web/views/groups.ts` — in `renderGroupsIndex`, add the button above the "New group" form: + +```ts + html += `
`; + html += ``; + html += `
`; +``` + +3d. `src/index.ts` uses `const command = process.argv[2]; switch (command) { case "ingest": { … break; } … }`. Add a new `case` alongside the others (e.g. after `case "summarize"`), matching that style (each case reads `loadConfig()`, `initDb(config.db_path)`, and ends with `break;`): + +```ts + case "import-desktop-groups": { + const { readDesktopGroups } = await import("./desktop-groups"); + const { importDesktopGroups } = await import("./groups"); + const config = loadConfig(); + const db = initDb(config.db_path); + const data = await readDesktopGroups(); + if (!data) { + console.log("No Claude Desktop groups found."); + } else { + const s = importDesktopGroups(db, data); + console.log( + `Imported: +${s.groupsAdded} groups, ${s.groupsRenamed} renamed, ${s.groupsRemoved} removed, ` + + `${s.sessionsAssigned} sessions assigned, ${s.skippedNoSession} skipped (not ingested), ` + + `${s.skippedNameCollision} name-collision(s).` + ); + } + closeDb(); + break; + } +``` + +Also update the usage string at the `default` case (currently `"Usage: notebook "`) to include `import-desktop-groups`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/web/server.test.ts && bun run check` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/web/server.ts src/web/views/groups.ts src/index.ts src/web/views/groups.test.ts +git commit -m "feat(desktop-import): add import route, Sync button, and CLI subcommand" +``` + +--- + +### Task 7: Full suite green + live smoke check + +**Files:** verification only. + +- [ ] **Step 1: Full suite + typecheck** + +Run: `bun run check` +Expected: all tests pass; `All checks passed.` + +- [ ] **Step 2: Live smoke — real Desktop import (Desktop may be open; import reads a copy)** + +```bash +engineering-notebook import-desktop-groups +``` +Expected: prints a summary with `+3 groups` (Tolaria, OpenBB, Cashins Comments) and a non-zero `sessions assigned`. Record output. + +- [ ] **Step 3: Live smoke — web views + guard** + +```bash +engineering-notebook serve --port 3941 & +sleep 1 +curl -s http://localhost:3941/groups | grep -o "Sync from Claude Desktop" | head -1 +curl -s http://localhost:3941/groups | grep -oE 'Tolaria|OpenBB|Cashins Comments' | sort -u +curl -s -o /dev/null -w "ungrouped=%{http_code}\n" http://localhost:3941/groups/ungrouped +kill %1 +``` +Expected: button present; the three group names appear; `ungrouped=200`. If Claude Desktop is running, also confirm a group create is blocked with the banner (manual check in the browser). Record output. + +- [ ] **Step 4: Commit any touch-ups (only if Steps 1-3 surfaced a fix)** + +```bash +git add -A +git commit -m "chore(desktop-import): finalize Phase 1" +``` + +--- + +## Self-Review + +**Spec coverage:** +- `desktop_id` column + provenance → Task 1. ✔ +- Reader (snapshot copy, extract, decode, validate shape, join to cliSessionId) → Task 2. ✔ +- `isClaudeDesktopRunning` + `GROUP_EDIT_POLICY`/`groupEditBlocked` → Task 2. ✔ +- Import/reconcile (mirror by desktop_id, remove dropped, skip name-collision, skip missing session, manual groups untouched, idempotent) → Task 3. ✔ +- Ungrouped view (paginated, recent-first, count) + index entry → Task 4. ✔ +- Block guard on the four mutating routes → Task 5. ✔ +- Import route + Sync button + CLI subcommand → Task 6. ✔ +- Import exempt from the guard (import route never calls `groupEditBlocked`) → Task 6. ✔ +- `name` UNIQUE kept; collisions skipped/reported → Tasks 1, 3. ✔ +- Reader never writes to Desktop (copy only) → Task 2. ✔ +- Live verification incl. real store → Tasks 2 (spike) & 7. ✔ + +**Placeholder scan:** No TBD/TODO; every code step has complete code; test steps show assertions. The reader's exact byte format is pinned in Global Constraints (verified against the real store), not deferred. + +**Type consistency:** `DesktopGroupsData`/`DesktopGroup`/`DesktopAssignment` defined in Task 2 are consumed unchanged in Tasks 3 and 6. `ImportSummary` fields set in Task 3 match the banner/CLI strings in Task 6. `readDesktopGroups`/`importDesktopGroups`/`renderUngrouped`/`groupEditBlocked`/`__setDesktopRunningProbe` names match across Tasks 2-6. Route paths (`/groups/import-desktop`, `/groups/ungrouped`) match between Task 6 routes, the Task 4/6 view button, and the Task 6 tests. diff --git a/docs/superpowers/plans/2026-07-19-phase1-foundation-session-display.md b/docs/superpowers/plans/2026-07-19-phase1-foundation-session-display.md new file mode 100644 index 0000000..f7d7c59 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-phase1-foundation-session-display.md @@ -0,0 +1,487 @@ +# Phase 1 — Foundation + Session Display Parity — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A React/Vite app in `web/`, served by the Hono backend via a JSON API, rendering a polished session viewer (collapsible tools, thinking, subagent nesting, client-side hide/show thinking & tools). + +**Architecture:** Backend adds additive `/api/*` JSON routes (structured transcript, subagents, session list) to the existing Hono app; a Vite React SPA in `web/` consumes them (dev: Vite proxy → Hono; prod: Hono serves `web/dist`). No changes to ingest/summaries/DB or the legacy views. + +**Tech Stack:** Bun (runtime + `bun test`), Hono, bun:sqlite; React + Vite + TypeScript + Tailwind (frontend); vitest + @testing-library/react (frontend tests). + +## Global Constraints + +- Runtime **Bun**. Backend tests: `bun test`. Frontend tests: `vitest` (run via `bun x vitest run` from `web/`). +- **Additive only:** new `/api/*` routes and a new `web/` app. Do NOT modify ingest/summarize/DB schema or the existing server-rendered routes/views. The React app is opt-in behind a `serve --react` flag. +- **Subagent layout (confirmed):** `//subagents/agent-.jsonl` + sibling `agent-.meta.json` = `{ agentType, description, toolUseId, spawnDepth }`. Map a subagent to its parent Task tool_use **exactly** via `meta.toolUseId`. +- **Claude JSONL blocks:** `{type:"user"|"assistant", message:{content}}`, content string or array of `text{text}` / `thinking{thinking}` / `tool_use{id,name,input}` / `tool_result{tool_use_id,content(string|array)}`. Records carry `uuid`, `parentUuid`, `timestamp`, `isSidechain`. +- **Codex JSONL:** `response_item` → `payload:{type:"message",role,content:[input_text|output_text]}}` — text only. +- API JSON on success; `{ error }` + status on failure. Reuse `src/transcript.ts` logic where possible. +- Toggles default **off**; thinking often empty → show an empty-state note. +- Tests assert real behavior; `bun run check` (backend) green before each backend commit; `vitest run` green before each frontend commit. Branch: `feature/react-frontend` (created in Task 1). + +--- + +### Task 1: Repo scaffold + Vite↔Hono wiring (end-to-end "hello") + +**Files:** +- Create: `web/` Vite React-TS app (`web/package.json`, `web/vite.config.ts`, `web/index.html`, `web/src/main.tsx`, `web/src/App.tsx`, `web/tailwind.config.js`, `web/postcss.config.js`, `web/src/index.css`) +- Create: `src/web/api/index.ts` (Hono sub-router mounted at `/api`) +- Modify: `src/web/server.ts` (mount `/api`; in prod, serve `web/dist` static + SPA fallback when a `react` flag is set) +- Modify: `src/index.ts` (`serve` reads a `--react` flag → pass to `createApp`/serving) +- Test: `src/web/api/api.test.ts` + +**Interfaces:** +- Produces: `createApiRouter(db): Hono` mounted at `/api`; `GET /api/ping` → `{ ok: true }`. + +- [ ] **Step 1: Create the branch + scaffold Vite app** + +```bash +git checkout -b feature/react-frontend +mkdir -p web && cd web +bun create vite . --template react-ts # accept overwrite into empty dir +bun add -d tailwindcss postcss autoprefixer @testing-library/react @testing-library/jest-dom jsdom vitest @vitejs/plugin-react +bunx tailwindcss init -p +cd .. +``` +Configure `web/tailwind.config.js` `content: ["./index.html","./src/**/*.{ts,tsx}"]`; add the three `@tailwind` directives to `web/src/index.css`. Add to `web/vite.config.ts` a dev proxy: +```ts +server: { proxy: { "/api": "http://localhost:3000" } }, +test: { environment: "jsdom", globals: true, setupFiles: "./src/test-setup.ts" }, +``` +Create `web/src/test-setup.ts` with `import "@testing-library/jest-dom";`. + +- [ ] **Step 2: Write the failing backend test** + +Create `src/web/api/api.test.ts`: +```ts +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { initDb, closeDb } from "../../db"; +import { createApiRouter } from "./index"; +import { mkdtempSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +describe("api router", () => { + let tempDir: string, db: ReturnType; + beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "api-")); db = initDb(join(tempDir, "t.db")); }); + afterEach(() => { closeDb(); rmSync(tempDir, { recursive: true, force: true }); }); + + test("GET /api/ping returns ok", async () => { + const app = createApiRouter(db); + const res = await app.request("/ping"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `bun test src/web/api/api.test.ts` +Expected: FAIL — `Cannot find module './index'`. + +- [ ] **Step 4: Implement the router + mount + hello page** + +Create `src/web/api/index.ts`: +```ts +import { Hono } from "hono"; +import { Database } from "bun:sqlite"; + +export function createApiRouter(db: Database): Hono { + const api = new Hono(); + api.get("/ping", (c) => c.json({ ok: true })); + return api; +} +``` +In `src/web/server.ts`, inside `createApp`, mount it: `app.route("/api", createApiRouter(db));` (add the import). Add optional prod static serving: when a `react` option is passed to `createApp`, use Hono's `serveStatic` for `web/dist` with SPA fallback to `web/dist/index.html` for non-`/api` GETs. Thread a `--react` flag from `src/index.ts`'s `serve` case. +Replace `web/src/App.tsx` with a component that fetches `/api/ping` and renders "API ok" when `{ok:true}`. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `bun test src/web/api/api.test.ts && bun run typecheck` +Expected: PASS. + +- [ ] **Step 6: Verify the pipe end-to-end (manual)** + +```bash +# terminal A: backend +engineering-notebook serve --react --port 3000 +# terminal B: vite dev +cd web && bun run dev +# open the Vite URL → should show "API ok" (proxied /api/ping) +# prod: cd web && bun run build ; then engineering-notebook serve --react serves web/dist +``` +Record that both dev (proxy) and prod (static) serve the app and reach `/api/ping`. + +- [ ] **Step 7: Commit** + +```bash +git add web src/web/api/index.ts src/web/server.ts src/index.ts src/web/api/api.test.ts package.json +git commit -m "feat(react): scaffold web/ Vite app + Hono /api router + wiring" +``` + +--- + +### Task 2: Structured transcript endpoint + +**Files:** +- Create: `src/transcript-structured.ts` (message-grouped structured parse) +- Modify: `src/web/api/index.ts` (`GET /api/sessions/:id/transcript`) +- Test: `src/transcript-structured.test.ts`, `src/web/api/api.test.ts` (append) + +**Interfaces:** +- Produces: + - `type StructuredBlock = { kind:"text"|"thinking"|"tool_use"|"tool_result"; content:string; name?:string; id?:string; toolUseId?:string; input?:Record }` + - `type StructuredMessage = { role:"user"|"assistant"; uuid?:string; parentUuid?:string; timestamp?:string; blocks: StructuredBlock[] }` + - `parseStructuredTranscript(jsonlText: string): { messages: StructuredMessage[]; format: "claude"|"codex"|"unknown" }` + +- [ ] **Step 1: Write the failing test** + +Create `src/transcript-structured.test.ts`: +```ts +import { describe, test, expect } from "bun:test"; +import { parseStructuredTranscript } from "./transcript-structured"; + +test("groups blocks under messages, preserving ids and threading", () => { + const lines = [ + JSON.stringify({ type:"assistant", uuid:"u1", parentUuid:null, timestamp:"t1", message:{ content:[ + { type:"thinking", thinking:"th" }, + { type:"text", text:"hi" }, + { type:"tool_use", id:"tu1", name:"Bash", input:{ command:"ls" } }, + ] } }), + JSON.stringify({ type:"user", uuid:"u2", parentUuid:"u1", timestamp:"t2", message:{ content:[ + { type:"tool_result", tool_use_id:"tu1", content:"out" }, + ] } }), + ].join("\n"); + const { messages, format } = parseStructuredTranscript(lines); + expect(format).toBe("claude"); + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ role:"assistant", uuid:"u1", parentUuid:null, timestamp:"t1" }); + expect(messages[0]!.blocks.map(b=>b.kind)).toEqual(["thinking","text","tool_use"]); + expect(messages[0]!.blocks[2]).toMatchObject({ kind:"tool_use", id:"tu1", name:"Bash", input:{ command:"ls" } }); + expect(messages[1]!.blocks[0]).toMatchObject({ kind:"tool_result", toolUseId:"tu1", content:"out" }); +}); + +test("empty → no messages, unknown", () => { + expect(parseStructuredTranscript("")).toEqual({ messages: [], format: "unknown" }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/transcript-structured.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 3: Implement `src/transcript-structured.ts`** + +Reuse the block-parsing logic from `src/transcript.ts` (import its `toolResultToString` by copying the small helper — keep modules independent), but group per record into `StructuredMessage`. Codex `response_item` → one message with text blocks. Full code: +```ts +export type StructuredBlock = { kind:"text"|"thinking"|"tool_use"|"tool_result"; content:string; name?:string; id?:string; toolUseId?:string; input?:Record }; +export type StructuredMessage = { role:"user"|"assistant"; uuid?:string; parentUuid?:string|null; timestamp?:string; blocks: StructuredBlock[] }; +export type StructuredFormat = "claude"|"codex"|"unknown"; + +function resultToString(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) return content.map((b:any)=> b && typeof b==="object" && typeof b.text==="string" ? b.text : JSON.stringify(b)).join("\n"); + if (content == null) return ""; + return JSON.stringify(content, null, 2); +} + +export function parseStructuredTranscript(jsonlText: string): { messages: StructuredMessage[]; format: StructuredFormat } { + const messages: StructuredMessage[] = []; + let format: StructuredFormat = "unknown"; + for (const line of jsonlText.split("\n")) { + const t = line.trim(); if (!t) continue; + let rec:any; try { rec = JSON.parse(t); } catch { continue; } + + if (rec?.type === "session_meta") { format = "codex"; continue; } + if (rec?.type === "response_item") { + const p = rec.payload; + if (p?.type !== "message" || (p.role !== "user" && p.role !== "assistant")) continue; + format = "codex"; + const blocks: StructuredBlock[] = []; + for (const b of Array.isArray(p.content)?p.content:[]) { + if ((b?.type==="input_text"||b?.type==="output_text") && typeof b.text==="string" && b.text && b.text!=="(no content)") blocks.push({ kind:"text", content:b.text }); + } + if (blocks.length) messages.push({ role:p.role, timestamp:rec.timestamp, blocks }); + continue; + } + + if (rec?.type !== "user" && rec?.type !== "assistant") continue; + const role = rec.type as "user"|"assistant"; + const content = rec?.message?.content; + const blocks: StructuredBlock[] = []; + if (typeof content === "string") { + if (content && content!=="(no content)") blocks.push({ kind:"text", content }); + } else if (Array.isArray(content)) { + for (const b of content) { + if (!b || typeof b!=="object") continue; + switch (b.type) { + case "text": if (typeof b.text==="string" && b.text && b.text!=="(no content)") blocks.push({ kind:"text", content:b.text }); break; + case "thinking": if (typeof b.thinking==="string" && b.thinking) blocks.push({ kind:"thinking", content:b.thinking }); break; + case "tool_use": blocks.push({ kind:"tool_use", name: typeof b.name==="string"?b.name:undefined, id: typeof b.id==="string"?b.id:undefined, input: b.input!=null && typeof b.input==="object"? b.input as Record:undefined, content: b.input!=null? JSON.stringify(b.input,null,2):"" }); break; + case "tool_result": blocks.push({ kind:"tool_result", toolUseId: typeof b.tool_use_id==="string"?b.tool_use_id:undefined, content: resultToString(b.content) }); break; + } + } + } else continue; + if (format==="unknown") format = "claude"; + if (blocks.length) messages.push({ role, uuid: rec.uuid, parentUuid: rec.parentUuid ?? null, timestamp: rec.timestamp, blocks }); + } + return { messages, format }; +} +``` + +- [ ] **Step 4: Add the endpoint + its test** + +Append to `src/web/api/api.test.ts` a test that seeds a session row with a `source_path` pointing to a fixture JSONL, requests `/sessions/:id/transcript`, and asserts the messages shape (and 404 for a missing session). Implement in `src/web/api/index.ts`: +```ts +import { existsSync, readFileSync } from "fs"; +import { parseStructuredTranscript } from "../../transcript-structured"; +// ... +api.get("/sessions/:id/transcript", (c) => { + const id = c.req.param("id"); + const row = db.query("SELECT source_path FROM sessions WHERE id = ?").get(id) as { source_path: string } | null; + if (!row) return c.json({ error: "session not found" }, 404); + if (!row.source_path || !existsSync(row.source_path)) return c.json({ error: "source unavailable" }, 410); + return c.json(parseStructuredTranscript(readFileSync(row.source_path, "utf-8"))); +}); +``` + +- [ ] **Step 5: Run tests** + +Run: `bun test src/transcript-structured.test.ts src/web/api/api.test.ts && bun run typecheck` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/transcript-structured.ts src/transcript-structured.test.ts src/web/api/index.ts src/web/api/api.test.ts +git commit -m "feat(api): structured transcript parser + /api/sessions/:id/transcript" +``` + +--- + +### Task 3: Subagent discovery + endpoints + +**Files:** +- Create: `src/subagents.ts` (discover + read `.meta.json`) +- Modify: `src/web/api/index.ts` (`GET /api/sessions/:id` incl. subagents; `GET /api/subagent/:sessionId/:agentId`) +- Test: `src/subagents.test.ts`, `src/web/api/api.test.ts` (append) + +**Interfaces:** +- Produces: + - `type Subagent = { agentId:string; agentType?:string; description?:string; toolUseId?:string; spawnDepth?:number }` + - `discoverSubagents(projectDir: string, sessionId: string): Subagent[]` — reads `//subagents/agent-*.meta.json` (+ falls back to files without meta as `{agentId}`). + - `subagentFilePath(projectDir, sessionId, agentId): string` + +- [ ] **Step 1: Write the failing test** + +Create `src/subagents.test.ts` that builds a temp `//subagents/` with two `agent-.jsonl` + `.meta.json` fixtures and asserts `discoverSubagents` returns both with `toolUseId`/`description`/`spawnDepth` parsed, sorted deterministically; a subagent without a meta file still appears (agentId only); missing dir → `[]`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/subagents.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 3: Implement `src/subagents.ts`** + +```ts +import { existsSync, readdirSync, readFileSync } from "fs"; +import { join } from "path"; + +export type Subagent = { agentId:string; agentType?:string; description?:string; toolUseId?:string; spawnDepth?:number }; + +export function subagentDir(projectDir: string, sessionId: string): string { + return join(projectDir, sessionId, "subagents"); +} +export function subagentFilePath(projectDir: string, sessionId: string, agentId: string): string { + return join(subagentDir(projectDir, sessionId), `agent-${agentId}.jsonl`); +} + +export function discoverSubagents(projectDir: string, sessionId: string): Subagent[] { + const dir = subagentDir(projectDir, sessionId); + if (!existsSync(dir)) return []; + const out: Subagent[] = []; + for (const name of readdirSync(dir)) { + const m = /^agent-(.+)\.jsonl$/.exec(name); + if (!m) continue; + const agentId = m[1]!; + let meta: Partial = {}; + const metaPath = join(dir, `agent-${agentId}.meta.json`); + if (existsSync(metaPath)) { + try { + const j = JSON.parse(readFileSync(metaPath, "utf-8")); + meta = { agentType: j.agentType, description: j.description, toolUseId: j.toolUseId, spawnDepth: j.spawnDepth }; + } catch { /* ignore malformed meta */ } + } + out.push({ agentId, ...meta }); + } + out.sort((a, b) => a.agentId.localeCompare(b.agentId)); + return out; +} +``` + +- [ ] **Step 4: Add endpoints + tests** + +The session's project dir = `dirname(source_path)` where the main file is `/.jsonl`, so `projectDir = dirname(source_path)`. Add to `src/web/api/index.ts`: +```ts +import { dirname } from "path"; +import { discoverSubagents, subagentFilePath } from "../../subagents"; + +api.get("/sessions/:id", (c) => { + const id = c.req.param("id"); + const row = db.query("SELECT id, project_id, project_path, source_path, started_at, ended_at, message_count FROM sessions WHERE id = ?").get(id) as any; + if (!row) return c.json({ error: "session not found" }, 404); + const subagents = row.source_path ? discoverSubagents(dirname(row.source_path), id) : []; + return c.json({ ...row, subagents }); +}); + +api.get("/subagent/:sessionId/:agentId", (c) => { + const { sessionId, agentId } = c.req.param(); + const row = db.query("SELECT source_path FROM sessions WHERE id = ?").get(sessionId) as { source_path: string } | null; + if (!row) return c.json({ error: "session not found" }, 404); + const path = subagentFilePath(dirname(row.source_path), sessionId, agentId); + if (!existsSync(path)) return c.json({ error: "subagent not found" }, 404); + return c.json(parseStructuredTranscript(readFileSync(path, "utf-8"))); +}); +``` +Append api.test.ts cases: `/sessions/:id` includes discovered subagents (fixture with a subagents dir); `/subagent/:sessionId/:agentId` returns the subagent transcript; 404s. + +- [ ] **Step 5: Run tests** + +Run: `bun test src/subagents.test.ts src/web/api/api.test.ts && bun run typecheck` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/subagents.ts src/subagents.test.ts src/web/api/index.ts src/web/api/api.test.ts +git commit -m "feat(api): subagent discovery via .meta.json + endpoints" +``` + +--- + +### Task 4: Session list endpoint + +**Files:** +- Modify: `src/web/api/index.ts` (`GET /api/sessions`) +- Test: `src/web/api/api.test.ts` (append) + +**Interfaces:** +- `GET /api/sessions?limit&offset&project&q` → `{ sessions: {...}[]; total: number }` ordered by `started_at` desc. + +- [ ] **Step 1: Write the failing test** — seed 3 sessions across 2 projects; assert list returns them newest-first with `total`, respects `limit`/`offset` and `project` filter, and each row has `id, project_id, display_name, started_at, message_count`. + +- [ ] **Step 2: Run to verify it fails** — `bun test src/web/api/api.test.ts` (route 404). + +- [ ] **Step 3: Implement:** +```ts +api.get("/sessions", (c) => { + const limit = Math.min(parseInt(c.req.query("limit") || "50", 10) || 50, 200); + const offset = parseInt(c.req.query("offset") || "0", 10) || 0; + const project = c.req.query("project"); + const where = project ? "WHERE s.project_id = ?" : ""; + const args = project ? [project] : []; + const total = (db.query(`SELECT COUNT(*) c FROM sessions s ${where}`).get(...args) as { c:number }).c; + const sessions = db.query( + `SELECT s.id, s.project_id, p.display_name, s.started_at, s.ended_at, s.message_count, s.is_subagent + FROM sessions s JOIN projects p ON p.id = s.project_id ${where} + ORDER BY s.started_at DESC LIMIT ? OFFSET ?` + ).all(...args, limit, offset); + return c.json({ sessions, total }); +}); +``` + +- [ ] **Step 4: Run tests** — `bun test src/web/api/api.test.ts && bun run check` → PASS. + +- [ ] **Step 5: Commit** +```bash +git add src/web/api/index.ts src/web/api/api.test.ts +git commit -m "feat(api): session list endpoint" +``` + +--- + +### Task 5: React data layer + session list view + +**Files:** +- Create: `web/src/api.ts` (typed fetch helpers), `web/src/routes.tsx` (client router), `web/src/pages/SessionList.tsx` +- Modify: `web/src/App.tsx`, `web/src/main.tsx` +- Test: `web/src/pages/SessionList.test.tsx` + +**Interfaces:** +- `web/src/api.ts` exports typed `getSessions`, `getSession`, `getTranscript`, `getSubagent` matching the API shapes from Tasks 2-4. + +- [ ] **Step 1: Write the failing component test** + +`web/src/pages/SessionList.test.tsx` — mock `fetch` (or the `api.ts` module) to return two sessions; render ``; assert both titles/links appear and link to `/s/:id`. + +- [ ] **Step 2: Run to verify it fails** — `cd web && bun x vitest run src/pages/SessionList.test.tsx` (component missing). + +- [ ] **Step 3: Implement** `web/src/api.ts` (typed wrappers over `/api/...` with `StructuredMessage`/`Subagent` types mirrored from the backend), a small client router (React Router or a minimal hash/history router) with routes `/` → `SessionList`, `/s/:id` → `SessionDetail` (stub for now), and `SessionList.tsx` (fetch `getSessions`, render rows with Tailwind, link to `/s/:id`). Wire `main.tsx`/`App.tsx` to the router. + +- [ ] **Step 4: Run tests** — `cd web && bun x vitest run` → PASS. + +- [ ] **Step 5: Commit** +```bash +git add web/src +git commit -m "feat(react): typed API layer + session list view" +``` + +--- + +### Task 6: Session detail + viewer components + toggles + +**Files:** +- Create: `web/src/pages/SessionDetail.tsx`, `web/src/components/{MessageBlock,ThinkingBlock,ToolCallBlock,SubagentPanel,Transcript}.tsx`, `web/src/toolPreview.ts` +- Test: `web/src/pages/SessionDetail.test.tsx`, `web/src/components/ToolCallBlock.test.tsx` + +**Interfaces:** +- `Transcript({ messages, showThinking, showTools })` renders the message stream; `ToolCallBlock` collapsible with preview + paired result; `SubagentPanel` lazy-loads `/api/subagent/...`; `toolPreview(name, input)` per-tool one-liner (same table as before). + +- [ ] **Step 1: Write the failing tests** + +`ToolCallBlock.test.tsx`: given a tool_use block + its result, renders a collapsed `
` with tool name + preview; expanding shows input + result; hidden when `showTools` false. +`SessionDetail.test.tsx`: mock `getSession` (with one subagent) + `getTranscript` (text+thinking+tool_use+tool_result); assert default hides thinking/tools; toggling `Show thinking`/`Show tools` reveals them; a subagent panel appears and lazy-loads on expand (mock `getSubagent`); empty-state note when a shown kind is absent. + +- [ ] **Step 2: Run to verify they fail** — `cd web && bun x vitest run` (components missing). + +- [ ] **Step 3: Implement** the components: + - `toolPreview.ts` — Read/Write/Edit→file_path, Bash→command≤80, Glob/Grep→pattern, Task→description, WebFetch→url, else "". + - `ThinkingBlock` — bubble, `~round(len/4)` token estimate when >100 chars; rendered only when `showThinking`. + - `ToolCallBlock` — `
` with `` = tool name + preview; body = `
` input + paired result; rendered only when `showTools`. Receives the result via a `resultsByToolUseId` map built in `Transcript`.
+  - `SubagentPanel` — collapsible; on first expand, `getSubagent(sessionId, agentId)` → renders nested `` (recursive), inheriting the toggle state.
+  - `MessageBlock` — role + timestamp; text via `react-markdown` + `remark-gfm`.
+  - `Transcript` — builds `resultsByToolUseId`, renders messages/blocks in order, respects toggles, renders subagent panels at their mapped `toolUseId` (via the session's subagents list), and shows dimension-specific empty-state notes.
+  - `SessionDetail` — fetches session + transcript, holds `showThinking`/`showTools` state (default false) + the two toggle buttons, renders ``.
+
+- [ ] **Step 4: Run tests** — `cd web && bun x vitest run` → PASS.
+
+- [ ] **Step 5: Commit**
+```bash
+git add web/src
+git commit -m "feat(react): session viewer — collapsible tools, thinking, subagents, toggles"
+```
+
+---
+
+### Task 7: Full verification + live smoke
+
+- [ ] **Step 1: Backend suite** — `bun run check` → all pass, typecheck clean.
+- [ ] **Step 2: Frontend suite** — `cd web && bun x vitest run` → all pass.
+- [ ] **Step 3: Prod build** — `cd web && bun run build` succeeds; `web/dist` produced.
+- [ ] **Step 4: Live smoke** — start `engineering-notebook serve --react --port 3944`; open `/`, click a session with tools+thinking+subagents (e.g. the current dev session `f7a485a8-…`); verify: list loads; detail renders; **Show tools** reveals collapsible tools with previews + paired results; **Show thinking** reveals thinking (use a session known to have thinking text); a **subagent panel** lazy-loads and renders on expand. Record output. Kill the server.
+- [ ] **Step 5: Commit any touch-ups (only if needed)**
+```bash
+git add -A && git commit -m "chore(react): finalize Phase 1 foundation"
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:** monorepo `web/` + Hono `/api` + dev proxy/prod static (Task 1); structured transcript endpoint (Task 2); subagent discovery via `.meta.json` exact mapping + endpoints (Task 3); session list (Task 4); React data layer + list (Task 5); session viewer with collapsible tools, thinking, subagent nesting, client-side hide/show toggles + empty-state (Task 6); verification incl. prod build + live smoke (Task 7). Additive-only; legacy views/DB untouched. ✔
+
+**Placeholder scan:** Backend tasks carry complete code. Frontend tasks (5-6) specify components, their contracts, and the exact tests to write; the React/Tailwind bodies are described precisely (props, behavior, per-tool preview, toggle defaults) rather than pasted in full — appropriate for generated-scaffold UI, with tests pinning behavior.
+
+**Type consistency:** `StructuredMessage`/`StructuredBlock` (Task 2) are the transcript API contract consumed by `web/src/api.ts` (Task 5) and the viewer (Task 6). `Subagent` (Task 3) flows into `/api/sessions/:id` (Task 3) → `api.ts` → `SubagentPanel` (Task 6). `toolPreview`'s table matches the earlier server-side version. Route paths (`/api/sessions`, `/api/sessions/:id`, `/api/sessions/:id/transcript`, `/api/subagent/:sessionId/:agentId`) are consistent across Tasks 2-6.
diff --git a/docs/superpowers/plans/2026-07-19-session-groups.md b/docs/superpowers/plans/2026-07-19-session-groups.md
new file mode 100644
index 0000000..dee66b0
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-19-session-groups.md
@@ -0,0 +1,995 @@
+# Session Groups Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a **Groups** tab to the engineering-notebook web UI where the user creates named headers and files individual coding sessions under them (one group per session).
+
+**Architecture:** Two new SQLite tables (`groups`, `session_groups`) with a dedicated helper module `src/groups.ts`. New Hono routes in `src/web/server.ts` render a Groups index/detail view (`src/web/views/groups.ts`) and handle create/rename/delete/assign via form POSTs. An "Add to group" ``;
+  html += ``;
+  html += ``;
+
+  if (groups.length === 0) {
+    html += `
No groups yet. Create one above.
`; + } else { + for (const g of groups) { + const activity = g.lastActivityAt ? g.lastActivityAt.slice(0, 10) : "—"; + html += `
`; + html += `${escapeHtml(g.name)}`; + html += `${g.sessionCount} session(s) · ${activity}`; + html += `
`; + } + } + + html += ``; + return html; +} + +export function renderGroupDetail(db: Database, id: number): string | null { + const data = getGroupWithSessions(db, id); + if (!data) return null; + const { group, sessions } = data; + + let html = `
`; + html += ``; + + html += `
`; + html += ``; + html += ``; + html += `
`; + + html += `
`; + html += ``; + html += `
`; + + if (sessions.length === 0) { + html += `
No sessions in this group yet.
`; + } else { + for (const s of sessions) { + html += `
`; + html += `${escapeHtml(s.display_name)}`; + html += `
${s.started_at.slice(0, 10)} · ${s.message_count} messages · ${escapeHtml(s.project_id)}
`; + html += `
`; + } + } + + html += `
`; + return html; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/web/views/groups.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/web/views/groups.ts src/web/views/groups.test.ts +git commit -m "feat(groups): add groups index and detail views" +``` + +--- + +### Task 5: Routes — Groups pages + create/rename/delete/assign + +**Files:** +- Modify: `src/web/server.ts` (add imports near lines 3-9; add routes before `return app;` at line 277) +- Test: `src/web/server.test.ts` + +**Interfaces:** +- Consumes: `renderGroupsIndex`, `renderGroupDetail` from `./views/groups`; `createGroup`, `renameGroup`, `deleteGroup`, `assignSession` from `../groups`; existing `renderLayout`. +- Produces routes: `GET /groups`, `GET /groups/:id`, `POST /groups`, `POST /groups/:id/rename`, `POST /groups/:id/delete`, `POST /sessions/:id/group`. + +- [ ] **Step 1: Write the failing test** + +Add to `src/web/server.test.ts` inside `describe("server", ...)`: + +```ts +describe("Groups routes", () => { + function seedSession(id: string) { + db.query("INSERT OR IGNORE INTO projects (id, path, display_name) VALUES ('p','/tmp/p','Proj')").run(); + db.query( + `INSERT INTO sessions (id, project_id, project_path, source_path, started_at, message_count, ingested_at) + VALUES (?, 'p', '/tmp/p', '/tmp/s.jsonl', '2026-07-10T00:00:00Z', 3, datetime('now'))` + ).run(id); + } + + test("GET /groups renders with Groups nav active", async () => { + const app = createApp(db, syncManager); + const res = await app.request("/groups"); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toMatch(/href="\/groups"\s+class="active"/); + expect(html).toContain("New group name"); + }); + + test("POST /groups creates a group then redirects", async () => { + const app = createApp(db, syncManager); + const form = new FormData(); + form.append("name", "Trading"); + const res = await app.request("/groups", { method: "POST", body: form }); + expect(res.status).toBe(302); + const rows = db.query("SELECT name FROM groups").all() as { name: string }[]; + expect(rows.map((r) => r.name)).toContain("Trading"); + }); + + test("POST /groups with duplicate name shows error, not 500", async () => { + const app = createApp(db, syncManager); + db.query("INSERT INTO groups (name, created_at) VALUES ('Trading', datetime('now'))").run(); + const form = new FormData(); + form.append("name", "Trading"); + const res = await app.request("/groups", { method: "POST", body: form }); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("Group name already exists"); + }); + + test("GET /groups/:id lists sessions; 404 for missing", async () => { + const app = createApp(db, syncManager); + seedSession("s1"); + const gid = (db.query("INSERT INTO groups (name, created_at) VALUES ('A', datetime('now')) RETURNING id").get() as { id: number }).id; + db.query("INSERT INTO session_groups (session_id, group_id, assigned_at) VALUES ('s1', ?, datetime('now'))").run(gid); + const ok = await app.request("/groups/" + gid); + expect(ok.status).toBe(200); + expect(await ok.text()).toContain('href="/session/s1"'); + const missing = await app.request("/groups/99999"); + expect(missing.status).toBe(404); + }); + + test("POST /groups/:id/rename and /delete", async () => { + const app = createApp(db, syncManager); + const gid = (db.query("INSERT INTO groups (name, created_at) VALUES ('A', datetime('now')) RETURNING id").get() as { id: number }).id; + const rf = new FormData(); rf.append("name", "B"); + const r1 = await app.request("/groups/" + gid + "/rename", { method: "POST", body: rf }); + expect(r1.status).toBe(302); + expect((db.query("SELECT name FROM groups WHERE id = ?").get(gid) as { name: string }).name).toBe("B"); + const r2 = await app.request("/groups/" + gid + "/delete", { method: "POST", body: new FormData() }); + expect(r2.status).toBe(302); + expect(db.query("SELECT id FROM groups WHERE id = ?").get(gid)).toBeNull(); + }); + + test("POST /sessions/:id/group files and unassigns a session", async () => { + const app = createApp(db, syncManager); + seedSession("s1"); + const gid = (db.query("INSERT INTO groups (name, created_at) VALUES ('A', datetime('now')) RETURNING id").get() as { id: number }).id; + const assign = new FormData(); assign.append("group_id", String(gid)); + const r1 = await app.request("/sessions/s1/group", { method: "POST", body: assign }); + expect(r1.status).toBe(302); + expect((db.query("SELECT group_id FROM session_groups WHERE session_id='s1'").get() as { group_id: number }).group_id).toBe(gid); + const unassign = new FormData(); unassign.append("group_id", ""); + const r2 = await app.request("/sessions/s1/group", { method: "POST", body: unassign }); + expect(r2.status).toBe(302); + expect(db.query("SELECT group_id FROM session_groups WHERE session_id='s1'").get()).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/web/server.test.ts` +Expected: FAIL — routes return 404 (not defined). + +- [ ] **Step 3: Implement the routes** + +3a. Add imports near the other view imports (after line 8 in `src/web/server.ts`): + +```ts +import { renderGroupsIndex, renderGroupDetail } from "./views/groups"; +import { createGroup, renameGroup, deleteGroup, assignSession } from "../groups"; +``` + +3b. Insert these routes immediately before `return app;` (line 277): + +```ts + // ────────────────────────────────────────── + // Groups + // ────────────────────────────────────────── + + app.get("/groups", (c) => { + const error = c.req.query("error") || undefined; + return c.html(renderLayout("Groups — Engineering Notebook", { + body: renderGroupsIndex(db, error), + activeTab: "groups", + })); + }); + + app.get("/groups/:id", (c) => { + const id = parseInt(c.req.param("id"), 10); + const body = isNaN(id) ? null : renderGroupDetail(db, id); + if (!body) return c.text("Group not found", 404); + return c.html(renderLayout("Group — Engineering Notebook", { + body, + activeTab: "groups", + })); + }); + + app.post("/groups", async (c) => { + const form = await c.req.parseBody(); + try { + createGroup(db, (form.name as string) || ""); + } catch (err) { + return c.html(renderLayout("Groups — Engineering Notebook", { + body: renderGroupsIndex(db, String(err instanceof Error ? err.message : err)), + activeTab: "groups", + })); + } + return c.redirect("/groups"); + }); + + app.post("/groups/:id/rename", async (c) => { + const id = parseInt(c.req.param("id"), 10); + const form = await c.req.parseBody(); + try { + renameGroup(db, id, (form.name as string) || ""); + } catch (err) { + return c.html(renderLayout("Groups — Engineering Notebook", { + body: renderGroupsIndex(db, String(err instanceof Error ? err.message : err)), + activeTab: "groups", + })); + } + return c.redirect(`/groups/${id}`); + }); + + app.post("/groups/:id/delete", (c) => { + const id = parseInt(c.req.param("id"), 10); + if (!isNaN(id)) deleteGroup(db, id); + return c.redirect("/groups"); + }); + + app.post("/sessions/:id/group", async (c) => { + const sessionId = c.req.param("id"); + const form = await c.req.parseBody(); + const raw = (form.group_id as string) || ""; + const groupId = raw === "" ? null : parseInt(raw, 10); + assignSession(db, sessionId, groupId === null || isNaN(groupId) ? null : groupId); + return c.redirect(`/session/${encodeURIComponent(sessionId)}`); + }); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/web/server.test.ts && bun run typecheck` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/web/server.ts src/web/server.test.ts +git commit -m "feat(groups): add group + session-assign routes" +``` + +--- + +### Task 6: "Add to group" control on the session view + +**Files:** +- Modify: `src/web/views/session.ts` (add a select-form after the metadata header block, near line 52) +- Test: `src/web/views/session.test.ts` + +**Interfaces:** +- Consumes: `listGroups`, `getSessionGroupId` from `../../groups`. +- Produces: `renderSessionDetail` output now includes an `
` with a ``; + html += ``; + for (const g of groups) { + html += ``; + } + html += ``; + html += ``; + html += `
`; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/web/views/session.test.ts && bun run typecheck` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/web/views/session.ts src/web/views/session.test.ts +git commit -m "feat(groups): add 'Add to group' control to session view" +``` + +--- + +### Task 7: Full suite green + manual smoke check + +**Files:** +- No new production files. Verification only. + +**Interfaces:** +- Consumes: everything from Tasks 1-6. + +- [ ] **Step 1: Run the whole suite + typecheck** + +Run: `bun run check` +Expected: all tests pass; `All checks passed.` from typecheck. + +- [ ] **Step 2: Smoke-test the running server** + +Run (server on a free port, since 3000 may be occupied): + +```bash +engineering-notebook serve --port 3737 & +sleep 1 +curl -s -o /dev/null -w "groups=%{http_code}\n" http://localhost:3737/groups +curl -s -X POST -F "name=Smoke Test" -o /dev/null -w "create=%{http_code}\n" http://localhost:3737/groups +curl -s http://localhost:3737/groups | grep -o "Smoke Test" | head -1 +kill %1 +``` + +Expected: `groups=200`, `create=302`, and `Smoke Test` printed (group created and listed). + +- [ ] **Step 3: Commit any final touch-ups (if needed)** + +Only if Step 1/2 surfaced a fix. Otherwise skip. + +```bash +git add -A +git commit -m "chore(groups): finalize session groups feature" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Groups nav tab (Groups last) → Task 3. ✔ +- Named header containing individual sessions → Tasks 1, 2, 4. ✔ +- One group per session (PK) → Task 1 schema + Task 2 reassign test. ✔ +- In-app create/rename/delete → Tasks 4, 5. ✔ +- Assign via control on session view → Task 6. ✔ +- Membership survives re-ingest (no FK on session_id) → Task 1 schema + Task 2 re-ingest test. ✔ +- Duplicate/empty name rejected without 500 → Task 2 (helper throws) + Task 5 (route renders error, status 200). ✔ +- Ordering (groups by last activity, sessions by recency) → Task 2 queries. ✔ +- Orphan membership filtered → Task 2 orphan test (JOIN sessions). ✔ +- Out of scope (web chats, colors, bulk, reorder, ungrouped page) → not built. ✔ + +**Placeholder scan:** No TBD/TODO; every code step shows full code; every test step shows the assertions. + +**Type consistency:** Helper signatures in Task 2's Interfaces (`createGroup`, `listGroups`, `renameGroup`, `deleteGroup`, `assignSession`, `getSessionGroupId`, `getGroupWithSessions`) match their use in Tasks 4, 5, 6. View function names (`renderGroupsIndex`, `renderGroupDetail`) match between Tasks 4 and 5. `activeTab: "groups"` added in Task 3 matches its use in Task 5 routes. Route paths (`/groups`, `/groups/:id`, `/groups/:id/rename`, `/groups/:id/delete`, `/sessions/:id/group`) match between Task 5 routes, Task 4 detail form actions, and Task 6 assign form. diff --git a/docs/superpowers/plans/2026-07-19-show-thinking-tools.md b/docs/superpowers/plans/2026-07-19-show-thinking-tools.md new file mode 100644 index 0000000..bc02353 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-show-thinking-tools.md @@ -0,0 +1,532 @@ +# Show / Hide Thinking & Tools Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a reader show/hide Thinking and Tool content on the `/session/:id` display, by re-parsing the original JSONL on demand; default stays the clean text-only view. + +**Architecture:** A new pure `src/transcript.ts` parses a session's raw JSONL into ordered `TranscriptItem`s (text/thinking/tool_use/tool_result). `renderSessionDetail` gains `{ showThinking, showTools }`: when either is set it reads `source_path`, parses it, and renders the enriched transcript (warning + text-only fallback if the file is unavailable); otherwise it renders the stored markdown exactly as today. `GET /session/:id` reads `thinking`/`tools` query params. Toggle controls are query-param links. + +**Tech Stack:** Bun, bun:sqlite, Hono, TypeScript, server-rendered HTML strings, `bun test`. + +## Global Constraints + +- Runtime **Bun**; verify with `bun run check` (`bun test` + `bun --bun tsc --noEmit`). Pre-commit hook runs both. No new dependencies. +- **Default behavior is unchanged:** with no params, `renderSessionDetail` renders the stored `conversation_markdown` and reads no file. +- Re-parse happens **only** when `showThinking || showTools`. Source of the JSONL is the session's `source_path`. +- **Fallback:** if a show flag is set but `source_path` is missing/unreadable → render a warning banner ("Original session file unavailable — can't show thinking/tools") then the text-only view; controls still render. +- **Claude Code JSONL block shapes** (verified against the real store): records `{ type: "user"|"assistant", message: { content } }`; `content` is a string or an array of blocks — `{type:"text",text}`, `{type:"thinking",thinking}`, `{type:"tool_use",name,input}` (input is an object), `{type:"tool_result",tool_use_id,content}` (content is a string or an array of `{type:"text",text}`). +- **Codex JSONL:** records `{type:"session_meta"}` (skip) and `{type:"response_item", payload:{ type:"message", role, content:[{type:"input_text"|"output_text",text}] }}` — text only; no thinking/tool data. When a show flag is set and the parsed items contain no thinking/tool items, render a muted "No thinking/tool data for this session." note. +- All content rendered through `escapeHtml`. Scope is the dedicated `/session/:id` view only (do not modify the inline three-panel session panel behavior). +- Tests assert real behavior; suite + typecheck green before each commit. Branch: `feature/session-groups`. + +--- + +### Task 1: Transcript parser — `src/transcript.ts` + +**Files:** +- Create: `src/transcript.ts` +- Test: `src/transcript.test.ts` + +**Interfaces:** +- Produces: + - `type TranscriptRole = "user" | "assistant"` + - `type TranscriptKind = "text" | "thinking" | "tool_use" | "tool_result"` + - `type TranscriptItem = { role: TranscriptRole; kind: TranscriptKind; content: string; name?: string }` + - `type TranscriptFormat = "claude" | "codex" | "unknown"` + - `parseTranscript(jsonlText: string): { items: TranscriptItem[]; format: TranscriptFormat }` + +- [ ] **Step 1: Write the failing test** + +Create `src/transcript.test.ts`: + +```ts +import { describe, test, expect } from "bun:test"; +import { parseTranscript } from "./transcript"; + +const claudeLines = [ + JSON.stringify({ type: "user", message: { content: [{ type: "text", text: "hi" }] } }), + JSON.stringify({ type: "assistant", message: { content: [ + { type: "thinking", thinking: "let me think" }, + { type: "text", text: "answer" }, + { type: "tool_use", name: "Bash", input: { command: "ls" } }, + ] } }), + JSON.stringify({ type: "user", message: { content: [ + { type: "tool_result", tool_use_id: "t1", content: "file-a\nfile-b" }, + ] } }), + "", + "{ not json", +].join("\n"); + +describe("parseTranscript", () => { + test("parses claude blocks in order with kinds/roles/names", () => { + const { items, format } = parseTranscript(claudeLines); + expect(format).toBe("claude"); + expect(items).toEqual([ + { role: "user", kind: "text", content: "hi" }, + { role: "assistant", kind: "thinking", content: "let me think" }, + { role: "assistant", kind: "text", content: "answer" }, + { role: "assistant", kind: "tool_use", name: "Bash", content: JSON.stringify({ command: "ls" }, null, 2) }, + { role: "user", kind: "tool_result", content: "file-a\nfile-b" }, + ]); + }); + + test("tool_result with array content joins text blocks", () => { + const line = JSON.stringify({ type: "user", message: { content: [ + { type: "tool_result", content: [{ type: "text", text: "line1" }, { type: "text", text: "line2" }] }, + ] } }); + const { items } = parseTranscript(line); + expect(items[0]).toEqual({ role: "user", kind: "tool_result", content: "line1\nline2" }); + }); + + test("string message content becomes a single text item", () => { + const line = JSON.stringify({ type: "assistant", message: { content: "plain" } }); + expect(parseTranscript(line).items).toEqual([{ role: "assistant", kind: "text", content: "plain" }]); + }); + + test("codex records parse to text-only items", () => { + const lines = [ + JSON.stringify({ type: "session_meta", payload: { id: "x" } }), + JSON.stringify({ type: "response_item", payload: { type: "message", role: "user", content: [{ type: "input_text", text: "u" }] } }), + JSON.stringify({ type: "response_item", payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: "a" }] } }), + ].join("\n"); + const { items, format } = parseTranscript(lines); + expect(format).toBe("codex"); + expect(items).toEqual([ + { role: "user", kind: "text", content: "u" }, + { role: "assistant", kind: "text", content: "a" }, + ]); + }); + + test("empty input → no items, unknown format", () => { + expect(parseTranscript("")).toEqual({ items: [], format: "unknown" }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/transcript.test.ts` +Expected: FAIL — `Cannot find module './transcript'`. + +- [ ] **Step 3: Implement `src/transcript.ts`** + +```ts +export type TranscriptRole = "user" | "assistant"; +export type TranscriptKind = "text" | "thinking" | "tool_use" | "tool_result"; +export type TranscriptItem = { role: TranscriptRole; kind: TranscriptKind; content: string; name?: string }; +export type TranscriptFormat = "claude" | "codex" | "unknown"; + +function toolResultToString(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((b) => (b && typeof b === "object" && typeof (b as any).text === "string" ? (b as any).text : JSON.stringify(b))) + .join("\n"); + } + if (content == null) return ""; + return JSON.stringify(content, null, 2); +} + +export function parseTranscript(jsonlText: string): { items: TranscriptItem[]; format: TranscriptFormat } { + const items: TranscriptItem[] = []; + let format: TranscriptFormat = "unknown"; + + for (const line of jsonlText.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + let rec: any; + try { rec = JSON.parse(trimmed); } catch { continue; } + + // Codex format + if (rec?.type === "session_meta") { format = "codex"; continue; } + if (rec?.type === "response_item") { + const p = rec.payload; + if (p?.type !== "message" || (p.role !== "user" && p.role !== "assistant")) continue; + format = "codex"; + const role = p.role as TranscriptRole; + for (const b of Array.isArray(p.content) ? p.content : []) { + if ((b?.type === "input_text" || b?.type === "output_text") && typeof b.text === "string" && b.text && b.text !== "(no content)") { + items.push({ role, kind: "text", content: b.text }); + } + } + continue; + } + + // Claude Code format + if (rec?.type !== "user" && rec?.type !== "assistant") continue; + const role = rec.type as TranscriptRole; + const content = rec?.message?.content; + if (typeof content === "string") { + if (content && content !== "(no content)") { items.push({ role, kind: "text", content }); if (format === "unknown") format = "claude"; } + continue; + } + if (!Array.isArray(content)) continue; + if (format === "unknown") format = "claude"; + + for (const b of content) { + if (!b || typeof b !== "object") continue; + switch (b.type) { + case "text": + if (typeof b.text === "string" && b.text && b.text !== "(no content)") items.push({ role, kind: "text", content: b.text }); + break; + case "thinking": + if (typeof b.thinking === "string" && b.thinking) items.push({ role, kind: "thinking", content: b.thinking }); + break; + case "tool_use": + items.push({ + role, kind: "tool_use", + name: typeof b.name === "string" ? b.name : undefined, + content: b.input != null ? JSON.stringify(b.input, null, 2) : "", + }); + break; + case "tool_result": + items.push({ role, kind: "tool_result", content: toolResultToString(b.content) }); + break; + } + } + } + + return { items, format }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/transcript.test.ts && bun run typecheck` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/transcript.ts src/transcript.test.ts +git commit -m "feat(session): add transcript parser (text/thinking/tool blocks)" +``` + +--- + +### Task 2: Enriched session render + toggle controls + styles + +**Files:** +- Modify: `src/web/views/session.ts` (`renderSessionDetail` gains opts; add `renderTranscriptItems`, toggle controls, warning + fallback) +- Modify: `src/web/views/layout.ts` (CSS classes `.transcript-thinking`, `.transcript-tool`, `.transcript-warning`, `.transcript-toggle`) +- Test: `src/web/views/session.test.ts` (append) + +**Interfaces:** +- Consumes: `parseTranscript`, `TranscriptItem` from `../../transcript`; `escapeHtml`; `readFileSync`/`existsSync` from `fs`. +- Produces: `renderSessionDetail(db, sessionId, opts?: { showThinking?: boolean; showTools?: boolean }): string` (opts optional → current behavior). + +- [ ] **Step 1: Write the failing test** + +Append to `src/web/views/session.test.ts` (it already has DB-backed tests + imports from Task-6 of the prior feature; add these imports if missing: `writeFileSync`, `mkdtempSync`, `rmSync`, `tmpdir`, `join`). Add a new describe: + +```ts +describe("session detail thinking/tools toggle", () => { + let tempDir: string; + let db: ReturnType; + + const jsonl = [ + JSON.stringify({ type: "user", message: { content: [{ type: "text", text: "the question" }] } }), + JSON.stringify({ type: "assistant", message: { content: [ + { type: "thinking", thinking: "SECRET_REASONING" }, + { type: "text", text: "the answer" }, + { type: "tool_use", name: "Bash", input: { command: "ls" } }, + ] } }), + ].join("\n"); + + function seedWithFile(id: string, sourcePath: string) { + db.query("INSERT OR IGNORE INTO projects (id, path, display_name) VALUES ('p','/tmp/p','P')").run(); + db.query( + `INSERT INTO sessions (id, project_id, project_path, source_path, started_at, message_count, ingested_at) + VALUES (?, 'p', '/tmp/p', ?, '2026-07-10T00:00:00Z', 2, datetime('now'))` + ).run(id, sourcePath); + db.query("INSERT INTO conversations (session_id, conversation_markdown, extracted_at) VALUES (?, '# md the answer', datetime('now'))").run(id); + } + + beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "tt-test-")); db = initDb(join(tempDir, "t.db")); }); + afterEach(() => { closeDb(); rmSync(tempDir, { recursive: true, force: true }); }); + + test("default render shows toggle controls and no thinking/tool content", () => { + const src = join(tempDir, "s.jsonl"); writeFileSync(src, jsonl); + seedWithFile("s1", src); + const html = renderSessionDetail(db, "s1"); + expect(html).toContain("Show thinking"); + expect(html).toContain("Show tools"); + expect(html).not.toContain("SECRET_REASONING"); + }); + + test("showThinking re-parses the file and renders thinking; control flips to Hide", () => { + const src = join(tempDir, "s.jsonl"); writeFileSync(src, jsonl); + seedWithFile("s1", src); + const html = renderSessionDetail(db, "s1", { showThinking: true }); + expect(html).toContain("SECRET_REASONING"); + expect(html).toContain("Hide thinking"); + expect(html).not.toContain('"input"'); // tools not shown + expect(html).not.toContain("ls"); // tool input not shown + }); + + test("showTools renders tool call, not thinking", () => { + const src = join(tempDir, "s.jsonl"); writeFileSync(src, jsonl); + seedWithFile("s1", src); + const html = renderSessionDetail(db, "s1", { showTools: true }); + expect(html).toContain("Bash"); + expect(html).toContain("ls"); + expect(html).not.toContain("SECRET_REASONING"); + }); + + test("missing source file → warning banner + text-only fallback", () => { + seedWithFile("s1", join(tempDir, "gone.jsonl")); // never written + const html = renderSessionDetail(db, "s1", { showThinking: true }); + expect(html).toContain("Original session file unavailable"); + expect(html).not.toContain("SECRET_REASONING"); + expect(html).toContain("Hide thinking"); // control still shown + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/web/views/session.test.ts` +Expected: FAIL — opts ignored / no toggle controls. + +- [ ] **Step 3: Implement** + +3a. `src/web/views/session.ts` — add imports at the top: + +```ts +import { existsSync, readFileSync } from "fs"; +import { parseTranscript, type TranscriptItem } from "../../transcript"; +``` + +3b. Add a helper that builds the toggle-control HTML and the transcript body. Insert before `renderSessionDetail`: + +```ts +function toggleControls(sessionId: string, showThinking: boolean, showTools: boolean): string { + const url = (t: boolean, o: boolean) => { + const params: string[] = []; + if (t) params.push("thinking=1"); + if (o) params.push("tools=1"); + return `/session/${encodeURIComponent(sessionId)}${params.length ? "?" + params.join("&") : ""}`; + }; + const thinkingLink = url(!showThinking, showTools); + const toolsLink = url(showThinking, !showTools); + return ( + `` + ); +} + +function renderTranscriptItems(items: TranscriptItem[], showThinking: boolean, showTools: boolean): string { + let html = ""; + let hadThinking = false; + let hadTool = false; + for (const item of items) { + if (item.kind === "thinking") { hadThinking = true; if (!showThinking) continue; } + if (item.kind === "tool_use" || item.kind === "tool_result") { hadTool = true; if (!showTools) continue; } + const who = item.role === "user" ? "User" : "Assistant"; + if (item.kind === "text") { + html += `
${who}
${escapeHtml(item.content)}
`; + } else if (item.kind === "thinking") { + html += `
${escapeHtml(item.content)}
`; + } else if (item.kind === "tool_use") { + html += `
🛠 ${escapeHtml(item.name || "tool")}
${escapeHtml(item.content)}
`; + } else { + html += `
↳ result
${escapeHtml(item.content)}
`; + } + } + if ((showThinking && !hadThinking) || (showTools && !hadTool)) { + html += `
No thinking/tool data for this session.
`; + } + return html; +} +``` + +3c. Change `renderSessionDetail`'s signature and body. Replace the current signature line and the conversation-body section: + +```ts +export function renderSessionDetail( + db: Database, + sessionId: string, + opts: { showThinking?: boolean; showTools?: boolean } = {} +): string { +``` + +After the existing metadata-header block (the `` that closes the header, before the `if (session.conversation_markdown)` block), insert the toggle controls and, when a flag is set, the enriched body: + +```ts + const showThinking = opts.showThinking ?? false; + const showTools = opts.showTools ?? false; + html += toggleControls(sessionId, showThinking, showTools); + + if (showThinking || showTools) { + if (session.source_path && existsSync(session.source_path)) { + try { + const { items } = parseTranscript(readFileSync(session.source_path, "utf-8")); + html += renderTranscriptItems(items, showThinking, showTools); + html += renderSessionFooter(sessionId, session.project_path, session.source_path); + return html; + } catch { + html += `
Original session file unavailable — can't show thinking/tools.
`; + } + } else { + html += `
Original session file unavailable — can't show thinking/tools.
`; + } + // fall through to text-only render below + } +``` + +Leave the existing `if (session.conversation_markdown) { ... } else { ... }` text render and the final `renderSessionFooter(...)` call as the default/fallback path. + +3d. `src/web/views/layout.ts` — add CSS near the other view styles (before the closing ``): + +```css + .transcript-toggle { display:flex; gap:12px; margin-bottom:14px; font-size:12px; } + .transcript-thinking { border-left:2px solid var(--border-subtle); padding:6px 10px; margin:8px 0; color:var(--text-muted); font-style:italic; white-space:pre-wrap; } + .transcript-tool { border:1px solid var(--border-subtle); border-radius:4px; padding:8px 10px; margin:8px 0; font-family:monospace; font-size:12px; } + .transcript-tool pre { white-space:pre-wrap; margin:4px 0 0; } + .transcript-warning { color:#b91c1c; font-size:12px; margin:8px 0; } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/web/views/session.test.ts && bun run typecheck` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/web/views/session.ts src/web/views/layout.ts src/web/views/session.test.ts +git commit -m "feat(session): render thinking/tools on demand with toggle controls" +``` + +--- + +### Task 3: Route — `GET /session/:id` reads toggle params + +**Files:** +- Modify: `src/web/server.ts` (`GET /session/:id` handler, near line 75) +- Test: `src/web/server.test.ts` (append) + +**Interfaces:** +- Consumes: `renderSessionDetail(db, sessionId, { showThinking, showTools })`. + +- [ ] **Step 1: Write the failing test** + +Add to `src/web/server.test.ts`: + +```ts +describe("session toggle route", () => { + function seedWithFile(id: string, sourcePath: string) { + db.query("INSERT OR IGNORE INTO projects (id, path, display_name) VALUES ('p','/tmp/p','P')").run(); + db.query( + `INSERT INTO sessions (id, project_id, project_path, source_path, started_at, message_count, ingested_at) + VALUES (?, 'p', '/tmp/p', ?, '2026-07-10T00:00:00Z', 2, datetime('now'))` + ).run(id, sourcePath); + db.query("INSERT INTO conversations (session_id, conversation_markdown, extracted_at) VALUES (?, '# md', datetime('now'))").run(id); + } + + test("?thinking=1 renders thinking; no param does not", async () => { + const { writeFileSync } = await import("fs"); + const src = join(tempDir, "sess.jsonl"); + writeFileSync(src, JSON.stringify({ type: "assistant", message: { content: [ + { type: "thinking", thinking: "SECRET_REASONING" }, { type: "text", text: "a" }, + ] } })); + seedWithFile("s1", src); + const app = createApp(db, syncManager); + + const on = await app.request("/session/s1?thinking=1"); + expect(on.status).toBe(200); + expect(await on.text()).toContain("SECRET_REASONING"); + + const off = await app.request("/session/s1"); + expect(await off.text()).not.toContain("SECRET_REASONING"); + }); +}); +``` + +(`tempDir` is the server test's per-test temp dir from its existing `beforeEach`.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/web/server.test.ts` +Expected: FAIL — params ignored. + +- [ ] **Step 3: Implement in `src/web/server.ts`** + +In the `app.get("/session/:id", ...)` handler, read the params and pass them: + +```ts + app.get("/session/:id", (c) => { + const sessionId = c.req.param("id"); + const showThinking = c.req.query("thinking") === "1"; + const showTools = c.req.query("tools") === "1"; + const panel3 = renderSessionDetail(db, sessionId, { showThinking, showTools }); + // ... rest of the handler unchanged (date lookup, panel1, panel2, layout) ... +``` + +Leave the remainder of the handler (date lookup for the index, `panel1`, `panel2`, `renderLayout` call) exactly as-is. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/web/server.test.ts && bun run check` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/web/server.ts src/web/server.test.ts +git commit -m "feat(session): wire thinking/tools query params into /session/:id" +``` + +--- + +### Task 4: Full suite green + live smoke check + +**Files:** verification only. + +- [ ] **Step 1: Full suite + typecheck** + +Run: `bun run check` +Expected: all pass; `All checks passed.` + +- [ ] **Step 2: Live smoke against a real session** + +```bash +engineering-notebook serve --port 3942 & +sleep 1 +SID=$(bun -e 'import{Database}from"bun:sqlite";import{homedir}from"os";import{join}from"path";const db=new Database(join(homedir(),".config/engineering-notebook/notebook.db"),{readonly:true});const r=db.query("SELECT id FROM sessions WHERE source_path LIKE ? ORDER BY started_at DESC LIMIT 1").get("%/.claude/projects/%");console.log(r.id)') +echo "session: $SID" +curl -s "http://localhost:3942/session/$SID" | grep -o "Show thinking" | head -1 # default: control present +curl -s "http://localhost:3942/session/$SID?thinking=1" | grep -o "Hide thinking" | head -1 # toggled on +curl -s -o /dev/null -w "thinking=%{http_code} tools=%{http_code}\n" "http://localhost:3942/session/$SID?thinking=1&tools=1" +kill %1 +``` +Expected: default page shows "Show thinking"; `?thinking=1` shows "Hide thinking"; both-params request returns 200. Confirm in a browser that thinking/tool blocks actually appear when toggled. Record output. + +- [ ] **Step 3: Commit any touch-ups (only if Steps 1-2 surfaced a fix)** + +```bash +git add -A +git commit -m "chore(session): finalize thinking/tools toggle" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Default text-only, no file read → Task 2 (enriched path gated on flags). ✔ +- Two independent controls, hidden by default, query-param links preserving the other param → Task 2 `toggleControls`. ✔ +- Re-parse `source_path` on show → Task 2. ✔ +- Warning + text-only fallback on missing/unreadable file → Task 2 (both `!existsSync` and try/catch paths). ✔ +- Claude blocks (text/thinking/tool_use/tool_result, incl. array tool_result) → Task 1. ✔ +- Codex → text + "No thinking/tool data" note → Task 1 (`format`/text-only) + Task 2 (note when requested-but-absent). ✔ +- Route reads `thinking`/`tools` params → Task 3. ✔ +- Scope limited to `/session/:id`; inline three-panel unchanged → Tasks 2/3 touch only `renderSessionDetail` + its route. ✔ +- All content `escapeHtml`-ed → Task 2. ✔ +- No ingest/schema/search/summary changes → confirmed by touched files. ✔ + +**Placeholder scan:** No TBD/TODO; every code step has full code; test steps show assertions. + +**Type consistency:** `TranscriptItem`/`parseTranscript` defined in Task 1 are consumed unchanged in Task 2. `renderSessionDetail(db, id, { showThinking, showTools })` signature in Task 2 matches the call in Task 3. `.transcript-thinking`/`.transcript-tool`/`.transcript-warning`/`.transcript-toggle` classes added in Task 2's CSS match the class names emitted by Task 2's render helpers. Query-param names (`thinking`, `tools`) match between Task 2's link builder and Task 3's route reader. diff --git a/docs/superpowers/plans/2026-07-19-transcript-visual-match.md b/docs/superpowers/plans/2026-07-19-transcript-visual-match.md new file mode 100644 index 0000000..c209dc2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-transcript-visual-match.md @@ -0,0 +1,335 @@ +# Transcript Visual Match (React viewer) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the enriched session transcript read like the React viewer — collapsible tool calls with name + preview and paired results, thinking bubbles with a token estimate, teal-accented rounded styling. + +**Architecture:** Enrich `TranscriptItem` with structured tool fields (`id`, `toolUseId`, `input`) in `src/transcript.ts`, then rework `renderTranscriptItems` in `src/web/views/session.ts` to render `
` tool blocks with previews and inline paired results and bubble-styled thinking, backed by new CSS in `src/web/views/layout.ts`. Default view, toggles, route, and fallback are untouched. + +**Tech Stack:** Bun, bun:sqlite, Hono, TypeScript, server-rendered HTML, `bun test`. No new dependencies. + +## Global Constraints + +- Runtime **Bun**; verify with `bun run check`. Pre-commit hook runs `bun test` + typecheck. +- **No new dependencies.** No changes to ingest/storage/search/summary, nor to the default text-only view, the toggle controls (`toggleControls`), the `/session/:id` route, or the warning/fallback logic. Only the enriched-block rendering + its CSS change. +- The notebook is **light-only**; use existing warm-neutral tokens plus one new `--accent: #1a6b5a`. +- All dynamic content (tool name, preview, input, result, thinking) rendered through `escapeHtml`. +- Tool preview table: `Read`/`Write`/`Edit`→`file_path`; `Bash`→`command` (≤80 chars); `Glob`/`Grep`→`pattern`; `Task`→`description`; `WebFetch`→`url`; else `""`. +- Result pairing: a `tool_result` renders inside its matching `tool_use` (`tool_use.id === tool_result.tool_use_id`); orphans render standalone; each result consumed at most once. +- Thinking token estimate: `~round(len/4).toLocaleString() tokens` shown only when content length > 100. +- Tests assert real behavior; suite + typecheck green before each commit. Branch: `feature/session-groups`. + +--- + +### Task 1: Enrich the transcript parser with structured tool fields + +**Files:** +- Modify: `src/transcript.ts` (`TranscriptItem` type; `tool_use` and `tool_result` push branches) +- Test: `src/transcript.test.ts` (update two expectation lines; add one pairing test) + +**Interfaces:** +- Produces: `TranscriptItem` gains optional `id?: string`, `toolUseId?: string`, `input?: Record`. + +- [ ] **Step 1: Update the test (RED)** + +In `src/transcript.test.ts`, update the two expectation lines in the existing "parses claude blocks in order…" test to include the new additive fields (the fixture's `tool_use` has `input`, and its `tool_result` has `tool_use_id: "t1"`): + +Change: +```ts + { role: "assistant", kind: "tool_use", name: "Bash", content: JSON.stringify({ command: "ls" }, null, 2) }, + { role: "user", kind: "tool_result", content: "file-a\nfile-b" }, +``` +to: +```ts + { role: "assistant", kind: "tool_use", name: "Bash", input: { command: "ls" }, content: JSON.stringify({ command: "ls" }, null, 2) }, + { role: "user", kind: "tool_result", toolUseId: "t1", content: "file-a\nfile-b" }, +``` + +Add a new test for id/toolUseId capture: +```ts +test("captures tool_use id, structured input, and tool_result toolUseId", () => { + const lines = [ + JSON.stringify({ type: "assistant", message: { content: [ + { type: "tool_use", id: "tu_1", name: "Read", input: { file_path: "/x.ts" } }, + ] } }), + JSON.stringify({ type: "user", message: { content: [ + { type: "tool_result", tool_use_id: "tu_1", content: "ok" }, + ] } }), + ].join("\n"); + const { items } = parseTranscript(lines); + expect(items[0]).toEqual({ role: "assistant", kind: "tool_use", name: "Read", id: "tu_1", input: { file_path: "/x.ts" }, content: JSON.stringify({ file_path: "/x.ts" }, null, 2) }); + expect(items[1]).toEqual({ role: "user", kind: "tool_result", toolUseId: "tu_1", content: "ok" }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/transcript.test.ts` +Expected: FAIL — items lack `input`/`toolUseId`/`id`. + +- [ ] **Step 3: Implement in `src/transcript.ts`** + +3a. Extend the `TranscriptItem` type: +```ts +export type TranscriptItem = { + role: TranscriptRole; + kind: TranscriptKind; + content: string; + name?: string; + id?: string; + toolUseId?: string; + input?: Record; +}; +``` + +3b. Replace the `tool_use` case body: +```ts + case "tool_use": + items.push({ + role, kind: "tool_use", + name: typeof b.name === "string" ? b.name : undefined, + id: typeof b.id === "string" ? b.id : undefined, + input: b.input != null && typeof b.input === "object" ? (b.input as Record) : undefined, + content: b.input != null ? JSON.stringify(b.input, null, 2) : "", + }); + break; +``` + +3c. Replace the `tool_result` case body: +```ts + case "tool_result": + items.push({ + role, kind: "tool_result", + toolUseId: typeof b.tool_use_id === "string" ? b.tool_use_id : undefined, + content: toolResultToString(b.content), + }); + break; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/transcript.test.ts && bun run typecheck` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/transcript.ts src/transcript.test.ts +git commit -m "feat(session): capture tool id/input and tool_result id in transcript parser" +``` + +--- + +### Task 2: Rework enriched rendering (collapsible tools, previews, pairing, bubbles) + CSS + +**Files:** +- Modify: `src/web/views/session.ts` (add `toolPreview`; rework `renderTranscriptItems`) +- Modify: `src/web/views/layout.ts` (add `--accent`; restyle `.transcript-thinking`/`.transcript-tool`; add `.tool-name`/`.tool-preview`/`.tool-result`/`.tokens`) +- Test: `src/web/views/session.test.ts` (append) + +**Interfaces:** +- Produces: `export function toolPreview(name: string | undefined, input: Record | undefined): string`. +- `renderTranscriptItems` now emits `
` for tool_use with a paired result inside; thinking as a bubble with token estimate. + +- [ ] **Step 1: Write the failing test** + +Append to the existing "session detail thinking/tools toggle" describe in `src/web/views/session.test.ts`: + +```ts +test("tool call renders collapsible with name, preview, and paired result", () => { + const src = join(tempDir, "tool.jsonl"); + writeFileSync(src, [ + JSON.stringify({ type: "assistant", message: { content: [ + { type: "tool_use", id: "tu_9", name: "Bash", input: { command: "ls -la /tmp" } }, + ] } }), + JSON.stringify({ type: "user", message: { content: [ + { type: "tool_result", tool_use_id: "tu_9", content: "RESULT_PAYLOAD" }, + ] } }), + ].join("\n")); + seedWithFile("s1", src); + const html = renderSessionDetail(db, "s1", { showTools: true }); + expect(html).toContain("
Bash"); + expect(html).toContain("ls -la /tmp"); // preview + input + expect(html).toContain("RESULT_PAYLOAD"); // paired result present + expect((html.match(/RESULT_PAYLOAD/g) || []).length).toBe(1); // not duplicated standalone +}); + +test("long thinking shows a token estimate", () => { + const src = join(tempDir, "think.jsonl"); + const long = "x".repeat(400); + writeFileSync(src, JSON.stringify({ type: "assistant", message: { content: [ + { type: "thinking", thinking: long }, + ] } })); + seedWithFile("s1", src); + const html = renderSessionDetail(db, "s1", { showThinking: true }); + expect(html).toContain("class=\"transcript-thinking\""); + expect(html).toContain("tokens"); +}); + +test("toolPreview returns per-tool one-liners", () => { + expect(toolPreview("Bash", { command: "a".repeat(120) }).length).toBe(80); + expect(toolPreview("Read", { file_path: "/a.ts" })).toBe("/a.ts"); + expect(toolPreview("Grep", { pattern: "foo" })).toBe("foo"); + expect(toolPreview("Task", { description: "do it" })).toBe("do it"); + expect(toolPreview("Unknown", { x: 1 })).toBe(""); + expect(toolPreview(undefined, undefined)).toBe(""); +}); +``` + +Add `toolPreview` to the import from `./session` at the top of the test file (alongside `renderSessionDetail`). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/web/views/session.test.ts` +Expected: FAIL — `toolPreview` not exported / old markup. + +- [ ] **Step 3: Implement** + +3a. In `src/web/views/session.ts`, add the exported helper (above `renderTranscriptItems`): + +```ts +export function toolPreview(name: string | undefined, input: Record | undefined): string { + if (!name || !input) return ""; + const s = (v: unknown) => (typeof v === "string" ? v : ""); + switch (name) { + case "Read": case "Write": case "Edit": return s(input.file_path); + case "Bash": return s(input.command).slice(0, 80); + case "Glob": case "Grep": return s(input.pattern); + case "Task": return s(input.description); + case "WebFetch": return s(input.url); + default: return ""; + } +} +``` + +3b. Replace the whole `renderTranscriptItems` function body with: + +```ts +function renderTranscriptItems(items: TranscriptItem[], showThinking: boolean, showTools: boolean): string { + const resultsByToolUseId = new Map(); + for (const it of items) { + if (it.kind === "tool_result" && it.toolUseId && !resultsByToolUseId.has(it.toolUseId)) { + resultsByToolUseId.set(it.toolUseId, it.content); + } + } + const consumed = new Set(); + + let html = ""; + let hadThinking = false; + let hadTool = false; + for (const item of items) { + if (item.kind === "thinking") { hadThinking = true; if (!showThinking) continue; } + if (item.kind === "tool_use" || item.kind === "tool_result") { hadTool = true; if (!showTools) continue; } + + if (item.kind === "text") { + const who = item.role === "user" ? "User" : "Assistant"; + html += `
${who}
${escapeHtml(item.content)}
`; + } else if (item.kind === "thinking") { + const tokens = item.content.length > 100 + ? `~${Math.round(item.content.length / 4).toLocaleString()} tokens` + : ""; + html += `
${escapeHtml(item.content)}${tokens}
`; + } else if (item.kind === "tool_use") { + const preview = toolPreview(item.name, item.input); + const body = escapeHtml(item.input ? JSON.stringify(item.input, null, 2) : item.content); + let resultHtml = ""; + if (item.id && resultsByToolUseId.has(item.id)) { + consumed.add(item.id); + resultHtml = `
${escapeHtml(resultsByToolUseId.get(item.id)!)}
`; + } + html += `
${escapeHtml(item.name || "tool")}` + + (preview ? `${escapeHtml(preview)}` : "") + + `
${body}
${resultHtml}
`; + } else { // tool_result + if (item.toolUseId && consumed.has(item.toolUseId)) continue; // already shown inside its tool_use + html += `
↳ result
${escapeHtml(item.content)}
`; + } + } + + if (showThinking && !hadThinking) { + html += `
No thinking data for this session.
`; + } + if (showTools && !hadTool) { + html += `
No tool data for this session.
`; + } + return html; +} +``` + +3c. `src/web/views/layout.ts` — add `--accent: #1a6b5a;` inside `:root` (after `--text-ghost`), and replace the existing transcript CSS block (`.transcript-thinking`/`.transcript-tool`/`.transcript-tool pre`, added in the prior feature) with: + +```css + .transcript-thinking { background:var(--surface); border-radius:14px 14px 14px 4px; padding:10px 14px; margin:8px 0; color:var(--text-muted); font-style:italic; font-size:12px; white-space:pre-wrap; } + .transcript-thinking .tokens { display:block; text-align:right; font-size:10px; color:var(--text-ghost); font-style:normal; margin-top:6px; } + details.transcript-tool { border:1px solid var(--border); border-radius:10px; padding:6px 10px; margin:8px 0; font-size:12px; } + details.transcript-tool > summary { cursor:pointer; list-style:none; color:var(--text-muted); } + details.transcript-tool > summary::-webkit-details-marker { display:none; } + .transcript-tool .tool-name { color:var(--accent); font-weight:600; font-family:var(--font-sans); } + .transcript-tool .tool-preview { color:var(--text-faint); margin-left:8px; font-family:monospace; } + .transcript-tool pre { white-space:pre-wrap; margin:6px 0 0; font-family:monospace; font-size:11px; } + .transcript-tool .tool-result { border-top:1px solid var(--border-subtle); margin-top:6px; padding-top:6px; } +``` + +Keep the existing `.transcript-toggle` and `.transcript-warning` rules unchanged. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/web/views/session.test.ts && bun run check` +Expected: PASS (new tests + the existing thinking/tools tests, which still hold: showTools output still contains "Bash" and the command text; showThinking still renders no tool markup). + +- [ ] **Step 5: Commit** + +```bash +git add src/web/views/session.ts src/web/views/layout.ts src/web/views/session.test.ts +git commit -m "feat(session): collapsible tool calls, thinking bubbles, teal accent styling" +``` + +--- + +### Task 3: Full suite green + live visual smoke + +**Files:** verification only. + +- [ ] **Step 1: Full suite + typecheck** + +Run: `bun run check` +Expected: all pass; `All checks passed.` + +- [ ] **Step 2: Live smoke on a session with many tools + thinking** + +```bash +engineering-notebook serve --port 3943 & +sleep 1 +SID=66f8e01a-8eb0-44f1-adc7-b6748914e727 +curl -s "http://localhost:3943/session/$SID?tools=1" | grep -o '
`, name + preview, expand for input) → Task 2. ✔ +- Tool preview table → Task 2 `toolPreview`. ✔ +- Result paired inside its tool_use; orphans standalone; consumed once → Task 2 (map + `consumed` set). ✔ +- Thinking bubble + token estimate (>100 chars) → Task 2. ✔ +- Teal accent + rounded styling on light tokens → Task 2 CSS (`--accent`). ✔ +- Parser carries `id`/`toolUseId`/`input` → Task 1. ✔ +- Text unchanged; escaping preserved; default/toggles/route/fallback untouched → Tasks 1-2 touch only the parser fields + `renderTranscriptItems` + CSS. ✔ +- No new deps, no ingest/storage changes → confirmed by touched files. ✔ + +**Placeholder scan:** No TBD/TODO; every code step has full code; test steps show assertions. + +**Type consistency:** `TranscriptItem`'s new optional fields (Task 1) are read in Task 2 (`item.id`, `item.input`, `item.toolUseId`). `toolPreview(name, input)` signature in Task 2's impl matches its test calls and its use in `renderTranscriptItems`. CSS class names added in Task 2c (`transcript-thinking`, `.tokens`, `transcript-tool`, `.tool-name`, `.tool-preview`, `.tool-result`) match exactly the class strings emitted by Task 2b's render code. diff --git a/docs/superpowers/specs/2026-07-19-combined-app-vision.md b/docs/superpowers/specs/2026-07-19-combined-app-vision.md new file mode 100644 index 0000000..20fe44c --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-combined-app-vision.md @@ -0,0 +1,80 @@ +# Combined App — Vision & Roadmap (React frontend on the engineering-notebook backend) + +**Date:** 2026-07-19 +**Status:** Draft for review (umbrella doc — each phase gets its own spec → plan → build) +**Decision context:** Keep engineering-notebook's backend (ingest, Claude summaries, journal, SQLite); build a new **React** frontend that matches `claude-session-viewer`'s session display; add Claude Desktop **group** functionality. + +## 1. Vision + +One local app that combines: +- **From engineering-notebook (backend, kept):** ingest of Claude Code + Codex sessions, **Claude LLM summaries → journal**, projects, calendar, search, iCal, session groups. +- **From claude-session-viewer (display, rebuilt in React):** polished session transcript — collapsible tool calls, diff view, thinking blocks, conversation minimap, **subagent nesting**, branch switching, and first-class **Hide/Show thinking & tools**. +- **New:** Claude Desktop group import (and later write-back), and correct **session-chain modeling** (subagents nested under their parent). + +The through-line: engineering-notebook's *analysis* features with the viewer's *reading* experience. + +## 2. Key facts established during design (evidence-based) + +- **Resuming a session continues the same session.** A non-subagent session = exactly one `.jsonl` file (verified across 364 real sessions: filename == internal sessionId, none span multiple files); resuming appends to that same file. There is **no** parent-linked "new session on resume." +- **Subagents are the multi-file case.** `agent-*` / sidechain files (174 of them) carry the *parent's* sessionId — one logical session, many files. **Current engineering-notebook does not stitch these together** (it de-dupes by sessionId per file); the new app must nest subagents under their parent. +- **`parentUuid`** threads messages into a tree within a session (edits/rewinds → branches). +- **Thinking is often empty** (only 25/521 sessions have thinking text; Claude Code omits the rest); **tools are populated broadly.** The UI must gracefully show "nothing to reveal" states. +- **Desktop groups are locally readable** at `Local Storage/leveldb` key `dframe-group-scopes` (`{value:{:{groups:[{id:"cg-…",name}], assignments:{"code:local_":groupId}}}}`), joinable to notebook sessions via `local_.json → cliSessionId → sessions.id`. Write-back requires Desktop closed (LevelDB lock). + +## 3. Architecture + +### 3.1 Recommendation: new frontend **inside** the engineering-notebook repo (monorepo) +You said the goal is "so we can incorporate any engineering-notebook enhancements." That is easiest when backend and frontend live and version together. **Recommend: one repo.** +- Backend stays **Bun + Hono + bun:sqlite**, refactored to expose a **JSON API** (the existing `renderX` view functions are replaced by JSON endpoints). +- Frontend: **React + Vite + TypeScript** in a `web/` subdirectory. In dev, Vite proxies `/api/*` to Hono; in prod, Hono serves the built static assets plus the API. +- Reuse the viewer's component *approach* (React Router optional; a single Vite SPA with client routing is simpler here). Port Tailwind or map to the notebook's existing tokens — TBD in the Phase 1 spec. +- **Coexistence:** keep the current server-rendered views working until the React app reaches parity, then retire them. No data migration. + +*(Alternative considered — a separate repo consuming the backend as a library/API — is viable but makes "keep both in sync" harder; deferred unless you prefer a clean split.)* + +### 3.2 Data-model changes (backend) +- **Structured transcript endpoint.** Today the backend stores only lossy text markdown. Add an on-demand endpoint that parses a session's JSONL into **structured blocks** (text/thinking/tool_use/tool_result with ids), so the React viewer can render and toggle them. (Generalizes the `src/transcript.ts` we already built.) +- **Subagent nesting.** Associate `agent-*`/sidechain files with their parent session (by shared sessionId + the spawning tool_use id) so the UI can nest them. Likely a `parent_session_id` / `is_subagent` correction + an API that returns the tree. +- **Groups.** Reuse the `groups`/`session_groups` schema and the `desktop-groups` reader/`importDesktopGroups` we specced; expose via API. +- Summaries/journal/projects/calendar/search: expose existing logic as JSON. + +## 4. Feature synthesis (what the combined app has, and its source) + +| Area | Source | Notes | +|---|---|---| +| Ingest (Claude Code + Codex) | notebook | keep; fix subagent stitching | +| Claude summaries → journal | notebook | keep; expose via API | +| Projects / Calendar / Search / iCal | notebook | re-render in React | +| Session transcript (collapsible tools, diff, thinking, minimap) | viewer | rebuild in React | +| Hide/Show thinking & tools | viewer (structured) | native/structured, replacing our re-parse toggle | +| Subagent panels / nesting | viewer | needs backend stitching | +| Branch switcher (`parentUuid`) | viewer | optional, later phase | +| Session groups | notebook (new) | manual + Desktop import | +| Desktop group import / write-back | new | Phase-1 import spec already written; write-back later | + +## 5. Phased roadmap (recommended order; each phase = its own spec → plan → build) + +**Phase 1 — Foundation + session display parity (the core UX).** +React/Vite app in-repo, served by Hono; JSON API for the session list and a **structured transcript** endpoint; the polished session viewer with **Hide/Show thinking & tools**, collapsible tools, and **subagent nesting**. Deliverable: browse sessions and read them beautifully. *Rationale: this is the experience you most want, and it forces the API + structured-transcript + subagent groundwork everything else builds on.* + +**Phase 2 — Journal & summaries in React.** Surface the existing summarization/journal via API + React views (daily journal, entry detail, on-demand summarize). + +**Phase 3 — Projects / Calendar / Search / iCal in React.** Port the remaining views. + +**Phase 4 — Session Groups.** Manual groups + **Desktop import** (adapt the already-written import spec/plan to the API + React), then **Desktop write-back** (Phase 2 of groups) with the closed-app safeguards. + +**Phase 5 — Polish & retire server-rendered views.** Branch switcher, minimap, empty-state affordances, remove the old Hono HTML views. + +*Reordered from the current queue:* the standalone Desktop-import and any further notebook tweaks fold into Phase 4 / the backend API work, so they're not wasted. + +## 6. Open questions / risks + +- **Styling system:** adopt the viewer's Tailwind, or map to the notebook's existing tokens? (Phase 1 spec decides.) +- **Client routing/SPA vs SSR:** recommend a Vite SPA + Hono API for simplicity; revisit if SEO/first-paint matters (it's a local tool, so it doesn't). +- **Subagent stitching correctness:** the spawning relationship must be derived reliably (shared sessionId + tool_use → agentId). Needs a data spike in Phase 1. +- **Scope creep:** the viewer has a lot (minimap, branches). Phase 1 targets the 80% (tools/thinking/subagents); minimap/branches are Phase 5. +- **Effort:** this is a multi-week rebuild. Phasing keeps each step shippable and reviewable. + +## 7. Immediate next step + +On approval of this vision: write the **Phase 1 spec** (Foundation + session display parity) — API surface, structured-transcript + subagent-tree endpoints, the React app skeleton, and the viewer components to build first — then plan → build it via the usual flow. diff --git a/docs/superpowers/specs/2026-07-19-desktop-group-import-design.md b/docs/superpowers/specs/2026-07-19-desktop-group-import-design.md new file mode 100644 index 0000000..551a3c7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-desktop-group-import-design.md @@ -0,0 +1,113 @@ +# Desktop Group Import (Phase 1) — Design + +**Date:** 2026-07-19 +**Status:** Approved +**Builds on:** `2026-07-19-session-groups-design.md` (the manual Session Groups feature) +**Branch:** `feature/session-groups` + +## Motivation + +The manual Session Groups feature ships an empty Groups tab: the user must create every group by hand. But the user already has real groups in the **Claude Desktop** app — "Tolaria", "OpenBB", "Cashins Comments" — each with sessions filed under it. They expected those to appear in the notebook automatically, with every other session shown as **Ungrouped**. + +Investigation established that the Desktop groups **are** readable from local disk (an earlier "not readable" conclusion was wrong — the search looked for the word "group" instead of the actual group names). They live in the Claude Desktop app's Chromium LocalStorage under the key **`dframe-group-scopes`**, and each Desktop session joins to a notebook session via a two-hop mapping that was verified end-to-end. + +This is **Phase 1**: one-way import (Desktop → notebook) plus an Ungrouped view and a guard. **Phase 2** (write-back, notebook → Desktop) is a separate future spec and is out of scope here. + +## Data Source (verified) + +- **File:** `~/Library/Application Support/Claude/Local Storage/leveldb/` (Chromium LevelDB-backed LocalStorage for origin `https://claude.ai`). +- **Key:** `dframe-group-scopes`. Its value is a JSON object holding: + - a **groups** array — objects with a group id (e.g. `g-4a1f3ce4-49ab-42d2-a2da-ec04dba4e9bd`) and a `name` ("Tolaria", "OpenBB", "Cashins Comments"); + - an **assignments** map — Desktop-session-id → group-id. + - (Exact top-level field names and value encoding are pinned by the reader spike, Task 1 of the plan.) +- **Join to the notebook (verified):** a Desktop assignment key is a Desktop session id matching a file `~/Library/Application Support/Claude/claude-code-sessions///local_.json`; that file's `cliSessionId` equals the notebook's `sessions.id`. Confirmed: assignment `bb6b56ef-…` → `local_bb6b56ef-…json` → `cliSessionId d73396da-…` → notebook session `d73396da-…`. + +## Requirements + +- A **"Sync from Claude Desktop"** action (Groups-page button + an `import-desktop-groups` CLI subcommand) that imports Desktop groups and their session assignments into the notebook. +- Imported groups are tagged with their Desktop group id; re-sync is idempotent and **mirrors Desktop** for imported groups (adds/renames/removes to track Desktop), while leaving **notebook-created groups untouched**. +- An **Ungrouped** view listing all sessions with no group. +- The existing manual create/rename/delete/assign controls stay. +- A **Desktop-open guard**: while Claude Desktop is running, group-mutating actions (create/rename/delete group, assign/unassign session) are **blocked** with an explanatory banner. The import action is exempt. The block/warn behavior is a single policy switch (this phase = block; future = warn). + +## Non-Goals (Phase 2 / out of scope) + +- Write-back to Desktop (notebook → `dframe-group-scopes`). +- Flipping the guard from block to warn-and-allow. +- Assigning notebook sessions that Desktop has never seen (no `local_*.json`) — not representable in Desktop, irrelevant for one-way import. + +## Data Model + +Extend the existing `groups` table (created in the manual-groups feature): + +```sql +ALTER TABLE groups ADD COLUMN desktop_id TEXT; -- Desktop group id; NULL for notebook-created groups +CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_desktop_id ON groups(desktop_id) WHERE desktop_id IS NOT NULL; +``` + +- Added idempotently via the existing try/catch `ALTER TABLE` migration pattern in `initDb`, plus the partial unique index in the schema block (partial so multiple manual groups can share `NULL`). +- `session_groups` unchanged. Provenance of a group is `desktop_id IS NOT NULL`. + +## Components + +### 1. Desktop reader — `src/desktop-groups.ts` +- `readDesktopGroups(opts?): { groups: {desktopId: string; name: string}[]; assignments: {cliSessionId: string; desktopGroupId: string}[] } | null` +- Steps: locate the leveldb dir → **copy it to a temp dir** (snapshot; safe while Desktop holds the write-lock) → extract the `dframe-group-scopes` value → decode JSON → validate shape (return `null` / throw a typed error if unrecognized, so a future Desktop format change fails safe rather than corrupting notebook state) → build the `desktopSessionId → cliSessionId` map by scanning `claude-code-sessions/**/local_*.json` → return Desktop groups plus assignments already resolved to `cliSessionId`. +- **Key risk / first task:** reliable extraction from the Chromium LevelDB (the value currently lives in a compacted `.ldb` sstable, and may be Snappy-compressed; the WAL path must also be handled). Task 1 is a spike with a test against the real store, iterating until it decodes. Prefer a dependency-free reader; a small vetted LevelDB/Snappy dependency is permitted only if dependency-free proves unreliable. +- Path override via option/env so tests run against a fixture copy rather than the live machine store. + +### 2. Import / reconcile — `src/groups.ts` +- `importDesktopGroups(db, source): { groupsAdded; groupsRenamed; groupsRemoved; sessionsAssigned; sessionsUnassigned; skipped }` where `source` is the reader's output (injected, so tests don't touch the real store). +- Reconcile: + - Upsert each Desktop group by `desktop_id` (create if new; rename if `name` changed). + - For each Desktop group, set its membership to exactly Desktop's assignment set whose `cliSessionId` exists in `sessions`: assign new ones (upsert on `session_id`), and remove sessions currently in that Desktop group but no longer assigned in Desktop. Assignments whose `cliSessionId` isn't in the notebook are counted as `skipped`. + - Delete `desktop_id` groups that no longer exist in Desktop (cascade clears their memberships). + - Groups with `desktop_id IS NULL` (notebook-created) and their memberships are never read or written by import. + +### 3. Ungrouped view — `src/web/views/groups.ts` +- The Groups index gains an **"Ungrouped (N)"** entry linking to `/groups/ungrouped`. +- `renderUngrouped(db, page)` lists sessions with no `session_groups` row, ordered by `started_at` desc, paginated (100/page) with an "N of M" count and prev/next links; each row links to `/session/:id`. + +### 4. Desktop-open guard — `src/desktop-groups.ts` + `src/web/server.ts` +- `isClaudeDesktopRunning(): boolean` — process check via `pgrep` for the Claude Desktop binary. Returns false on error (fail-open on detection failure so the notebook is never wedged by a detection bug). +- A single `GROUP_EDIT_POLICY` constant (`"block" | "warn"`, this phase `"block"`) and a helper `guardGroupEdit()` used by the four mutating routes (`POST /groups`, `/groups/:id/rename`, `/groups/:id/delete`, `/sessions/:id/group`). When policy is `block` and Desktop is running: skip the mutation and re-render the page (Groups index or session view) with a banner "Claude Desktop is open — quit it before editing groups." (HTTP 200). Import routes do not call the guard. +- The guard's "is Desktop running" check is injectable in tests (parameter/override) so tests don't depend on the real process table. + +### 5. Routes & CLI — `src/web/server.ts`, `src/index.ts` +- `POST /groups/import-desktop` — runs the reader + `importDesktopGroups`, redirects to `/groups` (with a summary/error banner). Not guarded. +- `GET /groups/ungrouped` — paginated Ungrouped page (`?page=`). +- A "Sync from Claude Desktop" button on the Groups index posts to `/groups/import-desktop`. +- CLI: `engineering-notebook import-desktop-groups` runs the same import and prints the summary. + +## Edge Cases + +| Case | Behavior | +|------|----------| +| Desktop store missing / key absent | Reader returns null; import reports "No Claude Desktop groups found," no changes. | +| `dframe-group-scopes` shape unrecognized (Desktop update) | Reader throws typed error; import aborts with a clear message; notebook state untouched. | +| Desktop assignment → session not in notebook | Counted as `skipped`; not assigned. | +| Desktop group deleted since last import | Its `desktop_id` group + memberships removed on re-sync. | +| Session manually moved off an imported group, then re-sync | Snaps back to Desktop's assignment (imported groups mirror Desktop). | +| Desktop group name collides with an existing differently-identified group (manual group, or already-imported different `desktop_id`) | The colliding Desktop group is **skipped** and reported in the summary; `groups.name` UNIQUE is kept (see Risk). Non-colliding groups still import. | +| Desktop running during a manual edit | Blocked with banner (policy = block). | +| Desktop running during import | Allowed (reads a snapshot copy). | + +## Risks + +- **`groups.name` UNIQUE constraint.** The base feature made `name` UNIQUE. We **keep** it — dropping it in SQLite forces a full `groups` table rebuild, which is risky given `session_groups.group_id`'s cascading FK. Instead, import matches groups by `desktop_id`; if a Desktop group's name collides with an existing group of different identity, that one group is skipped and reported, and the rest still import. This is simpler and lower-risk than a rebuild, at the cost of not allowing a manual and a Desktop group to share a name (acceptable — the user currently has no manual groups, and the collision is rare and clearly reported). +- **LevelDB/sstable reading** is the primary technical risk (Task 1 spike). +- **Undocumented Desktop format** — mitigated by shape validation that fails safe. +- **Snapshot copy while Desktop writes** — may yield a momentarily torn read; acceptable (re-run sync). + +## Testing + +- **Reader:** decodes a committed fixture copy of a leveldb store into the expected groups + assignments; shape-validation rejects malformed input; missing store → null. +- **Import/reconcile:** creates/renames/removes Desktop groups; propagates membership add + remove; leaves manual groups untouched; idempotent on re-run; counts skipped (session-not-in-notebook). +- **Join:** `local_*.json` → `cliSessionId` mapping across a fixture. +- **Ungrouped:** correct membership-free set, ordering, pagination + counts. +- **Guard:** mutation blocked when "running," allowed when not (running-check injected); import never blocked. +- **Migration:** adding `desktop_id` + partial unique index preserves existing rows; existing manual-groups tests still pass; a name-colliding Desktop group is skipped and reported. + +## Rollout + +Additive column + index and new module; no data loss. New CLI subcommand and routes. Reader touches only a temp **copy** of the Desktop store — never writes to Desktop in Phase 1. diff --git a/docs/superpowers/specs/2026-07-19-phase1-foundation-session-display-design.md b/docs/superpowers/specs/2026-07-19-phase1-foundation-session-display-design.md new file mode 100644 index 0000000..8229d90 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-phase1-foundation-session-display-design.md @@ -0,0 +1,85 @@ +# Phase 1 — Foundation + Session Display Parity — Design + +**Date:** 2026-07-19 +**Status:** Draft for review +**Umbrella:** `2026-07-19-combined-app-vision.md` (Phase 1 of 5) +**Branch:** a new `feature/react-frontend` off `feature/session-groups` (TBD at plan time) + +## Goal + +Stand up a **React frontend inside the engineering-notebook repo**, served by the existing Hono backend via a **JSON API**, delivering a polished **session viewer** — collapsible tool calls, thinking blocks, **subagent nesting**, and first-class **Hide/Show thinking & tools** — reading structured data parsed from the original JSONL. This is the foundation (API + structured transcript + build wiring) that later phases build on. + +## Scope (Phase 1 only) + +- Monorepo layout: React/Vite app in `web/`, Hono serves the API and (in prod) the built assets. +- JSON API: session **list**, session **metadata**, **structured transcript**, **subagent** transcript. +- React app: a **session list** view and a **session detail** view with the viewer components below. +- Session viewer: message stream (user/assistant), **collapsible `
` tool calls** with name + preview + paired result, **thinking blocks**, **subagent panels** (lazy-loaded), and client-side **Hide/Show thinking** + **Hide/Show tools** toggles (structured, no re-parse round-trip). +- Styling: **Tailwind** (as the viewer components assume). + +## Out of scope (later phases) + +- Journal/summaries (Phase 2), projects/calendar/search (Phase 3), groups + Desktop import/write-back (Phase 4), minimap + branch switcher + diff view + retiring old views (Phase 5). +- No changes to ingest/summarize/DB schema. The old server-rendered Hono views keep working, untouched. + +## Architecture + +### Repo & build +- `web/` — Vite + React + TypeScript + Tailwind SPA (client-side routing). +- Backend (`src/`) adds an **API router** mounted at `/api` in the existing Hono app (`createApp`), alongside the current routes. +- **Dev:** run Vite dev server (e.g. `:5173`) proxying `/api/*` to Hono (`:3000`); `engineering-notebook serve` still runs the Hono app. +- **Prod:** `vite build` → `web/dist`; Hono serves `web/dist` static assets for non-`/api` routes (SPA fallback to `index.html`). A new `serve --react` flag (or a config toggle) selects the React app; the legacy views remain at their paths until Phase 5. +- Bun stays the runtime and test runner. Vite/React are dev/build deps. + +### Backend: structured transcript (generalize `src/transcript.ts`) +- `parseTranscriptStructured(jsonlText)` → ordered blocks preserving message boundaries: `{ role, uuid, parentUuid, timestamp, blocks: Block[] }[]` where `Block = text | thinking | tool_use{id,name,input} | tool_result{toolUseId,content}`. (Extends the existing `parseTranscript`; keeps ids for pairing and message threading for later branch support.) +- Reads the session's `source_path`; falls back to `{error:"source unavailable"}` when missing. + +### Backend: subagent discovery + mapping (exact, via `.meta.json`) +On-disk layout confirmed by spike: `//subagents/agent-.jsonl`, each with a sibling `agent-.meta.json`: +```json +{ "agentType": "general-purpose", "description": "Implement Task 1: transcript parser", + "toolUseId": "toolu_01Up…", "spawnDepth": 1 } +``` +- Discover subagents by listing `//subagents/`. +- **Map each subagent to its parent `Task` tool_use *exactly* via `meta.toolUseId`** — no prompt-matching heuristic needed (the viewer's approach is superseded). `description`, `agentType`, and `spawnDepth` come from the meta file; `spawnDepth > 1` means a subagent spawned by another subagent (nest recursively). +- `GET /api/subagent/:sessionId/:agentId` returns that subagent's structured transcript. + +### API surface (Phase 1) +| Method/Path | Returns | +|---|---| +| `GET /api/sessions?limit&offset&project&q` | paginated session list: id, project, title/first-prompt, started/ended, message count, has-subagents, is-summarized (best-effort from existing tables) | +| `GET /api/sessions/:id` | session metadata + the list of `{agentId, description, agentType, toolUseId, spawnDepth}` subagents (from each `.meta.json`) | +| `GET /api/sessions/:id/transcript` | structured transcript (blocks) for the main session | +| `GET /api/subagent/:sessionId/:agentId` | structured transcript for one subagent | + +All JSON; errors as `{ error }` with appropriate status. These are additive (new `/api/*` routes); existing routes untouched. + +### Frontend +- **Routes:** `/` (session list), `/s/:id` (session detail). Client-side router. +- **Session list:** rows with title/first-prompt, project, date, counts; links to detail. (Search/filter minimal in Phase 1; full search is Phase 3.) +- **Session detail:** fetches `/api/sessions/:id` + `/transcript`; renders the message stream via: + - `MessageBlock` — role, timestamp, text (markdown via `react-markdown` + `remark-gfm`). + - `ThinkingBlock` — bubble; hidden unless "Show thinking". + - `ToolCallBlock` — `
` with tool name + preview (Read/Write/Edit→file_path, Bash→command≤80, Glob/Grep→pattern, Task→description, WebFetch→url) and the paired `tool_result` inside; hidden unless "Show tools". + - `SubagentPanel` — for each mapped subagent, an expandable panel that lazy-loads `/api/subagent/...` and renders it with the same components (recursively supports nested subagents). + - **Toggles:** two buttons controlling client state `showThinking` / `showTools` (default **off**), toggling visibility of already-loaded structured blocks (no server round-trip). Empty-state note when the shown kind has no data. +- **Styling:** Tailwind; a small token set approximating the notebook's warm palette + a teal accent (or the viewer's palette — decided in the plan; default: viewer's). + +## Testing + +- **Backend (bun test):** `parseTranscriptStructured` returns correct ordered blocks incl. ids + message threading (fixtures for Claude + Codex); subagent discovery finds `agent-*` files for a session; subagent→Task prompt-matching maps correctly and lists unmatched; each API endpoint returns the right shape and 404s appropriately (fixture DB + fixture JSONL). +- **Frontend (vitest + Testing Library):** session detail renders messages; tool calls are collapsed by default and reveal input + paired result on "Show tools"; thinking hidden until "Show thinking"; subagent panel lazy-loads and renders; empty-state note shows when a shown kind is absent. +- **Smoke:** dev + prod build serve the app; a real session with tools+thinking+subagents renders and toggles correctly. + +## Risks / decisions deferred to the Phase 1 plan + +- **On-disk subagent layout** — RESOLVED by spike: `//subagents/agent-.jsonl` + `.meta.json` with an exact `toolUseId`. No longer a risk. +- **Vite ↔ Hono wiring** (dev proxy + prod static serving under Bun) — a small integration spike (plan Task 1). +- **Tailwind vs notebook tokens** — pick in the plan; lean Tailwind. +- **Session-list "title"** — reuse the existing display_name/first-prompt logic. +- Keep Phase 1 to the 80%: no diff view, minimap, or branch switcher (Phase 5). + +## Rollout + +Purely additive: new `web/` app, new `/api/*` routes, new build wiring behind a flag. Legacy views and all backend data/logic unchanged. Nothing ships to users automatically; the React app is opt-in until parity across phases. diff --git a/docs/superpowers/specs/2026-07-19-session-groups-design.md b/docs/superpowers/specs/2026-07-19-session-groups-design.md new file mode 100644 index 0000000..6d57d1c --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-session-groups-design.md @@ -0,0 +1,127 @@ +# Session Groups — Design + +**Date:** 2026-07-19 +**Status:** Approved +**Scope:** Add a **Groups** view to the engineering-notebook web UI, letting the user create named headers and file individual coding sessions under them. + +## Motivation + +The Claude Desktop app lets a user group sessions under a named header in the sidebar. The user wants that organizing idea replicated in the notebook. Investigation established that the desktop groupings are held **server-side in the Anthropic account** and are not readable from any local file, and that the desktop "groups" (claude.ai Projects) actually organize **web-chat conversations** — a different corpus than the Claude Code / Codex **terminal transcripts** this notebook ingests. + +Decision: build **manual groups over the notebook's existing coding sessions** now. Mirroring/ingesting claude.ai web chats is a separate future spec and is **out of scope here**. + +## Requirements + +- A **Groups** tab in the top nav: `Journal · Projects · Calendar · Groups` (Groups last). +- A group is a **named header** containing **individual sessions**. +- **One group per session** (a session is filed under at most one header, like the desktop sidebar). +- Groups are created, renamed, and deleted **in-app** (no hand-editing config files). +- A session is filed into a group via an **"Add to group"** control on the **session transcript view**. +- Group membership **persists across re-ingest** (ingest deletes/recreates session rows). + +## Non-Goals (YAGNI) + +- No drag-to-reorder of groups or sessions. +- No colors, icons, or descriptions on groups. +- No bulk multi-select assignment. +- No dedicated "Ungrouped" browsing page. +- No ingestion of claude.ai web chats (separate future spec). + +## Data Model + +Two new SQLite tables, added to the existing `CREATE TABLE IF NOT EXISTS` schema block in `src/db.ts` (no migration gymnastics needed — the schema is created idempotently on init). + +```sql +CREATE TABLE IF NOT EXISTS groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS session_groups ( + session_id TEXT PRIMARY KEY, -- one group per session; NO FK (see below) + group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + assigned_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_session_groups_group ON session_groups(group_id); +``` + +**Foreign-key enforcement is ON.** `initDb` runs `PRAGMA foreign_keys = ON`. This is the constraint that shapes the schema: + +- **`session_id` has NO foreign key** (deliberately). `src/ingest.ts` runs `DELETE FROM sessions WHERE id = ?` on `--force` re-ingest. If `session_id` referenced `sessions(id)`, that DELETE would be blocked by the FK (RESTRICT) and re-ingest would throw. Keeping `session_id` a bare `TEXT PRIMARY KEY` means ingest never touches membership, so it survives re-ingest. When a session is deleted and re-inserted with the same UUID, its membership row still applies. +- **`group_id` references `groups(id) ON DELETE CASCADE`.** With FK enforcement on, deleting a group automatically removes its membership rows — no manual cleanup needed. + +**Cardinality:** `session_id` as PRIMARY KEY enforces one-group-per-session at the schema level. Reassigning a session is an upsert (`INSERT ... ON CONFLICT(session_id) DO UPDATE`). + +**Group deletion:** `deleteGroup` is a plain `DELETE FROM groups WHERE id = ?`; the `ON DELETE CASCADE` clears memberships. + +**Orphans:** a membership row whose `session_id` no longer exists (session permanently removed, not re-ingested) is harmless — all read queries `JOIN sessions`, so orphans are filtered out. Optional lazy prune is allowed but not required. + +## Backend + +### `src/db.ts` helpers + +- `listGroups(): GroupSummary[]` — each group with `id`, `name`, `sessionCount`, `lastActivityAt` (max of member sessions' `started_at`), ordered by `lastActivityAt` desc (nulls last). +- `createGroup(name: string): number` — insert, return id; throws / surfaces error on duplicate name (UNIQUE). +- `renameGroup(id: number, name: string): void` — surfaces duplicate-name error. +- `deleteGroup(id: number): void` — `DELETE FROM groups WHERE id = ?` (cascade clears memberships). +- `assignSession(sessionId: string, groupId: number | null): void` — `null` removes membership; otherwise upsert on `session_id`. +- `getSessionGroupId(sessionId: string): number | null` — current group for rendering the control. +- `getGroupWithSessions(id: number): { group, sessions[] } | null` — group plus its member sessions (joined to `sessions` and their `projects`), ordered by `started_at` desc. Each session row carries enough to link to its transcript and, where available, its journal entry. + +### `src/web/server.ts` routes (Hono, matching the existing Settings form-POST pattern) + +- `GET /groups` — index page. +- `GET /groups/:id` — group detail; 404 if missing. +- `POST /groups` — create from form field `name`; redirect to `/groups` (or back with an error banner on duplicate/empty name). +- `POST /groups/:id/rename` — form field `name`; redirect back. +- `POST /groups/:id/delete` — redirect to `/groups`. +- `POST /sessions/:id/group` — form field `group_id` (`""` = unassign); `assignSession`; redirect back to the session view. + +Validation: empty/whitespace name rejected; duplicate name reported to the user rather than throwing a 500. + +## UI (`src/web/views/`) + +- **`layout.ts`** — add a `Groups` nav link after `Calendar`, with a `groupsActive` flag mirroring the existing `journalActive` / `projectsActive` / `calendarActive` pattern. +- **`groups.ts` (new)** — two renders: + - **Index:** a "New group" text input + Create button; a list of groups showing name, session count, and last activity, each linking to its detail page; inline rename (input + save) and delete (button with confirm) per group. + - **Detail:** group name with rename/delete controls; a list of member sessions (title, date, project) linking to the existing session transcript view and journal view; empty-state text when the group has no sessions. +- **`session.ts`** — add an **"Add to group"** ``; + html += ``; + html += ``; + + if (groups.length === 0) { + html += `
No groups yet. Create one above.
`; + } else { + for (const g of groups) { + const activity = g.lastActivityAt ? g.lastActivityAt.slice(0, 10) : "—"; + html += `
`; + html += `${escapeHtml(g.name)}`; + html += `${g.sessionCount} session(s) · ${activity}`; + html += `
`; + } + } + + html += ``; + return html; +} + +export function renderGroupDetail(db: Database, id: number): string | null { + const data = getGroupWithSessions(db, id); + if (!data) return null; + const { group, sessions } = data; + + let html = `
`; + html += ``; + + html += `
`; + html += ``; + html += ``; + html += `
`; + + html += `
`; + html += ``; + html += `
`; + + if (sessions.length === 0) { + html += `
No sessions in this group yet.
`; + } else { + for (const s of sessions) { + html += `
`; + html += `${escapeHtml(s.display_name)}`; + html += `
${s.started_at.slice(0, 10)} · ${s.message_count} messages · ${escapeHtml(s.project_id)}
`; + html += `
`; + } + } + + html += `
`; + return html; +} diff --git a/src/web/views/layout.ts b/src/web/views/layout.ts index 8b7f2a9..8a82462 100644 --- a/src/web/views/layout.ts +++ b/src/web/views/layout.ts @@ -9,7 +9,7 @@ type ThreePanelContent = { type SingleContent = { body: string; - activeTab?: "calendar"; + activeTab?: "calendar" | "groups"; }; type FullWidthContent = { @@ -34,6 +34,7 @@ export function renderLayout(title: string, content: LayoutContent): string { const journalActive = activeTab === "journal"; const projectsActive = activeTab === "projects"; const calendarActive = activeTab === "calendar"; + const groupsActive = activeTab === "groups"; let bodyHtml: string; if (isThreePanel(content)) { @@ -67,6 +68,7 @@ export function renderLayout(title: string, content: LayoutContent): string { --text-muted: #57534e; --text-faint: #78716c; --text-ghost: #a8a29e; + --accent: #1a6b5a; --font-serif: Georgia, 'Times New Roman', serif; --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; } @@ -700,6 +702,18 @@ export function renderLayout(title: string, content: LayoutContent): string { } a { color: var(--text-muted); } a:hover { color: var(--text); } + + .transcript-toggle { display:flex; gap:12px; margin-bottom:14px; font-size:12px; } + .transcript-thinking { background:var(--surface); border-radius:14px 14px 14px 4px; padding:10px 14px; margin:8px 0; color:var(--text-muted); font-style:italic; font-size:12px; white-space:pre-wrap; } + .transcript-thinking .tokens { display:block; text-align:right; font-size:10px; color:var(--text-ghost); font-style:normal; margin-top:6px; } + .transcript-tool { border:1px solid var(--border); border-radius:10px; padding:6px 10px; margin:8px 0; font-size:12px; } + details.transcript-tool > summary { cursor:pointer; list-style:none; color:var(--text-muted); } + details.transcript-tool > summary::-webkit-details-marker { display:none; } + .transcript-tool .tool-name { color:var(--accent); font-weight:600; font-family:var(--font-sans); } + .transcript-tool .tool-preview { color:var(--text-faint); margin-left:8px; font-family:monospace; } + .transcript-tool pre { white-space:pre-wrap; margin:6px 0 0; font-family:monospace; font-size:11px; } + .transcript-tool .tool-result { border-top:1px solid var(--border-subtle); margin-top:6px; padding-top:6px; } + .transcript-warning { color:#b91c1c; font-size:12px; margin:8px 0; } @@ -709,6 +723,7 @@ export function renderLayout(title: string, content: LayoutContent): string { Journal Projects Calendar + Groups
diff --git a/src/web/views/session.test.ts b/src/web/views/session.test.ts index b52cd63..f49941f 100644 --- a/src/web/views/session.test.ts +++ b/src/web/views/session.test.ts @@ -1,5 +1,12 @@ import { describe, test, expect } from "bun:test"; import { renderSessionFooter } from "./session"; +import { beforeEach, afterEach } from "bun:test"; +import { initDb, closeDb } from "../../db"; +import { createGroup, assignSession } from "../../groups"; +import { renderSessionDetail, toolPreview } from "./session"; +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; describe("renderSessionFooter", () => { test("renders Claude resume command for Claude session source paths", () => { @@ -24,3 +31,196 @@ describe("renderSessionFooter", () => { expect(html).not.toContain("claude --resume"); }); }); + +describe("session detail group control", () => { + let tempDir: string; + let db: ReturnType; + + function seed(id: string) { + db.query("INSERT OR IGNORE INTO projects (id, path, display_name) VALUES ('p','/tmp/p','Proj')").run(); + db.query( + `INSERT INTO sessions (id, project_id, project_path, source_path, started_at, message_count, ingested_at) + VALUES (?, 'p', '/tmp/p', '/tmp/s.jsonl', '2026-07-10T00:00:00Z', 3, datetime('now'))` + ).run(id); + db.query("INSERT INTO conversations (session_id, conversation_markdown, extracted_at) VALUES (?, '# hi', datetime('now'))").run(id); + } + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "notebook-sessionctl-test-")); + db = initDb(join(tempDir, "test.db")); + }); + afterEach(() => { + closeDb(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("renders an assign form with a None option and each group", () => { + seed("s1"); + createGroup(db, "Trading"); + createGroup(db, "Infra"); + const html = renderSessionDetail(db, "s1"); + expect(html).toContain('action="/sessions/s1/group"'); + expect(html).toContain("None"); + expect(html).toContain("Trading"); + expect(html).toContain("Infra"); + }); + + test("preselects the session's current group", () => { + seed("s1"); + const gid = createGroup(db, "Trading"); + assignSession(db, "s1", gid); + const html = renderSessionDetail(db, "s1"); + expect(html).toMatch(new RegExp(`