The sessions table previously only supported per-row deletion via the
"del" button — no way to clear many at once. Add an industry-standard
multi-select pattern.
Pattern follows Gmail / GitHub / Linear / Vercel:
- Leftmost cell on each row is a checkbox; data-id binds to session id.
- Header checkbox is tri-state (empty / indeterminate / all-on-page)
driven by a single source of truth (selectedSessionIds: Set<string>).
- Shift-click on a row checkbox does range-select from the
last-clicked anchor to the current row, applying the new checked
state to every row in between (macOS Finder / Gmail convention).
- Contextual action bar slides in above the table when ≥1 row is
selected, showing "N selected · Delete selected · Clear (Esc)".
- Confirm modal shows count + first 3 session id prefixes + "and N
more" for sanity-check before destructive action.
- Esc key clears the selection (no-op when an input is focused).
- Selection is keyed on session id and reapplied after each diff
render — innerHTML rewrites no longer clobber checked state.
- Vanished sessions auto-drop from selection.
Backend:
- PruneOptions gains sessionIds?: string[] alongside the existing
single sessionId. Unknown ids are silently skipped (race-safe with
concurrent prunes). Composes with sessionId if both are set.
- handlePrune plumbs sessionIds through; the bulk-delete action calls
/api/sessions/prune once with all ids.
- 3 new selectSessionsToRemove tests: bulk select, unknown-id skip,
sessionId+sessionIds composition.
Tests: 266 → 269 (+3). Typecheck + build clean.
The dashboard headline was showing 0% saved because the math was
apples-to-oranges:
- orig_chars = static slab + tool docs ONLY (~135 KB)
- image_count = ALL images including reminders + tool_results (~33)
- txtReplaced = 135k/4 = 33.7k tokens
- imgTokensEst = 33 × 2500 = 82.5k tokens
- extraText = max(0, 33.7k - 82.5k) = 0 ← clamped negative
- baseline == paid → 'saved 0%' headline despite real savings happening
Two fixes:
1. Track 'compressedChars' separately from 'origChars'. It accumulates
only when a block actually image-renders (passes the break-even gate
AND any per-block min thresholds). Includes the static slab + all
compressed reminders + all compressed tool_results.
2. Remove the Math.max(0,...) clamp in baselineCost(). Negative savings
surface honestly — they're the signal that small-block compressions
are cost-bleeding, which the break-even gate is supposed to prevent.
Wires:
- TransformInfo.compressedChars (new field, mandatory).
- All 4 image-render success paths accumulate (static slab + reminder
+ tool_result string branch + tool_result array branch).
- TrackEvent.compressed_chars (new optional field, emitted when > 0).
- dashboard.baselineCost() takes compressedChars now; clamp removed.
- sessions.aggregateSessions() also uses compressed_chars + honest math.
229/229 tests passing. Bundle stays at 496 KB gzipped.
Post-restart, the dashboard 'tokens saved' will show real numbers
including negatives. Empirically expect ~1-5% net positive in
steady-state cache-hit traffic.
Two distinct improvements in one slice:
1. History-image compression (Variant C — opt-in, default OFF).
New src/core/history.ts module:
- findClosedPrefixBoundary(): walks tool_use_id openSet, returns largest
fully-closed prefix index. Mid-flight tool_use blocks bail the collapse.
- blocksToText(): linearises content blocks for the history image. Drops
thinking blocks (per Opus 4.7+ docs: only most-recent must be bit-perfect),
renders tool_use args + tool_result inner text, emits [image] placeholder
for nested images.
- collapseHistory(): orchestrates. Applies isCompressionProfitable() gate.
Returns a synthesized {role:'user', content:[text,image,text]} prepended
message + the elided turns dropped from messages[].
New TransformOptions: compressHistory (default false), historyKeepTail (4),
historyMinPrefix (10). Wired through src/node.ts + src/worker.ts as
COMPRESS_HISTORY / HISTORY_KEEP_TAIL / HISTORY_MIN_PREFIX env vars.
Cache_control: history-image carries NO cache_control on this first cut.
Static slab keeps its 1 breakpoint. Conservative — telemetry will reveal
whether the cache-miss rate on history images warrants the round-3
rebalance in a follow-up slice.
29 new tests in tests/history.test.ts: closed-prefix detection (parallel
tool calls, mid-flight, openSet straddling), block linearisation, full
collapse flow, isCompressionProfitable gate firing on small histories.
2. Per-session savings: honest formula (fixes task #63).
Previous src/sessions.ts computed saved = orig_chars - image_bytes — a
nonsense comparison of chars to PNG bytes, then clamped via Math.max(0,...).
Result: charsSaved/tokensSavedEst was 0 for every session including ones
with hundreds of MB of compressed traffic.
New formula mirrors src/dashboard.ts:
textTokens = orig_chars / 4
imageTokens = image_count × 2500
tokensSaved = textTokens − imageTokens (CAN be negative)
Negatives are NOT clamped — they surface the cost-bleed on small-block
compressions that the per-block break-even check is supposed to prevent.
2 new tests: positive-net case, all-negative case (must report negative).
229/229 tests passing. Bundle ~500 KB gzipped.
Pivot from prior CLI subcommands (stats, sessions) to dashboard-based UI.
bin/cli.js becomes a minimal proxy starter; all functionality lives at
http://127.0.0.1:47821/.
New endpoints:
GET /api/sessions.json — session list + Claude Code mapping
GET /api/sessions/:id.json — session detail (bodies redacted by default)
POST /api/sessions/prune — dry-run-first cleanup with atomic rewrite
GET /api/disk.json — events.jsonl + 4xx-bodies disk usage
GET /api/stats.json — full-history aggregate (replaces CLI 'stats')
GET /sessions/:id — HTML detail page
Dashboard HTML extended with sessions panel + stats panel + cleanup panel.
Smooth-poll: 2s tick for legacy live counters, 5s tick for sessions/stats/disk.
Session rows diff-rendered (no full-table flash). Destructive actions show
window.confirm() with dry-run preview of impact.
Session mapping: synthetic ID from first_user_sha8 (already on every event)
+ opportunistic enrichment by fingerprinting against ~/.claude/projects/*.jsonl.
Falls back gracefully if Claude Code's session dir is missing.
Atomic events.jsonl rewrite (write to .tmp, fsync, rename) preserved from
the prior CLI design — trigger is now HTTP POST.
+51 new tests (33 sessions + 18 dashboard-api). 149/149 total.