From 1af5bcbe871cc5f262c5c9a1a310d657b4a158d9 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 26 Apr 2026 06:14:15 +0300 Subject: [PATCH] docs(lethe-web-ui-foundation): import design handoff, add task file Bring in the React prototype bundle from the claude-design handoff (`docs/design_handoff_assistant_log/`) and the new task file (`docs/tasks/lethe-web-ui-foundation.md`) covering the foundation slice of the assistant-log web UI: scaffold, embed pipeline, shell, Home, and Session view. Backend additions to `/api/v1/sessions` (summary, turn_count, token totals, model) are part of this task; Projects, Stats, Search, Health, and Settings are split into deferred sibling tasks. --- .../Prototype.html | 208 +++++++ docs/design_handoff_assistant_log/README.md | 438 ++++++++++++++ .../original-spec.md | 95 +++ .../proto-atoms.jsx | 126 ++++ .../proto-data.jsx | 127 ++++ .../proto-home.jsx | 76 +++ .../proto-pages.jsx | 552 +++++++++++++++++ .../proto-palette.jsx | 91 +++ .../proto-search.jsx | 78 +++ .../proto-session.jsx | 76 +++ .../proto-shell.jsx | 46 ++ .../prototype.css | 560 ++++++++++++++++++ .../tweaks-panel.jsx | 419 +++++++++++++ docs/tasks/lethe-web-ui-foundation.md | 297 ++++++++++ 14 files changed, 3189 insertions(+) create mode 100644 docs/design_handoff_assistant_log/Prototype.html create mode 100644 docs/design_handoff_assistant_log/README.md create mode 100644 docs/design_handoff_assistant_log/original-spec.md create mode 100644 docs/design_handoff_assistant_log/proto-atoms.jsx create mode 100644 docs/design_handoff_assistant_log/proto-data.jsx create mode 100644 docs/design_handoff_assistant_log/proto-home.jsx create mode 100644 docs/design_handoff_assistant_log/proto-pages.jsx create mode 100644 docs/design_handoff_assistant_log/proto-palette.jsx create mode 100644 docs/design_handoff_assistant_log/proto-search.jsx create mode 100644 docs/design_handoff_assistant_log/proto-session.jsx create mode 100644 docs/design_handoff_assistant_log/proto-shell.jsx create mode 100644 docs/design_handoff_assistant_log/prototype.css create mode 100644 docs/design_handoff_assistant_log/tweaks-panel.jsx create mode 100644 docs/tasks/lethe-web-ui-foundation.md diff --git a/docs/design_handoff_assistant_log/Prototype.html b/docs/design_handoff_assistant_log/Prototype.html new file mode 100644 index 0000000000000000000000000000000000000000..7d9d8eb497d876ff123f47efecdf9208270165f0 --- /dev/null +++ b/docs/design_handoff_assistant_log/Prototype.html @@ -0,0 +1,208 @@ + + + + + + assistant-log + + + + + + + +
+ + + + + + + + + + + + + + diff --git a/docs/design_handoff_assistant_log/README.md b/docs/design_handoff_assistant_log/README.md new file mode 100644 index 0000000000000000000000000000000000000000..19c0f233387104ed1849e6b68841783309953a31 --- /dev/null +++ b/docs/design_handoff_assistant_log/README.md @@ -0,0 +1,438 @@ +# Handoff: assistant-log + +A self-hosted, single-user log aggregator for CLI AI assistants (Claude Code, opencode, crush, pi, kimi). Polls per-tool collectors on each host, normalizes turns/sessions/tools into a single timeline, exposes search + per-project + per-host views. + +This bundle ships **Direction 4 — "dense data UI"**: a GitHub/Linear-style information-dense interface, designed for power scanning. Tight rows, monospace data, sparklines, ⌘K palette, keyboard nav. + +--- + +## About the Design Files + +The files in this bundle are **design references created in HTML** — a clickable prototype showing the intended look, behavior, and information architecture. They are **not production code to copy directly**. + +The task is to **recreate these HTML designs in the target codebase's existing environment** — using its established patterns, component primitives, routing, and data layer. If no environment exists yet, the recommended stack is **React + TypeScript + Vite**, with state via Zustand or React Query, and CSS-modules or vanilla-CSS-with-tokens (the prototype uses raw CSS custom properties and that approach maps cleanly). + +The prototype uses inline JSX via Babel-standalone for portability — that is **not** how it should ship. + +--- + +## Fidelity + +**High-fidelity.** Colors, typography, spacing, layout, density, copy, keyboard map, and interactions are all final. Pixel-match the prototype. + +The prototype's mock data (`proto-data.jsx`) is illustrative — replace with real data from the backend, but **preserve the shapes** described in the [Data Shapes](#data-shapes) section below. + +--- + +## Files + +| Path | Purpose | +|---|---| +| `Prototype.html` | Entry point — sets up React, defines `App`, wires routing, keyboard nav, theme/density tweaks | +| `prototype.css` | All design tokens (`:root` and `[data-theme="dark"]`), every UI primitive's styles | +| `proto-shell.jsx` | `TopBar` (brand, search trigger, tab nav) and `RouterCtx` | +| `proto-atoms.jsx` | Shared primitives: `Tag`, `ToolDot`, `Spark` (inline-SVG sparkline), `StatusDot`, `Sub` (filter chip bar), `EmptyState` | +| `proto-data.jsx` | Mock data — sessions, turns, projects, hosts, collectors, tool colors | +| `proto-home.jsx` | Recent sessions screen (route `home`) | +| `proto-search.jsx` | Search results with FTS `` highlighting (route `search`) | +| `proto-session.jsx` | Single-session view: turn-list aside + linear transcript (route `session`) | +| `proto-pages.jsx` | Stats, Health, Projects index, Project view, Settings — all in one file | +| `proto-palette.jsx` | ⌘K command palette | +| `tweaks-panel.jsx` | In-design tweak panel (developer/preview tool — **do not ship**) | +| `original-spec.md` | The original direction spec we built from | + +--- + +## Top-level structure + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ TopBar ← dark, monospace, breadcrumb + search + tabs +├──────────────────────────────────────────────────────────────────┤ +│ Sub-bar (per route) ← filter chips, range, "since" controls +├──────────────────────────────────────────────────────────────────┤ +│ Body (flex: 1) ← the route's screen, scrolls +├──────────────────────────────────────────────────────────────────┤ +│ Footer strip (some routes only) ← e.g. Health backfill progress +└──────────────────────────────────────────────────────────────────┘ + Fixed-position elements: ⌘K palette overlay, Tweaks panel, hint chip +``` + +The shell never scrolls — only `.body` does. Top bar, sub-bar, and (where present) footer strip are `flex: none`. + +--- + +## Routes + +| Route name | Description | URL pattern (recommended) | +|---|---|---| +| `home` | Recent sessions across all projects/hosts | `/` | +| `search` | Search results with FTS highlights | `/search?q=…` | +| `session` | One session: turn list + transcript | `/session/:id` | +| `projects` | All projects, ranked by recent activity | `/projects` | +| `project` | One project (cwd) view | `/project/:cwd` | +| `stats` | Aggregate stats (per-tool, daily, hour-of-day, top-cwd, host split) | `/stats` | +| `health` | Collector ingestion health table | `/health` | +| `settings` | Settings (Sources / Display / Auth / Backup / Export / Tags / Saved searches) | `/settings/:section?` | + +The prototype implements routing with a single React state object `{name, ...args}` plus a `RouterCtx`. **Replace with the codebase's existing router** (React Router, TanStack Router, etc.). + +--- + +## Design Tokens + +All colors flow through CSS custom properties. Light mode is the `:root` block in `prototype.css`; dark mode is the `[data-theme="dark"]` block on ``. + +### Surfaces + +| Token | Light | Dark | Use | +|---|---|---|---| +| `--paper` | `#fdfcf8` | `#15140f` | Main body background | +| `--paper-2` | `#f3f0e7` | `#211f17` | Hover row, active tweak nav item | +| `--paper-3` | `#fbfaf2` | `#1a1813` | Sub-bar bg, table head bg, footer strip bg | +| `--paper-4` | `#ffffff` | `#1d1b14` | Card body, alternating-row "white" stripe | + +### Ink (text) + +| Token | Light | Dark | Use | +|---|---|---|---| +| `--ink` | `#1c1a17` | `#e8e3d4` | Primary text | +| `--ink-2` | `#4a453d` | `#b8b2a0` | Secondary text, USER role label | +| `--ink-3` | `#7a7468` | `#807a6b` | Muted text (column heads, meta) | +| `--ink-4` | `#b3ad9f` | `#4a4538` | Dim/placeholder, USER turn left-bar | + +### Rules + +| Token | Light | Dark | +|---|---|---| +| `--rule` | `#d8d3c4` | `#2c2920` | +| `--rule-2` | `#e6e1d2` | `#24221a` | + +### Top bar (always dark, even in light mode) + +| Token | Light | Dark | +|---|---|---| +| `--topbar-bg` | `#1c1a17` | `#0d0c08` | +| `--topbar-fg` | `#fdfcf8` | `#e8e3d4` | +| `--topbar-input-bg` | `rgba(255,255,255,0.06)` | `rgba(255,255,255,0.04)` | +| `--topbar-input-bd` | `rgba(255,255,255,0.15)` | `rgba(255,255,255,0.10)` | + +### Accent — locked to OKLCH hue 120 (lime-green) + +| Token | Light | Dark | Use | +|---|---|---|---| +| `--accent` | `oklch(0.78 0.18 120)` | `oklch(0.78 0.16 120)` | Active tab underline, accent chip bg, selected row left-border, FTS mark bg, sparkline highlight, ASSISTANT turn left-bar | +| `--accent-soft` | `oklch(0.94 0.05 120)` | `oklch(0.30 0.08 120)` | Selected row bg, palette active item bg, settings nav active bg | +| `--accent-ink` | `oklch(0.30 0.10 120)` | `oklch(0.86 0.16 120)` | Text **on** `--accent-soft`; ASSISTANT role label | +| `--accent-on` | `oklch(0.20 0.08 120)` | `oklch(0.18 0.06 120)` | Text **on** `--accent` (chip text, FTS mark text) | + +The four accent vars are also written **inline on ``** by the App effect — this is so the "Accent off" tweak can mute them at runtime. In production you can drop the inline writes if you don't need a runtime accent toggle. + +### Status + +| Token | Light | Dark | Use | +|---|---|---|---| +| `--ok` / `--ok-bg` | `#3b6e3b` / `#d6efd6` | `#6fb46f` / `#1f3327` | Healthy collector, "ok" tag | +| `--warn` / `--warn-bg` | `#c89a3a` / `#fdebcc` | `#d4a648` / `#3a2e15` | Warning state, schema drift | +| `--err` / `--err-bg` | `#b04a2a` / `#f5dccf` | `#d97052` / `#3a1f15` | Error, stale collector, last-error log | + +### Tag-specific + +| Token | Light | Dark | Use | +|---|---|---|---| +| `--tag-bg` | `#ebe7d8` | `#2a2820` | Default neutral tag bg | +| `--tag-host-bg` / `--tag-host-fg` | `#d9e6dd` / `#2f5a3f` | `#1f3327` / `#8fc09e` | Host tag (`laptop`, `workpc`) | + +### Turn surfaces (Session view) + +| Token | Light | Dark | +|---|---|---| +| `--turn-user` | `#f5f0e0` | `#1f1d15` | +| `--turn-asst` | `#ffffff` | `#15140f` | +| `--turn-tool` | `#f8f5e9` | `#211f17` | + +### Tool identity colors (constant across themes — these are brand-like per-tool) + +| Tool | Color | +|---|---| +| `claude-code` | `#c96442` (terracotta) | +| `opencode` | `#3b6e3b` (forest) | +| `crush` | `#7a4ea8` (purple) | +| `pi` | `#b8902a` (ochre) | +| `kimi` | `#2a6e9c` (steel-blue) | + +### Typography + +```css +--mono: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace; +--sans: 'Inter', system-ui, sans-serif; +``` + +**Type scale** — body text 12 px (`body`), data rows 11.5 px, table heads 10 px UPPERCASE 0.05 em tracking, transcript body 13 px, palette input 13 px. Line-height 1.4 throughout, 1.55 in transcript prose. **Mono is used for all data**: timestamps, paths, token counts, IDs, breadcrumbs, host names. Sans is for prose only (transcript bodies, settings copy, empty-state messages). + +### Sizing + +- Compact row height: 22–24 px (3 px vertical padding) +- Comfortable row height: 28–32 px (4 px vertical padding) +- **Compact is the default density** — set on the root via the class `density-compact` +- Card border-radius `4px`, palette `6px`, tags `3px`, status dots `99px` +- Sparkline: 60–140 × 12–18 px inline SVG ``, `stroke-width: 1.2` (1.4 when `.accent`) + +--- + +## Screens + +### 01 · Home (Recent) + +- **Sub-bar**: filter chips (`since: 30d`, `tool: any`, `host: any`, `+ filter`) +- **Table head**: `STARTED · TOOL · HOST · SUMMARY · TURNS · TOK · CWD` +- **Rows**: one per session. Cursor row gets `--accent-soft` bg + 2 px `--accent` left border (the row's `padding-left` shifts from 14 to 12 to compensate). +- **Empty filter result**: `EmptyState` with glyph `∅` and copy "no sessions match these filters" + +### 02 · Search results + +- **Sub-bar**: shows the active query as `""` in 12 px mono-bold + result count + "save as ★ saved search" action +- **Rows**: hit type (`turn` | `session` | `path`), session ref, snippet with `` wrapping query terms (lime bg, dark accent-on text) +- **Empty state**: glyph `⌕` and copy "no results — try a broader query" + +### 03 · Session view + +``` +┌────────────────────────────────────────────────────────┐ +│ Sub-bar: breadcrumb (~/cwd short-summary… tool/host/branch tags) +├────────────┬───────────────────────────────────────────┤ +│ Aside 240px│ Transcript (flex: 1, scrolls) │ +│ (turn list)│ │ +│ scrolls │ USER ← left-bar = --ink-4 │ +│ │ ASSISTANT← left-bar = --accent (lime) │ +│ │ TOOL ← no bar │ +│ │ │ +│ │ Each turn: 3 px left-border, role label │ +│ │ in mono-bold uppercase, meta line in │ +│ │ 10.5 px muted, body in 13 px prose │ +│ │ (or 11.5 px mono for tool turns) │ +└────────────┴───────────────────────────────────────────┘ +``` + +Aside row layout: `24px # · 14px role-glyph · 1fr preview · 36px tok` mono. Selected row gets `--paper-2` bg + 2 px accent left border. + +Tool turns are visually compressed — the "Show tool calls" tweak hides them entirely (`.hide-tool-calls .turn.tool { display: none }`). + +### 04 · Projects index + +Table head: `PROJECT (cwd) · SESSIONS · TURNS · LAST ACTIVE · HOSTS · TOOLS USED`. Hosts column shows `Tag.host` chips, tools column shows colored `ToolDot`s. + +### 05 · Project view + +Per-cwd view. If the cwd has no sessions, render the `EmptyState` with copy "no sessions in this project yet". Otherwise, sessions table identical to Home but scoped. + +### 06 · Stats + +A 2-column grid (`.stats-grid`) of cards, with one full-width card (`.full`): + +1. **Per-tool · last 30d** (full): table — tool dot, name, sessions, turns, tokens, sparkline, % share +2. **Daily turns · last 60d** (full): stacked-bar SVG chart, claude-code (accent) on bottom, opencode (forest) middle, crush (purple) top +3. **Activity heatmap · 12 weeks**: GitHub-style heatmap (84 cells, accent fill, opacity scaled by activity) +4. **Top cwd · 30d**: horizontal bar list — cwd in mono, accent bar, count right-aligned +5. **Hour-of-day distribution**: 24-bin bar chart +6. **Host split**: laptop vs workpc — mono % bars, host-specific colors + +### 07 · Health (ingestion) + +- **Sub-bar**: `collectors` label + status counters as tags (e.g., "● 7 ok", "● 1 warn") +- **Table head**: ` · HOST · TOOL · SOURCE PATH · LAG · OUT · LAST POLL · EVENTS` +- **Rows**: status dot (ok/warn/stale), host, tool tag with `ToolDot`, path mono-muted truncate, lag value (red `--err` if stale, yellow `--warn` if warn, muted otherwise), out-of-band events count (yellow if > 0), last-poll ago, lifetime events count +- **Footer strip**: backfill progress (`~3m`, mono path, % bar, % text) + last-error log line in `--err` color + +### 08 · Settings + +Two-column grid (`180px sidebar / 1fr content`). Sidebar items: `sources · display · auth · backup · export · tags · saved searches`. Active item: `--accent-soft` bg + `--accent-ink` color + 600 weight. + +The prototype only ships **Sources** (per-host-per-tool config table with status, path, poll interval, events count) and **Display** (mirrors the in-design tweaks) at full fidelity. Auth/Backup/Export/Tags/Saved searches are placeholder cards — flesh out per real backend. + +--- + +## Interactions & Behavior + +### Keyboard map + +| Key | Where | Action | +|---|---|---| +| `⌘K` / `Ctrl+K` | global | open command palette | +| `Esc` | palette open | close palette | +| `↑` / `↓` | palette open | move palette cursor | +| `↵` | palette open | activate palette item | +| `j` | list routes (home, projects, project, search, health) | move row cursor down | +| `k` | list routes | move row cursor up | +| `↵` | home | open session at cursor | +| `g h` | global | go to Home | +| `g p` | global | go to Projects | +| `g s` | global | go to Stats | +| `g i` | global | go to Health (i = ingestion) | + +The `g` leader has an 800 ms timeout. Implement it as a ref + setTimeout pair (see `App.onKeyDown` in `Prototype.html`). + +### Filter chips + +Each chip is `` showing `key: value`. Click → popover opens at chip's position with possible values. `+ filter` at the end opens a popover listing dimensions to add. `×` on a chip removes it. The popover is absolute-positioned, `z-index: 40`. Closes on outside click / Esc. + +**Dimensions to support**: `tool`, `host`, `since` (1d/7d/30d/90d/all), `cwd`, `model`, `has-tool-calls`. (The prototype only wires the first three; add the rest to match the spec.) + +### ⌘K command palette + +- Modal scrim (`var(--scrim)`), 80 px from top, 620 × 480 max +- Single input at top (mono, 13 px); list below with cursor highlighting +- Items have a `kind` prefix (uppercase mono, 70 px wide column): `JUMP`, `PROJECT`, `SESSION`, `SEARCH` +- When the query is non-empty and doesn't match any item, append a synthetic `SEARCH for ""` row that goes to `/search?q=…` +- Keyboard: `↑↓` cursor, `↵` activate, `Esc` close +- Footer: keyboard hints in mono (`↑↓ navigate · ↵ open · esc close`) + +### Active row cursor + +In list routes, `j`/`k` move a cursor index; the cursor row gets `cursor` class → `--accent-soft` bg + `--accent` left border. On `↵` (home only in the prototype — extend to other list routes in production), navigate to the cursored item. + +### Tweaks panel + +The `` from `tweaks-panel.jsx` is a **design-time tool only**. It is the in-design controls for theme, density, accent, etc. **Strip it from production**. The actual settings live under the Settings → Display route. + +The runtime knobs that should remain user-visible in production: +- **Theme** (light/dark) — persist to localStorage, default to `prefers-color-scheme` +- **Density** (compact/comfortable) — persist +- **Show tool calls** (per-session toggle, also persisted globally) + +The "Accent on/off" and per-component tweaks were exploration controls; do not surface them. + +--- + +## State Management + +### Top-level App state (in `Prototype.html`) + +```js +{ + route: { name, ...args }, // home | search | session | … + paletteOpen: boolean, + cursor: number, // active row index in list routes + query: string, // search query (mirrored in URL) + tweaks: { + density: 'compact' | 'comfortable', + showToolCalls: boolean, + accent: boolean, + theme: 'light' | 'dark', + }, +} +``` + +Replace with the codebase's normal store (Zustand, Redux, signals, etc.). + +### Server-state queries (recommended React Query keys) + +``` +['sessions', { since, tool, host, cwd, search? }] → home / project / search +['session', id] → session view +['projects', { since }] → projects index +['stats', { range }] → stats screen +['health'] → collectors + last-poll +['settings', 'sources'] → per-host/tool config +``` + +All queries should support reactive invalidation when the user changes filters in the sub-bar. + +--- + +## Data Shapes + +These are the shapes the prototype consumes (see `proto-data.jsx`). The real backend should serve compatible JSON. + +```ts +type Tool = 'claude-code' | 'opencode' | 'crush' | 'pi' | 'kimi'; +type Host = 'laptop' | 'workpc'; +type Role = 'user' | 'assistant' | 'tool'; + +interface Session { + id: string; // e.g. 'inflection-3' + tool: Tool; + host: Host; + cwd: string; // e.g. '~/code/tt-bundle' + branch?: string; // git branch when session started + model: string; // e.g. 'claude-opus-4-7' + started: string; // ISO timestamp + ended: string | null; + summary: string; // first user turn, truncated + turns: number; + tokensIn: number; + tokensOut:number; + hasError: boolean; +} + +interface Turn { + i: number; // 1-indexed within session + role: Role; + body: string; // markdown for user/assistant; mono content for tool + tokensIn?: number; // assistant turns only + tokensOut?: number; + toolName?: string; // tool turns: 'read', 'grep', 'bash', 'edit', 'write', … + toolKind?: 'call' | 'result'; +} + +interface Project { + cwd: string; + sessions: number; + turns: number; + lastActive: string; + hosts: Host[]; + tools: Tool[]; +} + +interface Collector { + host: Host; + tool: Tool; + sourcePath: string; // path the collector polls + status: 'ok' | 'warn' | 'stale'; + lagSeconds: number; // how far behind real-time + outOfBand: number; // events received that didn't parse + lastPoll: string; // relative, e.g. '7s ago' + events: number; // lifetime + lastError?: { ts: string; msg: string }; +} +``` + +--- + +## Theming + +The prototype supports light + dark. `` switches the entire token set. Implement: + +1. Read `prefers-color-scheme` on first load +2. Persist user choice to `localStorage['theme']` +3. Apply `data-theme` attribute on `` (NOT `` — the `[data-theme]` selector targets `html`) +4. Listen for OS theme changes when no user override is set + +The accent vars (`--accent`, `--accent-soft`, `--accent-ink`, `--accent-on`) are written inline on `` by the App effect (so an "accent off" toggle could mute them at runtime). In production you can drop the inline writes — the CSS rules suffice. + +--- + +## Production checklist + +- [ ] Replace `proto-data.jsx` mock with real API/queries +- [ ] Replace inline-React + Babel-standalone setup with the codebase's normal build (Vite/Next/etc.) +- [ ] Strip `tweaks-panel.jsx` and the `useTweaks` plumbing +- [ ] Wire real router (`/`, `/search?q=…`, `/session/:id`, `/project/:cwd`, `/stats`, `/health`, `/settings/:section?`) +- [ ] Implement remaining filter dimensions (`cwd`, `model`, `has-tool-calls`) — popover already supports them, just unstub +- [ ] Add real saved-searches store (currently a stub link) +- [ ] Build out Settings → Auth, Backup, Export, Tags, Saved searches (placeholder cards in prototype) +- [ ] Persist `theme`, `density`, `showToolCalls` to localStorage; sync `theme` with `prefers-color-scheme` on first load +- [ ] Wire `↵` on cursored row across all list routes (Home is the only one wired in the prototype) +- [ ] Real FTS in the search route — the prototype does substring matching against in-memory data +- [ ] Confirm the per-tool color palette matches your branding (terracotta/forest/purple/ochre/steel-blue) — these are constant across themes +- [ ] Decide whether the top bar stays "always dark" in light mode (the prototype keeps it dark for visual continuity); easy to flip via `--topbar-bg` / `--topbar-fg` + +--- + +## Notes for the implementer + +- **Density first**: "compact" is the default. Don't loosen padding "for accessibility" — this UI is for terminal-dwellers who want max info per screen. Comfortable mode exists for users who explicitly opt in. +- **Mono for data, sans for prose**. Never mix. If you find yourself rendering a number, path, ID, or timestamp in sans, that's a bug. +- **Accent is a spotlight, not a coat of paint**. Use it for: active tab, primary chip, sparkline highlight, FTS hit, ASSISTANT turn left-bar, cursor row, "stale" indicators in Health. That's the full set. +- **No tooltips for column heads**. The headers are uppercase 10 px mono; if a column needs explanation, the column itself is wrong. +- **Tool turns are evidence, not conversation**. Render them in mono, smaller, dimmer, and let users hide them entirely. diff --git a/docs/design_handoff_assistant_log/original-spec.md b/docs/design_handoff_assistant_log/original-spec.md new file mode 100644 index 0000000000000000000000000000000000000000..c22c5fe97ac5576a707736a65dacb9fea12e4dcf --- /dev/null +++ b/docs/design_handoff_assistant_log/original-spec.md @@ -0,0 +1,95 @@ +# Direction 4 · Dense Data UI — `assistant-log` + +## The product + +A self-hosted, single-user log aggregator for CLI AI assistants (Claude Code, opencode, crush, pi, kimi). Server runs on a home server (`phoebe`) behind Tailscale; collectors run as systemd user units on each machine and POST normalized turns to SQLite + FTS5. Server-rendered HTML UI, no SPA. Single user. Full transcripts of every session across every tool across every machine, searchable in one place. + +## Aesthetic + personality + +GitHub/Linear-style dense data UI for power scanning. Built on the assumption the user lives in terminals all day and wants information density, not chrome. + +- **Top bar**: dark (`#1c1a17`), monospace, contains breadcrumb (`assistant-log / scarlet`), an always-visible search input that responds to ⌘K, and tab nav (Recent / Projects / Stats / Health / Settings). +- **Body**: warm off-white (`#fdfcf8`), Inter for prose, JetBrains Mono for all data (timestamps, paths, tokens, IDs). +- **Accent**: terracotta `#c96442`, used sparingly — selected tab, primary chip, sparkline highlight, FTS hit highlight, "stale" warnings. +- **Type scale**: 11–13 px body. Tight (1.4) line-height. Uppercase 10 px column headers with 0.05 em tracking and `var(--ink-3)` color. +- **Rows**: 28–32 px tall (compact mode 22–24), 1 px `--rule-2` dividers, hover `#f3f0e7`. +- **Tags**: small mono pills — neutral `#ebe7d8`, host-green `#d9e6dd`, accent terracotta. Per-tool a 8 px square color dot (`claude-code` terracotta, `opencode` green, `crush` purple, `pi` ochre, `kimi` blue). +- **Sparklines**: 60–140 × 12–18 px inline SVG polylines, stroke 1.2, accent-colored when row is "the user's main thing". + +## Layout pattern (every page) + +1. Dark mono top bar (search + nav). +2. Light sub-bar with active filters as removable chips, plus a contextual stat strip on the right (e.g. turns/day sparkline + total). +3. Tight grid header row (uppercase mono labels). +4. Scrollable rows with `display: grid; grid-template-columns: …`, mono in time/path/numeric columns, sans in prose columns. +5. Optional footer strip for global state (backfill progress, last error). + +## Screens + +1. **Home (Recent)** — Reverse-chronological feed of sessions across all tools/hosts. Columns: when · tool tag · host tag · session question + cwd · turns · tok · turns/h sparkline. Top sub-bar carries `tool: any · host: any · since: 30d` chips and a global turns/day sparkline. + +2. **Search results** — Results are a flat table of *turns* (not sessions), one match per row, with the snippet truncated and FTS hits wrapped in a terracotta ``. Filters in chips above. Save-search action lives next to the chip row. + +3. **Session view** — Two-column. Left aside (240 px) is a turn list with `# · role glyph · preview · token count`, current turn highlighted with a left accent border. Right pane is the linear transcript: each turn is a row separated by 1 px rules, role label is mono uppercase color-coded (`USER` neutral, `ASSISTANT` accent, `TOOL` muted), tool calls render as monospace cards with a faint background tint. Sticky header carries `cwd · title · tool tag · host tag · model · turn count · tokens · open dot`. + +4. **Project view (cwd)** — Header shows `~/code/` breadcrumb + `tt-bundle` in mono, count chips, and a sparkline of activity. Saved-search chips below. Then the same dense session table filtered to that cwd. + +5. **Stats** — Top sub-bar has range pills (7d / 30d / 90d / all) and group-by pills (tool / host / project / model). Content is a grid of cards: per-tool stat strip (turns / tokens / cost / sparkline / share %), a stacked turns/day chart (60 days), a 12-week activity heatmap, and a top-cwd bar list. + +6. **Ingestion health** — Status pills in sub-bar (`● 7 ok · ● 1 warn`). Table of every (host, tool, source) row with: status dot · host · tool · source path · lag · outbox depth · last-OK · events 24h. Stale rows get accent-colored lag, non-empty outboxes get accent-colored count. Footer strip: backfill progress bar + last parser error inline. + +7. **Settings** — Sidebar nav (Sources / Display / Auth / Backup / Export / Tags / Saved searches). Sources page is the dense source table with status dot, tool tag, path, poll interval, event count, last-OK, edit. Display has compact tag-pair toggles (density, tool calls, accent). + +## Interaction model + +- Always-on global search (⌘K focuses the top input from any page). +- Filter chips are removable and additive; "+ add filter" opens a small popover. +- All paths are clickable and open the corresponding Project view. +- Each row in any list is clickable and opens its session/turn permalink. +- Keyboard: `j/k` move row, `↵` open, `g h/s/p/i` jump to home / stats / projects / health. + +## Mock data shape + +**Sample sessions** (across `claude-code`, `opencode`, `crush`, `pi`, `kimi` on `laptop` and `workpc`): + +| time | tool | host | cwd | first prompt | turns | tok | model | +|--------|-------------|--------|----------------------------------|-----------------------------------------------------------------------------|-------|-------|------------------| +| 14:22 | claude-code | laptop | ~/code/tt-bundle | lockfile design for the tt bundle — should we hash inputs or outputs? | 84 | 12.4k | claude-opus-4-7 | +| 11:08 | opencode | workpc | ~/work/atelier/migrations | why does the v3 migration drop the partial index on (org, ts) | 12 | 2.1k | gpt-5-mini | +| 10:52 | claude-code | workpc | ~/work/scarlet-svc | is there a way to make this retry loop tolerate 429s without a sleep | 41 | 7.7k | claude-opus-4-7 | +| 09:14 | crush | laptop | ~/code/dotfiles | rewrite this zsh prompt to show ssh host only when on a remote | 6 | 0.9k | gemini-2-flash | +| Yest | pi | laptop | ~/notes | help me draft a one-pager for the offsite session on incident response | 19 | 4.0k | inflection-3 | +| Yest | claude-code | laptop | ~/code/tt-bundle | when do we need to re-resolve transitive deps after a lock bump | 28 | 5.3k | claude-opus-4-7 | +| Yest | kimi | workpc | ~/work/atelier | translate these comments to english but keep the tone | 4 | 0.7k | kimi-k2 | + +**Ingestion health** — 8 collector rows (one per host × tool): + +| host | tool | source | lag | outbox | last ok | status | +|--------|-------------|---------------------------------|-----|--------|---------|--------| +| laptop | claude-code | ~/.claude/projects | 4s | 0 | now | ok | +| laptop | opencode | ~/.local/share/opencode | 12s | 0 | 12s | ok | +| laptop | crush | ~/.cache/crush | 28s | 0 | 28s | ok | +| laptop | pi | ~/.config/pi/history | 1m | 0 | 1m | ok | +| workpc | claude-code | ~/.claude/projects | 7s | 0 | now | ok | +| workpc | opencode | ~/.local/share/opencode | 18s | 0 | 18s | ok | +| workpc | kimi | ~/.kimi-cli/history.jsonl | 2m | 127 | 2m | warn | +| workpc | crush | ~/.cache/crush | 8m | 0 | 8m | stale | + +**Per-tool 30-day rollups:** + +- claude-code — 1820 turns / 482k tok / $0 (Max sub, cost unknowable) +- opencode — 211 turns / 18k tok / $4.12 +- crush — 64 turns / 2k tok / $0.71 +- pi — 32 turns / 7k tok / $0 +- kimi — 11 turns / 1k tok / $0 + +## Tech assumptions for the build + +- React 18.3.1 + Babel standalone for the prototype. +- Inter + JetBrains Mono via Google Fonts. +- All data is hard-coded mock data. No real backend. +- Single HTML file with split JSX modules per screen for maintainability. + +## Suggested first prompt for the new chat + +> Build a hi-fi clickable prototype of a personal CLI AI assistant log aggregator UI in the **Dense Data UI** style described in the attached spec. One HTML file. Real navigation between screens (top-bar tabs, row clicks, ⌘K search focus, filter chips). Cover Home / Search / Session / Project / Stats / Health / Settings. Use the mock data shape and visual system specified. diff --git a/docs/design_handoff_assistant_log/proto-atoms.jsx b/docs/design_handoff_assistant_log/proto-atoms.jsx new file mode 100644 index 0000000000000000000000000000000000000000..fc0e7d6a47780d5118dc4eaea1f93a355cd91ec6 --- /dev/null +++ b/docs/design_handoff_assistant_log/proto-atoms.jsx @@ -0,0 +1,126 @@ +// Atoms reused across screens + +const Spark = ({ w = 60, h = 16, accent = false, seed = 1 }) => { + const pts = []; + let v = h * 0.5; + for (let i = 0; i < 14; i++) { + v += (Math.sin(seed * i * 1.7) + Math.cos(seed * i * 0.9)) * 2.5; + v = Math.max(2, Math.min(h - 2, v)); + pts.push(`${(i / 13) * (typeof w === 'number' ? w : 100)},${v.toFixed(1)}`); + } + return ( + + + + ); +}; + +const ToolDot = ({ tool }) => ( + +); + +const ToolTag = ({ tool }) => ( + {tool} +); + +const HostTag = ({ host }) => {host}; + +const StatusDot = ({ status }) => ; + +// Filter chip popover +const FilterPopover = ({ dim, value, onPick, onClose, anchor }) => { + const ref = React.useRef(null); + React.useEffect(() => { + const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); }; + setTimeout(() => document.addEventListener('mousedown', onDoc), 0); + return () => document.removeEventListener('mousedown', onDoc); + }, [onClose]); + const opts = FILTER_DIMS[dim] || []; + const top = (anchor?.top || 30) + 4; + const left = anchor?.left || 14; + return ( +
+
{dim}
+ {opts.map(o => ( +
{ onPick(o); onClose(); }}> + + {o} +
+ ))} +
+ ); +}; + +// Removable filter chip with click-through to popover +const FilterChip = ({ dim, value, onChange, onRemove }) => { + const [open, setOpen] = React.useState(false); + const [anchor, setAnchor] = React.useState(null); + const ref = React.useRef(null); + return ( + <> + { + const r = ref.current.getBoundingClientRect(); + const parent = ref.current.closest('.app').getBoundingClientRect(); + setAnchor({ top: r.bottom - parent.top, left: r.left - parent.left }); + setOpen(true); + }}> + {dim}: {value} + {onRemove && ( + { e.stopPropagation(); onRemove(); }}>× + )} + + {open && setOpen(false)} />} + + ); +}; + +// Add-filter dashed chip + popover for which dim +const AddFilterChip = ({ availableDims, onAdd }) => { + const [open, setOpen] = React.useState(false); + const [anchor, setAnchor] = React.useState(null); + const ref = React.useRef(null); + return ( + <> + { + const r = ref.current.getBoundingClientRect(); + const parent = ref.current.closest('.app').getBoundingClientRect(); + setAnchor({ top: r.bottom - parent.top, left: r.left - parent.left }); + setOpen(true); + }}>+ filter + {open && ( +
setOpen(false)}> +
add filter
+ {availableDims.map(d => ( +
{ onAdd(d); setOpen(false); }}> + {d} +
+ ))} +
+ )} + + ); +}; + +const Empty = ({ glyph = '∅', title, hint }) => ( +
+
{glyph}
+
{title}
+ {hint &&
{hint}
} +
+); + +Object.assign(window, { + Spark, ToolDot, ToolTag, HostTag, StatusDot, + FilterPopover, FilterChip, AddFilterChip, Empty, +}); diff --git a/docs/design_handoff_assistant_log/proto-data.jsx b/docs/design_handoff_assistant_log/proto-data.jsx new file mode 100644 index 0000000000000000000000000000000000000000..17b25228e2031311b8e151abf6a703e94674ca07 --- /dev/null +++ b/docs/design_handoff_assistant_log/proto-data.jsx @@ -0,0 +1,127 @@ +// Mock data for the prototype + +const TOOLS = ['claude-code', 'opencode', 'crush', 'pi', 'kimi']; +const HOSTS = ['laptop', 'workpc']; + +// Authenticated identity. Server derives `owner` from this on every ingest; +// the wire format has no `owner` field. Admins can switch with ?owner= +const ME = { user: 'bigbes', isAdmin: true, via: 'forward-auth' }; + +// Other owners (visible only when current user is admin and ?owner=* is set) +const OWNERS = [ + { user: 'bigbes', sessions: 121, ktok: 482, last: 'Today 14:22', via: 'forward-auth' }, + { user: 'rin', sessions: 34, ktok: 61, last: 'Today 09:51', via: 'oidc-bearer' }, + { user: 'noor', sessions: 18, ktok: 22, last: 'Apr 23 18:14', via: 'oidc-bearer' }, +]; +const TOOL_COLORS = { + 'claude-code': '#c96442', + 'opencode': '#3b6e3b', + 'crush': '#7a4ea8', + 'pi': '#b8902a', + 'kimi': '#2a6e9c', +}; + +const SESSIONS = [ + { id: 'a1', tool: 'claude-code', host: 'laptop', cwd: '~/code/tt-bundle', q: 'lockfile design for the tt bundle — should we hash inputs or outputs?', turns: 84, tok: '12.4k', when: 'Today 14:22', day: 0, model: 'claude-opus-4-7' }, + { id: 'a2', tool: 'opencode', host: 'workpc', cwd: '~/work/atelier/migrations', q: 'why does the v3 migration drop the partial index on (org, ts)', turns: 12, tok: '2.1k', when: 'Today 11:08', day: 0, model: 'gpt-5-mini' }, + { id: 'a3', tool: 'claude-code', host: 'workpc', cwd: '~/work/scarlet-svc', q: 'is there a way to make this retry loop tolerate 429s without a sleep', turns: 41, tok: '7.7k', when: 'Today 10:52', day: 0, model: 'claude-opus-4-7' }, + { id: 'a4', tool: 'crush', host: 'laptop', cwd: '~/code/dotfiles', q: 'rewrite this zsh prompt to show ssh host only when on a remote', turns: 6, tok: '0.9k', when: 'Today 09:14', day: 0, model: 'gemini-2-flash' }, + { id: 'a5', tool: 'pi', host: 'laptop', cwd: '~/notes', q: 'help me draft a one-pager for the offsite session on incident response', turns: 19, tok: '4.0k', when: 'Yest. 22:01', day: 1, model: 'inflection-3' }, + { id: 'a6', tool: 'claude-code', host: 'laptop', cwd: '~/code/tt-bundle', q: 'when do we need to re-resolve transitive deps after a lock bump', turns: 28, tok: '5.3k', when: 'Yest. 18:40', day: 1, model: 'claude-opus-4-7' }, + { id: 'a7', tool: 'kimi', host: 'workpc', cwd: '~/work/atelier', q: 'translate these comments to english but keep the tone', turns: 4, tok: '0.7k', when: 'Yest. 15:12', day: 1, model: 'kimi-k2' }, + { id: 'a8', tool: 'claude-code', host: 'workpc', cwd: '~/work/scarlet-svc', q: 'add a structured logging adapter for the worker pool', turns: 22, tok: '4.8k', when: 'Yest. 11:30', day: 1, model: 'claude-opus-4-7' }, + { id: 'a9', tool: 'opencode', host: 'workpc', cwd: '~/work/atelier/migrations', q: 'rollback strategy for v3 if we hit prod with the wrong index', turns: 8, tok: '1.4k', when: 'Apr 23 16:42', day: 2, model: 'gpt-5-mini' }, + { id: 'a10', tool: 'claude-code', host: 'laptop', cwd: '~/code/tt-bundle', q: 'sketch a content-addressed cache for the build outputs', turns: 51, tok: '9.1k', when: 'Apr 23 09:18', day: 2, model: 'claude-opus-4-7' }, + { id: 'a11', tool: 'pi', host: 'laptop', cwd: '~/notes', q: 'expand my standup notes into a proper status email', turns: 3, tok: '0.5k', when: 'Apr 22 19:00', day: 3, model: 'inflection-3' }, + { id: 'a12', tool: 'crush', host: 'laptop', cwd: '~/code/dotfiles', q: 'fix this nvim mapping that breaks in tmux', turns: 4, tok: '0.6k', when: 'Apr 22 13:22', day: 3, model: 'gemini-2-flash' }, + { id: 'a13', tool: 'claude-code', host: 'workpc', cwd: '~/work/scarlet-svc', q: 'redo the connection pool to support graceful drain', turns: 38, tok: '6.9k', when: 'Apr 21 14:00', day: 4, model: 'claude-opus-4-7' }, + { id: 'a14', tool: 'claude-code', host: 'laptop', cwd: '~/code/tt-bundle', q: 'why does build.zig double-resolve when I touch only README', turns: 17, tok: '3.4k', when: 'Apr 21 10:11', day: 4, model: 'claude-opus-4-7' }, + { id: 'a15', tool: 'opencode', host: 'workpc', cwd: '~/work/atelier', q: 'is there a cleaner way to express this state machine', turns: 11, tok: '1.8k', when: 'Apr 20 17:42', day: 5, model: 'gpt-5-mini' }, +]; + +// Project rollups +const PROJECTS = [ + { cwd: '~/code/tt-bundle', sessions: 41, tok: '213k', last: 'Today 14:22', topTool: 'claude-code', tok30: 213 }, + { cwd: '~/work/scarlet-svc', sessions: 28, tok: '142k', last: 'Today 10:52', topTool: 'claude-code', tok30: 142 }, + { cwd: '~/work/atelier/migrations', sessions: 14, tok: '38k', last: 'Today 11:08', topTool: 'opencode', tok30: 38 }, + { cwd: '~/code/dotfiles', sessions: 7, tok: '4.1k', last: 'Today 09:14', topTool: 'crush', tok30: 4.1 }, + { cwd: '~/notes', sessions: 5, tok: '11k', last: 'Yest 22:01', topTool: 'pi', tok30: 11 }, + { cwd: '~/work/atelier', sessions: 4, tok: '2.0k', last: 'Yest 15:12', topTool: 'kimi', tok30: 2.0 }, +]; + +// Per-tool rollups (30d) +const TOOL_ROLLUPS = [ + { tool: 'claude-code', turns: 1820, ktok: 482, cost: 0, share: 0.92 }, + { tool: 'opencode', turns: 211, ktok: 18, cost: 4.12, share: 0.18 }, + { tool: 'crush', turns: 64, ktok: 2, cost: 0.71, share: 0.08 }, + { tool: 'pi', turns: 32, ktok: 7, cost: 0, share: 0.05 }, + { tool: 'kimi', turns: 11, ktok: 1, cost: 0, share: 0.02 }, +]; + +// Health collectors +const COLLECTORS = [ + { host: 'laptop', tool: 'claude-code', src: '~/.claude/projects', poll: '30s', lag: '4s', out: 0, last: 'now', ev: 412, status: 'ok' }, + { host: 'laptop', tool: 'opencode', src: '~/.local/share/opencode', poll: '30s', lag: '12s', out: 0, last: '12s', ev: 38, status: 'ok' }, + { host: 'laptop', tool: 'crush', src: '~/.cache/crush', poll: '60s', lag: '28s', out: 0, last: '28s', ev: 9, status: 'ok' }, + { host: 'laptop', tool: 'pi', src: '~/.config/pi/history', poll: '60s', lag: '1m', out: 0, last: '1m', ev: 7, status: 'ok' }, + { host: 'workpc', tool: 'claude-code', src: '~/.claude/projects', poll: '30s', lag: '7s', out: 0, last: 'now', ev: 228, status: 'ok' }, + { host: 'workpc', tool: 'opencode', src: '~/.local/share/opencode', poll: '30s', lag: '18s', out: 0, last: '18s', ev: 54, status: 'ok' }, + { host: 'workpc', tool: 'kimi', src: '~/.kimi-cli/history.jsonl', poll: '60s', lag: '2m', out: 127, last: '2m', ev: 3, status: 'warn' }, + { host: 'workpc', tool: 'crush', src: '~/.cache/crush', poll: '60s', lag: '8m', out: 0, last: '8m', ev: 0, status: 'stale' }, +]; + +// Long sample turns for session view +const TURNS = [ + { role: 'user', text: 'lockfile design for the tt bundle — should we hash inputs or outputs?' }, + { role: 'assistant', text: "Both, but for different reasons. Input hashes give you the 'should we rebuild' signal; output hashes give you the 'is this artifact equivalent' signal. The input hash lives in the lockfile next to each module entry; the output hash is computed post-build and stored in the artifact index.", model: 'claude-opus-4-7', tIn: 412, tOut: 980 }, + { role: 'tool', text: 'Read("~/code/tt-bundle/build.zig")', kind: 'read' }, + { role: 'tool', text: '→ 14.2 KB, 318 lines', kind: 'result' }, + { role: 'assistant', text: "Looking at your current build.zig, the input set is already explicit — module list, compiler flags, target triple. So input-hashing is straightforward: sha256 over the canonical-JSON encoding of (module_list, flags, target).", model: 'claude-opus-4-7', tIn: 530, tOut: 1240 }, + { role: 'user', text: 'ok so what about transitive deps that come from a vendored dir' }, + { role: 'assistant', text: "Transitive deps need a separate manifest pass. The lockfile records the *resolved* tree, not the declared one — so when you bump a direct dep you re-resolve and rewrite the transitive section. The cache key is sha256(direct + transitive sorted).", model: 'claude-opus-4-7', tIn: 88, tOut: 760 }, + { role: 'tool', text: 'Grep("vendor/", glob: "*.zig")', kind: 'grep' }, + { role: 'tool', text: '→ 47 matches across 12 files', kind: 'result' }, + { role: 'assistant', text: "Three of those are header-only style — they don't have their own deps. The other nine each declare 1–4 transitives. So your lock should expand to ~30 entries on first resolve.", model: 'claude-opus-4-7', tIn: 220, tOut: 540 }, + { role: 'user', text: 'how would the cache key look if I bumped one transitive but nothing else changed' }, + { role: 'assistant', text: "sha256 over the sorted list, so any change ripples up — but only the affected output keys flip. Direct deps that didn't move keep their input hash; their *output* hash also doesn't move because output depends on the resolved-tree slice they actually consume.", model: 'claude-opus-4-7', tIn: 110, tOut: 880 }, +]; + +// Saved searches per project (for tt-bundle) +const SAVED_SEARCHES = ['lockfile', 'retry/backoff', 'vendored deps', 'build.zig', 'transitive']; + +// Filter dimensions for popover +const FILTER_DIMS = { + tool: ['any', 'claude-code', 'opencode', 'crush', 'pi', 'kimi'], + host: ['any', 'laptop', 'workpc'], + since: ['24h', '7d', '30d', '90d', 'all'], + model: ['any', 'claude-opus-4-7', 'gpt-5-mini', 'gemini-2-flash', 'inflection-3', 'kimi-k2'], +}; + +// Auth config (mirrors internal/config Auth substruct) +const AUTH_CONFIG = { + bind: '127.0.0.1:8401', + allowedUsers: ['bigbes', 'rin', 'noor'], + admins: ['bigbes'], + forwardAuth: { enabled: true, userHeader: 'Remote-User' }, + oidc: { enabled: true, + issuer: 'https://auth.example.com', + audience: 'lethe', + usernameClaim: 'preferred_username', + jwksLastFetch: '4m ago' }, +}; + +// Recent auth events for the Auth settings panel +const AUTH_EVENTS = [ + { t: '14:22:14', user: 'bigbes', via: 'forward-auth', path: 'GET /api/v1/sessions', status: 200 }, + { t: '14:18:02', user: 'rin', via: 'oidc-bearer', path: 'POST /api/v1/ingest', status: 200 }, + { t: '14:11:48', user: 'rin', via: 'oidc-bearer', path: 'GET /api/v1/sessions/.../...', status: 200 }, + { t: '13:54:30', user: '—', via: 'oidc-bearer', path: 'POST /api/v1/ingest', status: 401, note: 'expired jwt' }, + { t: '13:40:19', user: 'noor', via: 'oidc-bearer', path: 'GET /api/v1/sessions?owner=*', status: 403, note: 'admin-only param' }, + { t: '13:02:11', user: 'bigbes', via: 'forward-auth', path: 'GET /api/v1/sessions?owner=rin', status: 200 }, +]; + +Object.assign(window, { + TOOLS, HOSTS, TOOL_COLORS, SESSIONS, PROJECTS, TOOL_ROLLUPS, COLLECTORS, + TURNS, SAVED_SEARCHES, FILTER_DIMS, + ME, OWNERS, AUTH_CONFIG, AUTH_EVENTS, +}); diff --git a/docs/design_handoff_assistant_log/proto-home.jsx b/docs/design_handoff_assistant_log/proto-home.jsx new file mode 100644 index 0000000000000000000000000000000000000000..339741da8ae78f2425aac575d995e1e5c07cdf08 --- /dev/null +++ b/docs/design_handoff_assistant_log/proto-home.jsx @@ -0,0 +1,76 @@ +// Home (Recent) — feed of sessions across tools/hosts + +const HomeScreen = () => { + const { go, cursor, setCursor } = useRouter(); + const [filters, setFilters] = React.useState({ + tool: 'any', host: 'any', since: '30d', + }); + const [activeFilters, setActiveFilters] = React.useState(['tool', 'host', 'since']); + + const removeFilter = (k) => + setActiveFilters(fs => fs.filter(f => f !== k)); + const addFilter = (k) => + setActiveFilters(fs => fs.includes(k) ? fs : [...fs, k]); + const setF = (k, v) => + setFilters(f => ({ ...f, [k]: v })); + + const rows = SESSIONS.filter(s => { + if (filters.tool !== 'any' && activeFilters.includes('tool') && s.tool !== filters.tool) return false; + if (filters.host !== 'any' && activeFilters.includes('host') && s.host !== filters.host) return false; + return true; + }); + + const cols = '120px 110px 70px 1fr 50px 60px 90px'; + + return ( + <> +
+ {activeFilters.map(k => ( + setF(k, v)} + onRemove={() => removeFilter(k)} /> + ))} + !activeFilters.includes(d))} + onAdd={addFilter} /> + + turns/day + + 2,138 + · 30d +
+ +
+ whentoolhostsession · cwd + turnstokturns/h +
+
+ {rows.length === 0 ? ( + + ) : rows.map((s, i) => ( +
go({ name: 'session', id: s.id })}> + {s.when} + + + + {s.q} + { e.stopPropagation(); go({ name: 'project', cwd: s.cwd }); }}> + {s.cwd} + + + {s.turns} + {s.tok} + +
+ ))} +
+ + ); +}; + +Object.assign(window, { HomeScreen }); diff --git a/docs/design_handoff_assistant_log/proto-pages.jsx b/docs/design_handoff_assistant_log/proto-pages.jsx new file mode 100644 index 0000000000000000000000000000000000000000..11f861629f0edacfd702f1ce035779b4742e225f --- /dev/null +++ b/docs/design_handoff_assistant_log/proto-pages.jsx @@ -0,0 +1,552 @@ +// Projects index, single project, stats, health, settings + +const ProjectsScreen = () => { + const { go } = useRouter(); + const cols = '1fr 90px 90px 110px 110px 90px'; + return ( + <> +
+ {PROJECTS.length} projects · ranked by recent activity + + {}} /> +
+
+ cwdsessionstok + lasttop toolactivity +
+
+ {PROJECTS.map((p, i) => ( +
go({ name: 'project', cwd: p.cwd })}> + {p.cwd} + {p.sessions} + {p.tok} + {p.last} + + +
+ ))} +
+ + ); +}; + +const ProjectScreen = () => { + const { go, route } = useRouter(); + const cwd = route.cwd || '~/code/tt-bundle'; + const meta = PROJECTS.find(p => p.cwd === cwd) || { sessions: 0, tok: '0', topTool: 'claude-code' }; + const lastSeg = cwd.split('/').filter(Boolean).pop() || cwd; + const parent = cwd.slice(0, cwd.length - lastSeg.length); + const sessionList = SESSIONS.filter(s => s.cwd === cwd); + + const cols = '90px 110px 70px 1fr 50px 60px'; + + return ( + <> +
+
{parent}
+
+ {lastSeg} + {meta.sessions} sessions + {meta.tok} tok + {Array.from(new Set(sessionList.map(s => s.host))).map(h => )} + + +
+
+ + {sessionList.length > 0 && ( +
+ ★ saved: + {SAVED_SEARCHES.map(s => ( + ★ {s} + ))} + + new +
+ )} + +
+ datetoolhostsession + turnstok +
+
+ {sessionList.length === 0 ? ( + + ) : sessionList.map((s) => ( +
go({ name: 'session', id: s.id })}> + {s.when.replace(/^Today /, '').replace(/^Yest\. /, 'Yest ')} + + + {s.q} + {s.turns} + {s.tok} +
+ ))} +
+ + ); +}; + +const StatsScreen = () => { + const { go } = useRouter(); + const [range, setRange] = React.useState('30d'); + const [groupBy, setGroupBy] = React.useState('tool'); + + return ( + <> +
+ range: + {['7d', '30d', '90d', 'all'].map(r => ( + setRange(r)}>{r} + ))} + + group by: + {['tool', 'host', 'project', 'model'].map(g => ( + setGroupBy(g)}>{g} + ))} +
+ +
+
+ {/* per-tool strip */} +
+
per tool · last {range}
+
+ {TOOL_ROLLUPS.map((r, i) => ( +
+ + {r.tool} + + {r.turns.toLocaleString()} + {r.ktok}k tok + {r.cost > 0 ? `$${r.cost.toFixed(2)}` : '—'} + + + + {(r.share * 100).toFixed(0)}% +
+ ))} +
+
+ + {/* turns/day stacked */} +
+
turns/day · stacked by tool + + + claude-code + opencode + crush + +
+
+ + {/* Y axis grid */} + {[0, 30, 60, 90, 120].map(y => ( + + ))} + {Array.from({ length: 60 }).map((_, i) => { + const claude = 8 + Math.abs(Math.sin(i * 0.5)) * 60; + const oc = 2 + Math.abs(Math.cos(i * 0.9)) * 7; + const crush = 1 + Math.abs(Math.sin(i * 1.2)) * 3; + const x = i * 10 + 2; + return ( + + + + + + ); + })} + +
+ 60d ago + today +
+
+
+ + {/* heatmap */} +
+
activity · 12 weeks
+
+ + {Array.from({ length: 12 * 7 }).map((_, i) => { + const v = (Math.sin(i * 0.6) + Math.cos(i * 0.3) + 1.5) / 3; + const w = Math.floor(i / 7), d = i % 7; + const op = 0.1 + v * 0.85; + return ; + })} + +
+ less + {[0.15, 0.35, 0.55, 0.8, 1].map(o => ( + + ))} + more +
+
+
+ + {/* top cwd */} +
+
top cwd
+
+ {[ + ['~/code/tt-bundle', 41, 1.0], + ['~/work/scarlet-svc', 28, 0.68], + ['~/work/atelier/migrations', 14, 0.34], + ['~/code/dotfiles', 7, 0.17], + ['~/notes', 5, 0.12], + ].map(([p, n, r]) => ( +
go({ name: 'project', cwd: p })}> + {p} + +
+ + {n} +
+ ))} +
+
+ + {/* hour-of-day */} +
+
turns by hour
+
+ + {Array.from({ length: 24 }).map((_, i) => { + const v = i < 7 ? 4 + Math.sin(i) * 3 + : i < 12 ? 30 + Math.sin(i * 0.7) * 18 + : i < 18 ? 45 + Math.cos(i * 0.6) * 14 + : 18 + Math.sin(i * 1.1) * 10; + const h = Math.max(2, v); + return 19 ? 0.45 : 0.85} />; + })} + +
+ 0006121824 +
+
+
+ + {/* host split */} +
+
by host
+
+ {[ + ['laptop', 1240, 0.72, '#3b6e3b'], + ['workpc', 480, 0.28, 'var(--accent)'], + ].map(([h, n, r, c]) => ( +
+
+ + {n.toLocaleString()} ({(r * 100).toFixed(0)}%) +
+
+
+
+
+ ))} +
1,720 turns · 30d
+
+
+
+
+ + ); +}; + +const HealthScreen = () => { + const okCount = COLLECTORS.filter(c => c.status === 'ok').length; + const warnCount = COLLECTORS.filter(c => c.status === 'warn').length; + const staleCount = COLLECTORS.filter(c => c.status === 'stale').length; + const cols = '20px 90px 130px 1fr 60px 70px 80px 80px'; + + return ( + <> +
+ collectors + ● {okCount} ok + {warnCount > 0 && ● {warnCount} warn} + {staleCount > 0 && ● {staleCount} stale} + + poll: 30s · ingesting as {ME.user} via {ME.via} → 127.0.0.1:8401 +
+ +
+ hosttoolsource + lagoutbox + last okevents 24h +
+
+ {COLLECTORS.map((r, i) => ( +
+ + {r.host} + + {r.src} + {r.lag} + 0 ? '' : 'muted')} + style={r.out > 0 ? { color: 'var(--warn)', fontWeight: 600 } : {}}>{r.out} + {r.last} + {r.ev} +
+ ))} +
+ +
+ backfill: + claude-code/workpc + 6/9 files + +
+ + 67% + ~3m + + ● last error: 09:08 crush "tool_call_v2" → metadata fallback +
+ + ); +}; + +const SettingsScreen = () => { + const [section, setSection] = React.useState('Sources'); + const sections = ['Sources', 'Display', 'Auth', 'Backup', 'Export', 'Tags', 'Saved searches']; + + return ( +
+ +
+ {section === 'Sources' && } + {section === 'Display' && } + {section === 'Auth' && } + {section === 'Backup' && } + {section === 'Export' && } + {section === 'Tags' && } + {section === 'Saved searches' && } +
+
+ ); +}; + +const SettingsSources = () => { + const cols = '14px 110px 1fr 60px 90px 70px 50px'; + return ( + <> +
Sources
+
+ ~/.config/assistant-log/config.toml · per-host +
+
+
+ toolpath + pollevents + last ok +
+ {[ + ['ok', 'claude-code', '~/.claude/projects', '30s', 12418, 'now'], + ['ok', 'opencode', '~/.local/share/opencode', '30s', 312, '12s'], + ['ok', 'crush', '~/.cache/crush', '60s', 88, '28s'], + ['ok', 'pi', '~/.config/pi/history', '60s', 47, '1m'], + ['warn', 'kimi', '~/.kimi-cli/history.jsonl', '60s', 12, '2m'], + ].map(([s, t, p, poll, ev, last], i, arr) => ( +
+ + + {p} + {poll} + {ev.toLocaleString()} + {last} + edit +
+ ))} +
+
+ + add source +
+ +
+
server
+
+ modulesourcecraft.dev/bigbes/lethe + bind{AUTH_CONFIG.bind} · loopback-only, behind reverse proxy + db~/.local/share/lethe/store.sqlite (412 MB) · WAL · busy_timeout=5s + ftsturns_fts · tool_outputs_fts · 24,118 turns indexed + migrations0001_init · applied on startup via embed.FS + api/api/v1 · /healthz · /readyz · /metrics + uptime14d 03:12 · since boot +
+
+ + ); +}; + +const SettingsAuth = () => { + const a = AUTH_CONFIG; + return ( + <> +
Auth
+
+ Two independent paths, both gated by the same allowlist. Server binds 127.0.0.1 only — + a reverse proxy on phoebe terminates TLS and forwards. Editing requires rewriting config.yaml and restarting. +
+ +
+
allowlist · auth.allowed_users
+
+ {a.allowedUsers.map(u => ( + + {u}{a.admins.includes(u) && ADMIN} + + ))} + + add +
+
+ +
+
+
+ forward-auth (header trust) + + {a.forwardAuth.enabled ? '● enabled' : '○ disabled'} +
+
+ user header{a.forwardAuth.userHeader} + trust sourceCaddy → Authelia forward-auth + used bybrowser sessions w/ Authelia cookie +
+
+
caddy snippet
+
{`forward_auth authelia.internal:9091 {
+  uri /api/verify?rd=https://auth/
+  copy_headers Remote-User Remote-Email
+}
+reverse_proxy 127.0.0.1:8401`}
+
+
+ +
+
+ oidc bearer + + {a.oidc.enabled ? '● enabled' : '○ disabled'} +
+
+ issuer{a.oidc.issuer} + audience{a.oidc.audience} + claim{a.oidc.usernameClaim} → fallback sub + jwkscached · last fetch {a.oidc.jwksLastFetch} + used bycollector, scripted clients +
+
+
resolution order
+
+ 1. Authorization: Bearer … validated → user from JWT
+ 2. else {a.forwardAuth.userHeader} taken from proxy
+ 3. else 401 · invalid bearer never falls back (fail-closed) +
+
+
+
+ +
+
recent auth events
+
+ timeuserviapathcodenote +
+ {AUTH_EVENTS.map((e, i) => ( +
= 400 ? 'var(--err-bg)' : 'transparent', + }}> + {e.t} + {e.user} + {e.via} + {e.path} + = 400 ? 'var(--err)' : 'var(--ok)' }}>{e.status} + {e.note || ''} +
+ ))} +
+ +
+
trust model
+
+ owner is server-derived from the authenticated user on every ingest write. + The wire format in internal/shared/wire/ has no owner field — collectors cannot impersonate. + Read endpoints filter to owner = current_user; admins ({a.admins.join(', ')}) may pass ?owner=<user> or ?owner=* to override. Non-admins passing ?owner= at all → 403. + A session belonging to another owner returns 404 (existence is never leaked). +
+
+ + ); +}; + +const SettingsDisplay = () => ( + <> +
Display
+
Mirror of the Tweaks panel — these settings sync.
+
+ {[ + ['density', ['compact', 'comfortable']], + ['tool calls', ['expanded', 'collapsed']], + ['accent color', ['on', 'off']], + ['hour format', ['24h', '12h']], + ].map(([l, opts]) => ( +
+
{l}
+
+ {opts.map((o, j) => ( + {o} + ))} +
+
+ ))} +
+ +); + +const SettingsStub = ({ title, subtitle }) => ( + <> +
{title}
+
{subtitle}
+
+
+
Detailed config UI · stubbed for prototype
+
+ +); + +Object.assign(window, { + ProjectsScreen, ProjectScreen, StatsScreen, HealthScreen, SettingsScreen, +}); diff --git a/docs/design_handoff_assistant_log/proto-palette.jsx b/docs/design_handoff_assistant_log/proto-palette.jsx new file mode 100644 index 0000000000000000000000000000000000000000..5fe62538c26ccb0d35a38be5bef86a07eacedbaf --- /dev/null +++ b/docs/design_handoff_assistant_log/proto-palette.jsx @@ -0,0 +1,91 @@ +// Command palette overlay + +const Palette = ({ onClose }) => { + const { go } = useRouter(); + const [q, setQ] = React.useState(''); + const [sel, setSel] = React.useState(0); + const inputRef = React.useRef(null); + React.useEffect(() => { inputRef.current && inputRef.current.focus(); }, []); + React.useEffect(() => { setSel(0); }, [q]); + + const jumps = [ + { kind: 'jump', label: 'Recent', hint: 'g h', go: { name: 'home' } }, + { kind: 'jump', label: 'Projects', hint: 'g p', go: { name: 'projects' } }, + { kind: 'jump', label: 'Stats', hint: 'g s', go: { name: 'stats' } }, + { kind: 'jump', label: 'Health', hint: 'g i', go: { name: 'health' } }, + { kind: 'jump', label: 'Settings', hint: '', go: { name: 'settings' } }, + ]; + const sessionItems = SESSIONS.map(s => ({ + kind: 'session', label: s.q, hint: `${s.tool} · ${s.cwd}`, + go: { name: 'session', id: s.id } + })); + const projectItems = PROJECTS.map(p => ({ + kind: 'project', label: p.cwd, hint: `${p.sessions} sessions`, + go: { name: 'project', cwd: p.cwd } + })); + const all = [...jumps, ...projectItems, ...sessionItems]; + + const filtered = q.trim() === '' ? all : all.filter(x => + (x.label + ' ' + x.hint).toLowerCase().includes(q.toLowerCase()) + ); + const showSearch = q.trim() !== ''; + const total = filtered.length + (showSearch ? 1 : 0); + + const fire = (idx) => { + if (showSearch && idx === 0) { go({ name: 'search', q }); onClose(); return; } + const item = filtered[showSearch ? idx - 1 : idx]; + if (item) { go(item.go); onClose(); } + }; + + const onKey = (e) => { + if (e.key === 'Escape') { e.preventDefault(); onClose(); } + else if (e.key === 'ArrowDown') { e.preventDefault(); setSel(s => Math.min(total - 1, s + 1)); } + else if (e.key === 'ArrowUp') { e.preventDefault(); setSel(s => Math.max(0, s - 1)); } + else if (e.key === 'Enter') { e.preventDefault(); fire(sel); } + }; + + return ( +
+
e.stopPropagation()}> +
+ ⌘K + setQ(e.target.value)} + onKeyDown={onKey} + placeholder="search turns, jump to a page, pick a session…" /> +
+
+ {showSearch && ( +
fire(0)}> + search + "{q}" + +
+ )} + {filtered.map((item, i) => { + const idx = showSearch ? i + 1 : i; + const active = idx === sel; + return ( +
fire(idx)}> + {item.kind} + {item.label} + {item.hint} +
+ ); + })} + {filtered.length === 0 && !showSearch && ( +
no matches
+ )} +
+
+ ↑↓ move↵ openesc close + + g h · g p · g s · g i +
+
+
+ ); +}; + +Object.assign(window, { Palette }); diff --git a/docs/design_handoff_assistant_log/proto-search.jsx b/docs/design_handoff_assistant_log/proto-search.jsx new file mode 100644 index 0000000000000000000000000000000000000000..0a491d34df3f386aead58babf300c379797bc045 --- /dev/null +++ b/docs/design_handoff_assistant_log/proto-search.jsx @@ -0,0 +1,78 @@ +// Search results — flat turn-level results with FTS marks + +const SearchScreen = () => { + const { query, go } = useRouter(); + const q = (query || '').trim(); + + // Fake search corpus — turns with snippets that match the query + const corpus = [ + { ts: '04-23 10:54', cwd: '~/work/scarlet-svc', n: 18, tool: 'claude-code', sid: 'a3', text: "use jittered exp backoff with 30s cap, but always read Retry-After from server", hits: ['backoff', 'Retry-After', 'retry'] }, + { ts: '04-23 10:52', cwd: '~/work/scarlet-svc', n: 14, tool: 'claude-code', sid: 'a3', text: "tolerate 429s without sleep — retry should respect Retry-After", hits: ['429', 'retry', 'Retry-After'] }, + { ts: '04-19 16:30', cwd: '~/code/tt-bundle', n: 8, tool: 'claude-code', sid: 'a1', text: "rate limited; we retry once at the call site, anything beyond is real 429", hits: ['retry', '429'] }, + { ts: '04-15 09:11', cwd: '~/work/atelier', n: 3, tool: 'opencode', sid: 'a2', text: "queue worker hits 429 bursts when upstream cache misses", hits: ['429'] }, + { ts: '04-12 14:02', cwd: '~/work/scarlet-svc', n: 22, tool: 'claude-code', sid: 'a3', text: "the backoff library you're using has Decorrelated Jitter built in", hits: ['backoff'] }, + { ts: '04-09 11:45', cwd: '~/code/tt-bundle', n: 12, tool: 'claude-code', sid: 'a1', text: "for the retry loop in the resolver, bump to 4 attempts but cap at 30s", hits: ['retry'] }, + { ts: '04-04 17:20', cwd: '~/code/tt-bundle', n: 5, tool: 'claude-code', sid: 'a6', text: "bumping a transitive dep should not invalidate every output", hits: ['transitive'] }, + { ts: '04-02 10:08', cwd: '~/code/tt-bundle', n: 19, tool: 'claude-code', sid: 'a1', text: "lockfile entries store the resolved transitive tree, not declared", hits: ['lockfile', 'transitive'] }, + ]; + + const lc = q.toLowerCase(); + const results = !q + ? [] + : corpus.filter(r => r.text.toLowerCase().includes(lc) || r.hits.some(h => h.toLowerCase().includes(lc))); + + const renderSnippet = (text, hits) => { + if (!q) return text; + // Build a regex from query + hit list + const terms = [q, ...hits].filter(Boolean).map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + const re = new RegExp(`(${terms.join('|')})`, 'gi'); + const parts = text.split(re); + return parts.map((p, i) => + re.test(p) ? {p} : {p} + ); + }; + + const cols = '110px 110px 200px 50px 1fr'; + const sids = new Set(results.map(r => r.sid)); + + return ( + <> +
+ "{q || '(empty)'}" + → {results.length} turns / {sids.size} sessions / 0.012s + + {}} onRemove={() => {}} /> + {}} onRemove={() => {}} /> + {}} onRemove={() => {}} /> + ★ save +
+
+ tstoolcwd#snippet +
+
+ {!q && ( + + )} + {q && results.length === 0 && ( + + )} + {results.map((r, i) => ( +
go({ name: 'session', id: r.sid })}> + {r.ts} + + { e.stopPropagation(); go({ name: 'project', cwd: r.cwd }); }}>{r.cwd} + #{r.n} + {renderSnippet(r.text, r.hits)} +
+ ))} +
+ + ); +}; + +Object.assign(window, { SearchScreen }); diff --git a/docs/design_handoff_assistant_log/proto-session.jsx b/docs/design_handoff_assistant_log/proto-session.jsx new file mode 100644 index 0000000000000000000000000000000000000000..30ae9a3fb1dfee754890904e3dc98ef9b5cdf4c0 --- /dev/null +++ b/docs/design_handoff_assistant_log/proto-session.jsx @@ -0,0 +1,76 @@ +// Session view — turn list aside + linear transcript + +const SessionScreen = () => { + const { go, route } = useRouter(); + const session = SESSIONS.find(s => s.id === (route.id || 'a1')) || SESSIONS[0]; + + const turnList = [ + [1, 'U', 'lockfile design for the …', 32], + [2, 'A', 'Both, but for diff reasons …', 980], + [3, '⚙', 'Read("build.zig")', 0], + [4, '⚙', '→ 14.2 KB, 318 lines', 0], + [5, 'A', 'Looking at your build.zig …', 1240, true], + [6, 'U', 'ok so transitive deps from…', 18], + [7, 'A', 'Transitive deps need a sep…', 760], + [8, '⚙', 'Grep("vendor/")', 0], + [9, '⚙', '→ 47 matches', 0], + [10, 'A', 'Three of those are header-…', 540], + [11, 'U', 'how would the cache key …', 22], + [12, 'A', 'sha256 over the sorted…', 880], + ]; + + return ( + <> +
+ go({ name: 'project', cwd: session.cwd })}>{session.cwd} + {session.q.slice(0, 56)}{session.q.length > 56 ? '…' : ''} + + + {session.model.replace('claude-', '')} + + {session.turns} turns · {session.tok} tok · started {session.when} · ●open +
+ +
+ + +
+ {TURNS.map((t, i) => ( +
+
+ {t.role.toUpperCase()} + {t.kind && {t.kind}} + {t.model && {t.model}} + {t.tIn != null && in {t.tIn} · out {t.tOut}} + + #{i + 1} +
+
{t.text}
+
+ ))} +
+
+ + ); +}; + +Object.assign(window, { SessionScreen }); diff --git a/docs/design_handoff_assistant_log/proto-shell.jsx b/docs/design_handoff_assistant_log/proto-shell.jsx new file mode 100644 index 0000000000000000000000000000000000000000..317894efffa8d2da281c23b2257d2b2918db229d --- /dev/null +++ b/docs/design_handoff_assistant_log/proto-shell.jsx @@ -0,0 +1,46 @@ +// Top bar + sub-bar + router context shared across screens + +const RouterCtx = React.createContext(null); +const useRouter = () => React.useContext(RouterCtx); + +const TopBar = () => { + const { route, go, openPalette, query } = useRouter(); + const tabs = [ + { id: 'home', label: 'Recent', match: ['home', 'search', 'session'] }, + { id: 'projects', label: 'Projects', match: ['projects', 'project'] }, + { id: 'stats', label: 'Stats', match: ['stats'] }, + { id: 'health', label: 'Health', match: ['health'] }, + { id: 'settings', label: 'Settings', match: ['settings'] }, + ]; + const ownerScope = route.ownerScope; // set by ProjectsScreen / HomeScreen when admin uses ?owner= + return ( +
+ go({ name: 'home' })}>assistant-log + / + scarlet +
+ {query + ? "{query}" + : search turns, sessions, paths…} + ⌘K +
+ + go({ name: 'settings' })} title={`signed in via ${ME.via}`}> + + {ME.user} + {ME.isAdmin && admin} + {ownerScope && ownerScope !== ME.user && ( + viewing: {ownerScope === '*' ? 'all owners' : ownerScope} + )} + +
+ ); +}; + +Object.assign(window, { RouterCtx, useRouter, TopBar }); diff --git a/docs/design_handoff_assistant_log/prototype.css b/docs/design_handoff_assistant_log/prototype.css new file mode 100644 index 0000000000000000000000000000000000000000..2b95a560298911e248d906fb2ca88cc3cd5b94ba --- /dev/null +++ b/docs/design_handoff_assistant_log/prototype.css @@ -0,0 +1,560 @@ +/* assistant-log · prototype */ + +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap'); + +:root { + /* Accent locked at hue 120 (true green) */ + --accent: oklch(0.78 0.18 120); + --accent-soft: oklch(0.94 0.05 120); + --accent-ink: oklch(0.30 0.10 120); + --accent-on: oklch(0.20 0.08 120); /* fg on bright accent bg */ + + /* Surfaces */ + --paper: #fdfcf8; /* main bg */ + --paper-2: #f3f0e7; /* hover */ + --paper-3: #fbfaf2; /* sub-bar / table head */ + --paper-4: #ffffff; /* card body */ + + /* Ink */ + --ink: #1c1a17; + --ink-2: #4a453d; + --ink-3: #7a7468; + --ink-4: #b3ad9f; + + /* Rules */ + --rule: #d8d3c4; + --rule-2: #e6e1d2; + + /* Top bar */ + --topbar-bg: #1c1a17; + --topbar-fg: #fdfcf8; + --topbar-input-bg: rgba(255,255,255,0.06); + --topbar-input-bd: rgba(255,255,255,0.15); + + /* Tag base */ + --tag-bg: #ebe7d8; + --tag-host-bg: #d9e6dd; + --tag-host-fg: #2f5a3f; + + /* Turn backgrounds */ + --turn-user: #f5f0e0; + --turn-asst: #ffffff; + --turn-tool: #f8f5e9; + + /* Status */ + --ok: #3b6e3b; --ok-bg: #d6efd6; + --warn: #c89a3a; --warn-bg: #fdebcc; + --err: #b04a2a; --err-bg: #f5dccf; + + /* Misc */ + --scrim: rgba(28,26,23,0.45); + --shadow: 0 20px 60px rgba(0,0,0,0.25); + --shadow-pop: 0 8px 24px rgba(0,0,0,0.18); + + --mono: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace; + --sans: 'Inter', system-ui, sans-serif; +} + +[data-theme="dark"] { + /* Same accent hue but tuned for dark surface contrast */ + --accent: oklch(0.78 0.16 120); + --accent-soft: oklch(0.30 0.08 120); + --accent-ink: oklch(0.86 0.16 120); + --accent-on: oklch(0.18 0.06 120); /* fg on bright accent bg */ + + --paper: #15140f; + --paper-2: #211f17; + --paper-3: #1a1813; + --paper-4: #1d1b14; + + --ink: #e8e3d4; + --ink-2: #b8b2a0; + --ink-3: #807a6b; + --ink-4: #4a4538; + + --rule: #2c2920; + --rule-2: #24221a; + + --topbar-bg: #0d0c08; + --topbar-fg: #e8e3d4; + --topbar-input-bg: rgba(255,255,255,0.04); + --topbar-input-bd: rgba(255,255,255,0.10); + + --tag-bg: #2a2820; + --tag-host-bg: #1f3327; + --tag-host-fg: #8fc09e; + + --turn-user: #1f1d15; + --turn-asst: #15140f; + --turn-tool: #211f17; + + --ok: #6fb46f; --ok-bg: #1f3327; + --warn: #d4a648; --warn-bg: #3a2e15; + --err: #d97052; --err-bg: #3a1f15; + + --scrim: rgba(0,0,0,0.65); + --shadow: 0 20px 60px rgba(0,0,0,0.6); + --shadow-pop: 0 8px 24px rgba(0,0,0,0.5); +} + +* { box-sizing: border-box; } +html, body, #root { height: 100%; margin: 0; padding: 0; } +body { + background: var(--paper); + color: var(--ink); + font-family: var(--sans); + font-size: 12px; + line-height: 1.4; + -webkit-font-smoothing: antialiased; +} + +/* ─── App shell ─── */ +.app { + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; + background: var(--paper); + color: var(--ink); +} + +/* ─── Top bar ─── */ +.topbar { + background: var(--topbar-bg); + color: var(--topbar-fg); + padding: 6px 14px; + display: flex; + align-items: center; + gap: 12px; + font-family: var(--mono); + font-size: 11px; + flex: none; + border-bottom: 1px solid #000; +} +.topbar .brand { font-weight: 700; cursor: pointer; } +.topbar .brand-sep { opacity: 0.4; } +.topbar .brand-host { opacity: 0.7; cursor: pointer; } +.topbar .search { + flex: 1; + background: var(--topbar-input-bg); + border: 1px solid var(--topbar-input-bd); + color: var(--topbar-fg); + padding: 4px 10px; + font-family: var(--mono); + font-size: 11px; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.topbar .search .ghost { opacity: 0.45; } +.topbar .search .kbd { margin-left: auto; opacity: 0.5; font-size: 10px; } +.topbar nav { display: flex; gap: 4px; } +.topbar nav .tab { + padding: 3px 8px; + border-bottom: 2px solid transparent; + opacity: 0.7; + cursor: pointer; + user-select: none; +} +.topbar nav .tab:hover { opacity: 1; } +.topbar nav .tab.active { + opacity: 1; + border-bottom-color: var(--accent); +} + +/* ─── Identity badge in topbar ─── */ +.topbar .who { + display: flex; align-items: center; gap: 6px; + padding: 3px 8px; + border-left: 1px solid rgba(255,255,255,0.12); + cursor: pointer; + margin-left: 4px; +} +.topbar .who:hover { background: rgba(255,255,255,0.04); } +.topbar .who-dot { + width: 6px; height: 6px; border-radius: 50%; + background: oklch(0.78 0.16 145); + box-shadow: 0 0 6px oklch(0.78 0.16 145 / 0.5); +} +.topbar .who-admin { + font-size: 9px; + padding: 1px 5px; + margin-left: 2px; + border-radius: 2px; + background: transparent; + color: var(--accent-ink); + border: 1px solid var(--accent-ink); + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 600; + opacity: 0.75; +} +.topbar .who-scope { + font-size: 10px; + padding: 1px 5px; + border-radius: 2px; + background: rgba(255,180,80,0.18); + color: #f0c060; + border: 1px solid rgba(255,180,80,0.3); +} + +/* ─── Sub-bar (filter chips) ─── */ +.subbar { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + border-bottom: 1px solid var(--rule-2); + background: var(--paper-3); + font-size: 11px; + flex: none; + position: relative; +} + +/* ─── Tags / chips ─── */ +.tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 6px; + border-radius: 3px; + background: var(--tag-bg); + color: var(--ink-2); + font-family: var(--mono); + font-size: 10px; + font-weight: 500; + white-space: nowrap; +} +.tag.accent { background: var(--accent); color: var(--accent-on); } +.tag.host { background: var(--tag-host-bg); color: var(--tag-host-fg); } +.tag.ok { background: var(--ok-bg); color: var(--ok); } +.tag.warn { background: var(--warn-bg); color: var(--warn); } +.tag.err { background: var(--err-bg); color: var(--err); } +.tag.dashed { background: transparent; border: 1px dashed var(--ink-4); cursor: pointer; color: var(--ink-3); } +.tag.outline { background: transparent; border: 1px solid var(--rule); } +.tag.click { cursor: pointer; } +.tag.click:hover { filter: brightness(1.08); } +[data-theme="dark"] .tag.click:hover { filter: brightness(1.2); } + +/* ─── Tool dot ─── */ +.tooldot { + display: inline-block; + width: 8px; height: 8px; + border-radius: 2px; + flex: none; +} + +/* ─── Grid rows ─── */ +.thead { + display: grid; + padding: 5px 14px; + background: var(--paper-3); + font-size: 10px; + color: var(--ink-3); + text-transform: uppercase; + letter-spacing: 0.05em; + font-family: var(--mono); + align-items: center; + gap: 10px; + border-bottom: 1px solid var(--rule-2); + flex: none; +} +.row { + display: grid; + padding: 4px 14px; + border-bottom: 1px solid var(--rule-2); + align-items: center; + gap: 10px; + font-size: 11.5px; + cursor: pointer; + color: var(--ink); +} +.row.no-click { cursor: default; } +.row:hover { background: var(--paper-2); } +.row.cursor { + background: var(--accent-soft); + border-left: 2px solid var(--accent); + padding-left: 12px; +} + +.body { flex: 1; overflow: auto; min-height: 0; } +.body-pad { padding: 14px; } + +/* ─── Helpers ─── */ +.mono { font-family: var(--mono); } +.muted { color: var(--ink-3); } +.dim { color: var(--ink-4); } +.accent-c { color: var(--accent-ink); } +.right { text-align: right; } +.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } +.flex { display: flex; align-items: center; gap: 8px; } +.flex-1 { flex: 1; min-width: 0; } +.click { cursor: pointer; } +.uppercase-mono { + font-family: var(--mono); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ink-3); +} + +/* ─── Sparkline ─── */ +.spark { stroke: var(--ink-2); stroke-width: 1.2; fill: none; } +.spark.accent { stroke: var(--accent); stroke-width: 1.4; } + +/* ─── Card ─── */ +.card { + border: 1px solid var(--rule-2); + border-radius: 4px; + background: var(--paper-4); +} +.card .card-head { + font-family: var(--mono); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ink-3); + padding: 8px 10px 6px; + border-bottom: 1px solid var(--rule-2); + display: flex; + align-items: center; + gap: 8px; +} +.card .card-body { padding: 10px; } + +/* ─── Dot status ─── */ +.statusdot { width: 8px; height: 8px; border-radius: 99px; flex: none; } +.statusdot.ok { background: var(--ok); } +.statusdot.warn { background: var(--warn); } +.statusdot.stale { background: var(--err); } + +/* ─── FTS mark ─── */ +mark.fts { + background: var(--accent); + color: var(--accent-on); + padding: 0 2px; + border-radius: 2px; + font-weight: 600; +} + +/* ─── Session view ─── */ +.turn { + padding: 10px 16px; + border-bottom: 1px solid var(--rule-2); + border-left: 3px solid transparent; +} +.turn.user { background: var(--turn-user); border-left-color: var(--ink-4); } +.turn.assistant { background: var(--turn-asst); border-left-color: var(--accent); } +.turn.tool { background: var(--turn-tool); } +.turn .meta { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 4px; + font-size: 10.5px; + color: var(--ink-3); +} +.turn .role-USER { font-family: var(--mono); font-weight: 700; color: var(--ink-2); letter-spacing: 0.04em; } +.turn .role-ASSISTANT { font-family: var(--mono); font-weight: 700; color: var(--accent-ink); letter-spacing: 0.04em; } +.turn .role-TOOL { font-family: var(--mono); font-weight: 700; color: var(--ink-3); letter-spacing: 0.04em; } +.turn .body { + font-size: 13px; + line-height: 1.55; + color: var(--ink); + flex: none; + overflow: visible; +} +.turn.tool .body { font-family: var(--mono); font-size: 11.5px; } + +/* ─── Compact density ─── */ +.density-compact .row { padding-top: 3px; padding-bottom: 3px; } +.density-compact .turn { padding: 7px 16px; } + +/* ─── Hide tool calls tweak ─── */ +.hide-tool-calls .turn.tool { display: none; } + +/* ─── Palette overlay ─── */ +.scrim { + position: absolute; inset: 0; + background: var(--scrim); + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 80px; + z-index: 50; +} +.palette { + width: 620px; + max-height: 480px; + background: var(--paper); + border: 1px solid var(--ink-3); + border-radius: 6px; + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + overflow: hidden; +} +.palette .pin { + padding: 10px 14px; + border-bottom: 1px solid var(--rule-2); + display: flex; + align-items: center; + gap: 10px; +} +.palette input { + flex: 1; + border: none; + outline: none; + background: transparent; + font-family: var(--mono); + font-size: 13px; + color: var(--ink); +} +.palette .plist { flex: 1; overflow: auto; padding: 4px 0; min-height: 0; } +.palette .pitem { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 14px; + cursor: pointer; + font-size: 12.5px; + border-left: 2px solid transparent; + color: var(--ink); +} +.palette .pitem .kind { + font-family: var(--mono); + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ink-3); + width: 70px; + flex: none; +} +.palette .pitem.active { + background: var(--accent-soft); + border-left-color: var(--accent); +} +.palette .pfoot { + padding: 6px 14px; + border-top: 1px solid var(--rule-2); + background: var(--paper-3); + font-size: 10px; + color: var(--ink-3); + display: flex; + gap: 14px; + font-family: var(--mono); +} + +/* ─── Filter chip popover ─── */ +.popover { + position: absolute; + background: var(--paper); + border: 1px solid var(--ink-3); + border-radius: 4px; + box-shadow: var(--shadow-pop); + padding: 4px 0; + z-index: 40; + min-width: 160px; + font-size: 11.5px; + color: var(--ink); +} +.popover .ph { + font-family: var(--mono); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ink-3); + padding: 6px 10px 4px; +} +.popover .pi { + padding: 4px 10px; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; +} +.popover .pi:hover { background: var(--paper-2); } + +/* ─── Empty state ─── */ +.empty { + padding: 60px 20px; + text-align: center; + color: var(--ink-3); + font-size: 12px; +} +.empty .glyph { + font-family: var(--mono); + font-size: 28px; + color: var(--ink-4); + margin-bottom: 8px; +} + +/* ─── Bottom hint chip ─── */ +.hint { + position: absolute; + right: 10px; bottom: 10px; + font-family: var(--mono); + font-size: 10px; + background: rgba(28,26,23,0.85); + color: #fdfcf8; + padding: 4px 8px; + border-radius: 3px; + pointer-events: none; + letter-spacing: 0.04em; + z-index: 30; +} +[data-theme="dark"] .hint { background: rgba(0,0,0,0.85); color: var(--ink); border: 1px solid var(--rule); } + +/* ─── Settings sidebar ─── */ +.settings-grid { + display: grid; + grid-template-columns: 180px 1fr; + gap: 14px; + padding: 14px; + height: 100%; + overflow: auto; + align-content: start; +} +.settings-nav .item { + font-family: var(--mono); + padding: 5px 8px; + border-radius: 3px; + cursor: pointer; + color: var(--ink-2); + font-size: 11.5px; +} +.settings-nav .item.active { + background: var(--accent-soft); + color: var(--accent-ink); + font-weight: 600; +} +.settings-nav .item:hover:not(.active) { background: var(--paper-2); } + +/* ─── Stats grid ─── */ +.stats-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + padding: 14px; +} +.stats-grid .full { grid-column: 1 / -1; } + +/* ─── Footer strip ─── */ +.footstrip { + border-top: 1px solid var(--rule-2); + background: var(--paper-3); + padding: 7px 14px; + font-size: 11px; + display: flex; + gap: 14px; + align-items: center; + flex: none; + color: var(--ink); +} + +/* ─── Tweaks panel positioning ─── */ +.tweaks-panel { z-index: 20; } diff --git a/docs/design_handoff_assistant_log/tweaks-panel.jsx b/docs/design_handoff_assistant_log/tweaks-panel.jsx new file mode 100644 index 0000000000000000000000000000000000000000..d01b95a6264ef2c936ef956e2c6bff771e3eb2b9 --- /dev/null +++ b/docs/design_handoff_assistant_log/tweaks-panel.jsx @@ -0,0 +1,419 @@ + +// tweaks-panel.jsx +// Reusable Tweaks shell + form-control helpers. +// +// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode, +// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so +// individual prototypes don't re-roll it. Ships a consistent set of controls so you +// don't hand-draw , segmented radios, steppers, etc. +// +// Usage (in an HTML file that loads React + Babel): +// +// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ +// "primaryColor": "#D97757", +// "fontSize": 16, +// "density": "regular", +// "dark": false +// }/*EDITMODE-END*/; +// +// function App() { +// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); +// return ( +//
+// Hello +// +// +// setTweak('fontSize', v)} /> +// setTweak('density', v)} /> +// +// setTweak('primaryColor', v)} /> +// setTweak('dark', v)} /> +// +//
+// ); +// } +// +// ───────────────────────────────────────────────────────────────────────────── + +const __TWEAKS_STYLE = ` + .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px; + max-height:calc(100vh - 32px);display:flex;flex-direction:column; + background:rgba(250,249,247,.78);color:#29261b; + -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%); + border:.5px solid rgba(255,255,255,.6);border-radius:14px; + box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18); + font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden} + .twk-hd{display:flex;align-items:center;justify-content:space-between; + padding:10px 8px 10px 14px;cursor:move;user-select:none} + .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em} + .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55); + width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1} + .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b} + .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px; + overflow-y:auto;overflow-x:hidden;min-height:0; + scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent} + .twk-body::-webkit-scrollbar{width:8px} + .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px} + .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px; + border:2px solid transparent;background-clip:content-box} + .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25); + border:2px solid transparent;background-clip:content-box} + .twk-row{display:flex;flex-direction:column;gap:5px} + .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px} + .twk-lbl{display:flex;justify-content:space-between;align-items:baseline; + color:rgba(41,38,27,.72)} + .twk-lbl>span:first-child{font-weight:500} + .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums} + + .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase; + color:rgba(41,38,27,.45);padding:10px 0 0} + .twk-sect:first-child{padding-top:0} + + .twk-field{appearance:none;width:100%;height:26px;padding:0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px; + background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none} + .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)} + select.twk-field{padding-right:22px; + background-image:url("data:image/svg+xml;utf8,"); + background-repeat:no-repeat;background-position:right 8px center} + + .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0; + border-radius:999px;background:rgba(0,0,0,.12);outline:none} + .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none; + width:14px;height:14px;border-radius:50%;background:#fff; + border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%; + background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + + .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px; + background:rgba(0,0,0,.06);user-select:none} + .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px; + background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12); + transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s} + .twk-seg.dragging .twk-seg-thumb{transition:none} + .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0; + background:transparent;color:inherit;font:inherit;font-weight:500;height:22px; + border-radius:6px;cursor:default;padding:0} + + .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px; + background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0} + .twk-toggle[data-on="1"]{background:#34c759} + .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%; + background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s} + .twk-toggle[data-on="1"] i{transform:translateX(14px)} + + .twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)} + .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize; + user-select:none;padding-right:8px} + .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent; + font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0; + outline:none;color:inherit;-moz-appearance:textfield} + .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{ + -webkit-appearance:none;margin:0} + .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)} + + .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px; + background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default} + .twk-btn:hover{background:rgba(0,0,0,.88)} + .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit} + .twk-btn.secondary:hover{background:rgba(0,0,0,.1)} + + .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px; + border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default; + background:transparent;flex-shrink:0} + .twk-swatch::-webkit-color-swatch-wrapper{padding:0} + .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px} + .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px} +`; + +// ── useTweaks ─────────────────────────────────────────────────────────────── +// Single source of truth for tweak values. setTweak persists via the host +// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk). +function useTweaks(defaults) { + const [values, setValues] = React.useState(defaults); + const setTweak = React.useCallback((key, val) => { + setValues((prev) => ({ ...prev, [key]: val })); + window.parent.postMessage({ type: '__edit_mode_set_keys', edits: { [key]: val } }, '*'); + }, []); + return [values, setTweak]; +} + +// ── TweaksPanel ───────────────────────────────────────────────────────────── +// Floating shell. Registers the protocol listener BEFORE announcing +// availability — if the announce ran first, the host's activate could land +// before our handler exists and the toolbar toggle would silently no-op. +// The close button posts __edit_mode_dismissed so the host's toolbar toggle +// flips off in lockstep; the host echoes __deactivate_edit_mode back which +// is what actually hides the panel. +function TweaksPanel({ title = 'Tweaks', children }) { + const [open, setOpen] = React.useState(false); + const dragRef = React.useRef(null); + const offsetRef = React.useRef({ x: 16, y: 16 }); + const PAD = 16; + + const clampToViewport = React.useCallback(() => { + const panel = dragRef.current; + if (!panel) return; + const w = panel.offsetWidth, h = panel.offsetHeight; + const maxRight = Math.max(PAD, window.innerWidth - w - PAD); + const maxBottom = Math.max(PAD, window.innerHeight - h - PAD); + offsetRef.current = { + x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)), + y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)), + }; + panel.style.right = offsetRef.current.x + 'px'; + panel.style.bottom = offsetRef.current.y + 'px'; + }, []); + + React.useEffect(() => { + if (!open) return; + clampToViewport(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', clampToViewport); + return () => window.removeEventListener('resize', clampToViewport); + } + const ro = new ResizeObserver(clampToViewport); + ro.observe(document.documentElement); + return () => ro.disconnect(); + }, [open, clampToViewport]); + + React.useEffect(() => { + const onMsg = (e) => { + const t = e?.data?.type; + if (t === '__activate_edit_mode') setOpen(true); + else if (t === '__deactivate_edit_mode') setOpen(false); + }; + window.addEventListener('message', onMsg); + window.parent.postMessage({ type: '__edit_mode_available' }, '*'); + return () => window.removeEventListener('message', onMsg); + }, []); + + const dismiss = () => { + setOpen(false); + window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*'); + }; + + const onDragStart = (e) => { + const panel = dragRef.current; + if (!panel) return; + const r = panel.getBoundingClientRect(); + const sx = e.clientX, sy = e.clientY; + const startRight = window.innerWidth - r.right; + const startBottom = window.innerHeight - r.bottom; + const move = (ev) => { + offsetRef.current = { + x: startRight - (ev.clientX - sx), + y: startBottom - (ev.clientY - sy), + }; + clampToViewport(); + }; + const up = () => { + window.removeEventListener('mousemove', move); + window.removeEventListener('mouseup', up); + }; + window.addEventListener('mousemove', move); + window.addEventListener('mouseup', up); + }; + + if (!open) return null; + return ( + <> + +
+
+ {title} + +
+
{children}
+
+ + ); +} + +// ── Layout helpers ────────────────────────────────────────────────────────── + +function TweakSection({ label, children }) { + return ( + <> +
{label}
+ {children} + + ); +} + +function TweakRow({ label, value, children, inline = false }) { + return ( +
+
+ {label} + {value != null && {value}} +
+ {children} +
+ ); +} + +// ── Controls ──────────────────────────────────────────────────────────────── + +function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) { + return ( + + onChange(Number(e.target.value))} /> + + ); +} + +function TweakToggle({ label, value, onChange }) { + return ( +
+
{label}
+ +
+ ); +} + +function TweakRadio({ label, value, options, onChange }) { + const trackRef = React.useRef(null); + const [dragging, setDragging] = React.useState(false); + const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o })); + const idx = Math.max(0, opts.findIndex((o) => o.value === value)); + const n = opts.length; + + // The active value is read by pointer-move handlers attached for the lifetime + // of a drag — ref it so a stale closure doesn't fire onChange for every move. + const valueRef = React.useRef(value); + valueRef.current = value; + + const segAt = (clientX) => { + const r = trackRef.current.getBoundingClientRect(); + const inner = r.width - 4; + const i = Math.floor(((clientX - r.left - 2) / inner) * n); + return opts[Math.max(0, Math.min(n - 1, i))].value; + }; + + const onPointerDown = (e) => { + setDragging(true); + const v0 = segAt(e.clientX); + if (v0 !== valueRef.current) onChange(v0); + const move = (ev) => { + if (!trackRef.current) return; + const v = segAt(ev.clientX); + if (v !== valueRef.current) onChange(v); + }; + const up = () => { + setDragging(false); + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }; + + return ( + +
+
+ {opts.map((o) => ( + + ))} +
+ + ); +} + +function TweakSelect({ label, value, options, onChange }) { + return ( + + + + ); +} + +function TweakText({ label, value, placeholder, onChange }) { + return ( + + onChange(e.target.value)} /> + + ); +} + +function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) { + const clamp = (n) => { + if (min != null && n < min) return min; + if (max != null && n > max) return max; + return n; + }; + const startRef = React.useRef({ x: 0, val: 0 }); + const onScrubStart = (e) => { + e.preventDefault(); + startRef.current = { x: e.clientX, val: value }; + const decimals = (String(step).split('.')[1] || '').length; + const move = (ev) => { + const dx = ev.clientX - startRef.current.x; + const raw = startRef.current.val + dx * step; + const snapped = Math.round(raw / step) * step; + onChange(clamp(Number(snapped.toFixed(decimals)))); + }; + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }; + return ( +
+ {label} + onChange(clamp(Number(e.target.value)))} /> + {unit && {unit}} +
+ ); +} + +function TweakColor({ label, value, onChange }) { + return ( +
+
{label}
+ onChange(e.target.value)} /> +
+ ); +} + +function TweakButton({ label, onClick, secondary = false }) { + return ( + + ); +} + +Object.assign(window, { + useTweaks, TweaksPanel, TweakSection, TweakRow, + TweakSlider, TweakToggle, TweakRadio, TweakSelect, + TweakText, TweakNumber, TweakColor, TweakButton, +}); diff --git a/docs/tasks/lethe-web-ui-foundation.md b/docs/tasks/lethe-web-ui-foundation.md new file mode 100644 index 0000000000000000000000000000000000000000..c75e9118b740ee2ff2c0f6fe2ede52f2229da7b4 --- /dev/null +++ b/docs/tasks/lethe-web-ui-foundation.md @@ -0,0 +1,297 @@ +# lethe-web-ui-foundation + +**Status:** Design +**Module:** `sourcecraft.dev/bigbes/lethe` +**Branch:** master +**Worktree:** none +**Parent RFC:** Personal AI Assistant Log Aggregator (2026-04-25) +**Design source:** `docs/design_handoff_assistant_log/` (Direction 4 — Dense Data UI) +**Sibling tasks:** +- `lethe-server.md` (#1, ✓ verified) +- `lethe-collector-claude-code.md` (#2, deferred) +- `lethe-search-and-opencode.md` (#3, deferred) +- `lethe-web-ui-aggregates.md` (#5, deferred — Projects/Stats endpoints + screens) +- `lethe-web-ui-palette-savedsearch.md` (#6, deferred — full ⌘K + saved searches) +- `lethe-web-ui-settings-display.md` (#7, deferred — Settings → Display) +- `lethe-web-ui-search.md` (#8, blocked on #3) +- `lethe-web-ui-health-sources.md` (#9, blocked on #2) + +## Design + +### Purpose + +Stand up the Lethe web UI: a single-page React app served by the lethe binary, providing a usable browse-and-read experience over the existing `/api/v1/sessions` surface. This is the foundation that follow-on tasks build on (aggregates, palette, settings, search, health). **In scope**: project skeleton, build pipeline, embed/serve, theming primitives, Home, Session view. **Out of scope**: Projects, Stats, Search, Health, Settings, ⌘K palette beyond a stub, saved searches. + +### Chosen approach + +**Stack**: React 18 + TypeScript + Vite + TanStack Router + TanStack Query, vanilla CSS using the tokens shipped in the handoff's `prototype.css`. (React over alternatives because the handoff is a React prototype — porting JSX directly is the cheapest route to fidelity.) + +**Layout**: source at `web/` (sibling to `cmd/`, `internal/`); `web/dist/` is git-ignored; production embeds via `//go:embed all:dist/*` from `internal/server/web/embed.go` and serves the SPA as a fallback under the **unauthed** mux. `/api/v1` stays auth-gated; the SPA shell loads even if unauthenticated, and a 401 from any data fetch flips the UI into an auth-error state. + +**Dev**: `just web-dev` starts vite at `:5173` with a proxy to `127.0.0.1:18888` for `/api/v1`, `/healthz`, `/readyz`, `/metrics`. The proxy injects `Remote-User: bigbes` on every outbound request so forward-auth passes without a real reverse proxy. Go code is **not** modified for dev auth. + +### Routes shipped + +| Path | Screen | +|---|---| +| `/` | Home (recent sessions, real data) | +| `/session/:tool/:host/:id` | Session view (turn list aside + transcript, real data) | +| `/projects`, `/stats`, `/health`, `/settings` | Stub: TopBar tab is functional, screen renders a small "coming in a later task" `EmptyState` card | +| `/search` | Stub (palette's synthetic `SEARCH for ""` row routes here) | +| anything else | 404 (the prototype's `EmptyState`) | + +Stubs ship to keep the shell coherent (TopBar tabs and `g h/p/s/i` keybindings all "work" — they navigate). They're cheap and avoid TopBar dead-clicks. + +### Backend changes (additive only) + +`GET /api/v1/sessions` response gains five fields per row, all derivable from the current schema: + +| Field | SQL | +|---|---| +| `summary` | first user turn's `content`, truncated to 200 chars | +| `turn_count` | `COUNT(*)` over turns scoped to the session | +| `tokens_in_total`, `tokens_out_total` | `SUM(tokens_in)`, `SUM(tokens_out)` over the same scope | +| `model` | latest turn's `model` (`ORDER BY seq DESC LIMIT 1`) | + +Implemented with a single SQL using correlated subqueries or a derived join, in `internal/domain/session/repository.go`. Repository tests cover each aggregate. **No wire-type changes**, **no schema changes**, **no removed fields** — the response stays a strict superset. + +`branch` and `has_error` (in the handoff's `Session` TS type) are **deferred** — neither is in the schema; will land in a future task that introduces them. + +### Frontend layout (under `web/`) + +``` +web/ +├── package.json +├── vite.config.ts # proxy + Remote-User injection +├── tsconfig.json +├── index.html # entry; +├── public/ +│ └── (fonts: Inter, JetBrains Mono — vendored) +└── src/ + ├── main.tsx # router + query client init + ├── routes/ # TanStack Router route tree + │ ├── _app.tsx # shell: TopBar, SubBar slot, Body slot + │ ├── index.tsx # Home + │ ├── session.$tool.$host.$id.tsx + │ └── ...stub routes + ├── shell/ # TopBar, SubBar, hint-chip, palette skeleton + ├── primitives/ # Tag, ToolDot, Spark, StatusDot, EmptyState, Sub + ├── features/ + │ ├── home/ # SessionsTable, FilterChips, useSessions + │ └── session/ # TurnList, Transcript, useSession + ├── api/ + │ ├── client.ts # fetch wrapper + RFC 7807 error normalization + │ └── adapters.ts # Go DTO → prototype TS shape + ├── styles/ + │ ├── tokens.css # :root + [data-theme="dark"] from prototype.css + │ ├── shell.css + │ ├── primitives.css + │ ├── home.css + │ └── session.css + └── lib/ + ├── theme.ts # prefers-color-scheme + localStorage + └── keyboard.ts # j/k/↵, g-leader (800ms), Esc, ⌘K +``` + +### Build pipeline + +- New Justfile targets: `web-install`, `web-dev`, `web-build`, `web-test`, `web-lint`. +- `just build` becomes: `web-build` then `go build`. The Go embed directive fails the build if `web/dist/` is missing — so the binary cannot be built without the SPA bundle. +- CI: add a `web` job that runs `npm ci && npm run build && npm test && npm run lint`. Existing Go job stays as-is. +- Dockerfile: builder stage gains a `node:20-alpine` step that produces `web/dist/` before the Go build. + +### Dev experience + +- `just web-dev` → vite at `:5173`, proxies API to `127.0.0.1:18888`, injects `Remote-User: bigbes`. +- `just run` → Go server at `:18888` (today's behavior). +- Workflow: two terminals, vite for the SPA, Go for the API. Hot reload on the frontend; Air handles the backend. + +### Theme / density + +- **Theme**: `prefers-color-scheme` on first load; no user toggle in foundation. (Toggle ships with Settings.) `data-theme="light|dark"` attribute on ``. +- **Density**: hard-coded `compact` in foundation (README's recommended default). Toggle ships with Settings. +- Inline-on-`` accent variable writes from `Prototype.html` are dropped — the CSS rules suffice; the runtime "accent off" toggle is exploration-only and not shipped. + +### Keyboard map (foundation) + +| Key | Action | +|---|---| +| `j` / `k` | move row cursor in Home | +| `↵` | open cursored session (Home only) | +| `g h` | go to Home | +| `g p` / `g s` / `g i` | go to stub screens (so the chord is "real") | +| `⌘K` / `Ctrl+K` | open palette skeleton (single input, no items; SEARCH synthetic row routes to `/search` stub) | +| `Esc` | close palette | + +Full palette items (JUMP/PROJECT/SESSION) ship in `lethe-web-ui-palette-savedsearch`. + +### Verification at end of execute + +- `go test ./... -race -count=1` green; new aggregate tests covered. +- `just web-build` produces `web/dist/`; `just build` produces a binary that, when run, serves the SPA at `/`. +- Manual smoke: hit `/` in a browser via `just web-dev` → see Home with real sessions; click a row → Session view loads with turns; press `g` then `p` → land on Projects stub; press `⌘K` → palette overlay opens and closes on `Esc`. Light + dark mode both render correctly (toggle OS theme). +- `internal/shared/wire/` untouched (grep verification). + +### TDD: yes (scoped) + +Per the `up:test-driven-development` applicability rule — "deterministic, reusable code where regressions would warrant a CI red light": + +- **Yes** for: the SQL aggregate query (Go repository test), the Go→TS adapter (`api/adapters.ts`), the keyboard-state state machine (`lib/keyboard.ts` — g-leader timeout, palette cursor, Esc handling), the theme bootstrap (`lib/theme.ts` — prefers-color-scheme + localStorage interaction). +- **No** for: visual primitives (`Tag`, `ToolDot`, `Spark`), shell layout components, routing wiring — verified by eye against the prototype. + +### Unknowns (resolve during execute) + +- **Aggregates query plan at scale**: the correlated-subquery shape is cheap on the existing indexes, but if it regresses materially we may add a covering index. Bench during execute against the existing fixtures and on a synthetic 10k-session DB. +- **SPA-static auth posture**: shell loads unauthenticated; first `/api/v1` call shows the auth-error state. Decide what that state looks like during execute (probably: a centered error card mirroring `EmptyState`'s tone, copy "not authenticated"). Not novel work, just deferred styling. + +### Invariants + +- `web/dist/` is git-ignored. Production embeds via `//go:embed all:dist/*` only — no committed build output. +- The forward-auth middleware code is unchanged. Dev-time auth is solved entirely in `web/vite.config.ts` (header injection). +- `internal/shared/wire/` is untouched. New aggregate fields live only in the HTTP response DTO (`internal/domain/session/repository.go::Session`), not the wire contract. +- All API calls go through TanStack Query. No raw `fetch` in components. +- All sortable/data values render in `var(--mono)`; prose renders in `var(--sans)`. No mixing. +- `GET /api/v1/sessions` response is a strict superset of today's — no removed or renamed fields. +- `tweaks-panel.jsx` is **not** ported. Neither is the runtime accent-off toggle. +- Routes that are shipped match the prototype pixel-for-pixel (tokens, type scale, row heights, spacing); stub routes render a single `EmptyState` and do not reserve layout for unimplemented content. + +### Principles + +- Pixel-match the prototype for shipped routes. The README's tokens, type scale, and spacing are authoritative; resist "polishing" them. +- Reconcile Go DTO ↔ prototype TS shape in a **single** adapter layer at the query boundary (`api/adapters.ts`). Components consume the prototype shapes; nothing else touches snake_case. +- Foundation ships shell + 2 routes; resist pre-building for routes that aren't shipped here. Stubs are placeholders, not skeletons. +- No CSS-in-JS, no Tailwind, no UI kit. Plain CSS files with the token names from `prototype.css`. +- Prefer the prototype's component boundaries (e.g. `proto-atoms.jsx` → `primitives/`, `proto-home.jsx` → `features/home/`) when porting, but rename to match repo conventions (PascalCase TS components, camelCase hooks). + +## Plan + +Approach: six commits — Phase 1 ships the backend aggregates Home depends on (Go-only, independently shippable); Phases 2–3 lay the frontend toolchain and Go-side embed; Phases 4–6 build shell + Home + Session in dependency order. TDD applies to the SQL aggregate, the API adapter, theme, and keyboard machine; visual port work is verified by eye against the handoff. + +### Phase 1 — Session List aggregates (Go) + +- **1.1** `internal/domain/session/repository.go:97-107` (modify) — `Session` struct gains five fields, all with matching `db` and `json` tags: + - `Summary string` (json `summary`) + - `TurnCount int64` (json `turn_count`) + - `TokensInTotal int64` (json `tokens_in_total`) + - `TokensOutTotal int64` (json `tokens_out_total`) + - `Model *string` (json `model,omitempty`) +- **1.2** `internal/domain/session/repository.go:176-189` (modify) — `sessionSelectColumns` stays as-is for `Get` path (which uses `SessionWithTurns` and computes nothing). Add a new `sessionListSelectColumns` const that wraps the base columns plus four correlated subqueries and a join to the `turns` table for `summary`/`model`. Centralize the SQL fragment so repository tests can assert column order. + - Invariant: response is a strict superset; `Get` path unchanged. +- **1.3** `internal/domain/session/repository.go:194-242` (modify) — `Repository.List` swaps to `sessionListSelectColumns`. ORDER/LIMIT/WHERE clauses unchanged. +- **1.4** `internal/domain/session/repository_test.go` (modify) — add `TestList_Aggregates` covering: zero turns (turn_count=0, tokens=0, summary="", model=nil); one user turn (summary truncated at 200 chars when content > 200); multiple turns with mixed roles (model = newest turn's model regardless of role); NULL token columns (sums treat NULL as 0). +- **1.5** `internal/domain/session/handler_test.go` (modify if any test asserts exact JSON shape) — extend fixtures so list-response assertions cover the new fields. +- Commit: `session: extend List response with summary, turn_count, token totals, model` + +### Phase 2 — Frontend scaffold + tokens + primitives + +- **2.1** `web/package.json` (create) — deps: `react`, `react-dom`, `@tanstack/react-router`, `@tanstack/react-query`, `@tanstack/router-vite-plugin`. devDeps: `vite`, `@vitejs/plugin-react`, `typescript`, `@types/react`, `@types/react-dom`, `vitest`, `@testing-library/react`, `eslint`, `@typescript-eslint/*`, `prettier`. Scripts: `dev`, `build`, `test`, `lint`, `typecheck`. +- **2.2** `web/vite.config.ts` (create) — react plugin + router plugin; `server.proxy` for `/api/v1`, `/healthz`, `/readyz`, `/metrics` to `http://127.0.0.1:18888`; proxy `configure` hook injects `req.headers['Remote-User'] = 'bigbes'` on every forwarded request. +- **2.3** `web/tsconfig.json`, `web/tsconfig.node.json`, `web/.eslintrc.cjs`, `web/.prettierrc` (create) — strict mode, react-jsx, ES2022 target. +- **2.4** `web/index.html` (create) — ``, body class `density-compact`, font preconnects. +- **2.5** `web/src/main.tsx` (create) — React root, `QueryClientProvider`, `RouterProvider`, theme bootstrap call. +- **2.6** `web/src/styles/tokens.css` (create) — port `prototype.css` `:root` and `[data-theme="dark"]` blocks verbatim. **Drop** the `[data-theme="dark"] { --accent: ...; ... }` inline-on-`` accent writes from `Prototype.html` (the CSS rules suffice). +- **2.7** `web/src/styles/primitives.css` (create) — port `.tag`, `.tool-dot`, `.spark`, `.status-dot`, `.empty-state`, `.sub` rules from `prototype.css`. +- **2.8** `web/src/primitives/{Tag,ToolDot,Spark,StatusDot,EmptyState,Sub}.tsx` (create) — direct ports from `proto-atoms.jsx`, retyped. Signatures: + - `Tag(props: { kind?: 'host'|'tool'|'accent'|'neutral'; onClick?: () => void; children: React.ReactNode }): JSX.Element` + - `ToolDot(props: { tool: 'claude-code'|'opencode'|'crush'|'pi'|'kimi' }): JSX.Element` + - `Spark(props: { points: number[]; w?: number; h?: number; accent?: boolean }): JSX.Element` + - `StatusDot(props: { status: 'ok'|'warn'|'err' }): JSX.Element` + - `EmptyState(props: { glyph: string; copy: string }): JSX.Element` +- **2.9** `web/.gitignore` (create) — `node_modules/`, `dist/`, `coverage/`. +- **2.10** Root `.gitignore` (modify) — append `web/dist/`, `web/node_modules/`. +- Commit: `web: scaffold vite/react/ts project, port design tokens and primitives` + +### Phase 3 — Go embed + build pipeline + +- **3.1** `internal/server/web/embed.go` (create) — `package web`. `//go:embed all:dist/*` on a `var distFS embed.FS`. Export `Handler() http.Handler` returning a `http.FileServer(http.FS(...))` wrapped with a SPA-fallback shim: any 404 from the file server rewrites to `index.html` and serves that with 200; paths starting with `/api/`, `/healthz`, `/readyz`, `/metrics` short-circuit to a `next` handler so the mount point doesn't swallow API routes. + - Signature: `func Handler() http.Handler` + - Invariant: `web/dist/` ignored; embed fails the build if absent. +- **3.2** `internal/server/web/dist/.gitkeep` (create) + `internal/server/web/dist/index.html` (create, placeholder) — required so `go build` succeeds in dev without first running `just web-build`. Real assets overwrite at build time. +- **3.3** `internal/server/server.go:91-99` (modify) — mount the embed handler. Order: API routes first (still under `/api/v1` with auth middleware), `/healthz`/`/readyz`/`/metrics` unchanged, then a catch-all `r.Handle("/*", web.Handler())`. The web handler returns 404 → SPA fallback → `index.html`. Auth middleware does not run on `/`. +- **3.4** `internal/server/server_test.go` (modify) — add `TestRouter_ServesSPAAtRoot` (GET `/` → 200 with HTML body), `TestRouter_SPAFallbackForNonAPIPath` (GET `/session/foo/bar/baz` → 200 HTML, not 404), `TestRouter_APIPathsBypassSPA` (GET `/api/v1/sessions` without auth → 401 problem+json, not HTML). +- **3.5** `Justfile` (modify, line 1-) — add targets: + - `web-install: cd web && npm ci` + - `web-dev: cd web && npm run dev` + - `web-build: cd web && npm run build` + - `web-test: cd web && npm test` + - `web-lint: cd web && npm run lint && npm run typecheck` + - Modify existing `build` to depend on `web-build` (or document `just web-build && just build`). +- **3.6** `Dockerfile` (modify) — add `node:20-alpine` builder stage that runs `npm ci && npm run build` against `web/`, copies `web/dist/` into the Go builder context before `go build`. +- **3.7** `.github/workflows/*` or `.sourcecraft/ci.yml` (modify if exists; create otherwise) — add a `web` job: `npm ci`, `npm run lint`, `npm run typecheck`, `npm test`, `npm run build`. Existing Go job stays. +- Commit: `server: embed web SPA at /, wire build pipeline` + +### Phase 4 — Shell + theme + keyboard + stubs + palette skeleton + +- **4.1** `web/src/lib/theme.ts` (create) — TDD. + - `bootstrapTheme(): void` — runs once at startup. Reads `localStorage.theme`; falls back to `window.matchMedia('(prefers-color-scheme: dark)')`. Sets `` accordingly. Adds a `change` listener on the media query that updates only when no `localStorage.theme` is set. + - `setTheme(theme: 'light' | 'dark' | null): void` — `null` clears the override (re-syncs with OS). +- **4.2** `web/src/lib/theme.test.ts` (create) — TDD targets: bootstrap with no localStorage and OS=dark → `data-theme=dark`; with `localStorage.theme=light` → `light` even when OS=dark; OS change while no override flips `data-theme`; `setTheme(null)` resyncs. +- **4.3** `web/src/lib/keyboard.ts` (create) — TDD. + - `createKeyboardController(opts: { go: (route: 'home'|'projects'|'stats'|'health') => void; openPalette: () => void; closePalette: () => void; cursor: { move(d: 1|-1): void; activate(): void } }): { onKeyDown(e: KeyboardEvent): void; teardown(): void }` — internal `gLeader` ref + 800 ms `setTimeout`. Cmd/Ctrl-K opens palette; Esc closes; `j`/`k` move cursor (only when not in ``); `↵` activates; `g` then `h|p|s|i` navigates. +- **4.4** `web/src/lib/keyboard.test.ts` (create) — TDD targets: `g` then `h` within 800 ms calls `go('home')`; `g` then nothing for 1 s → next `h` does nothing; `j`/`k` ignored when target is an input; ⌘K opens; Esc closes when palette open. +- **4.5** `web/src/shell/TopBar.tsx` (create) — port `proto-shell.jsx`. Brand crumb (`assistant-log / lethe`), search trigger button (`⌘K`), tab nav. Active tab via current router match. Dark always (`--topbar-bg`). + - Signature: `TopBar(props: { onPaletteOpen: () => void }): JSX.Element` +- **4.6** `web/src/shell/SubBar.tsx` (create) — outlet/slot used by routes; renders nothing if no children. + - Signature: `SubBar(props: { children?: React.ReactNode }): JSX.Element` +- **4.7** `web/src/shell/Palette.tsx` (create) — modal scrim; single ``; cursor list rendered from a single `JUMP` items array (`home`, `projects`, `stats`, `health`, `settings`); SEARCH synthetic row when input non-empty and no JUMP match. `↵` activates; `Esc` closes. Footer hint chip. + - Signature: `Palette(props: { open: boolean; onClose: () => void }): JSX.Element` +- **4.8** `web/src/styles/{shell,palette}.css` (create) — port `.topbar`, `.subbar`, `.palette` rules from `prototype.css`. +- **4.9** `web/src/routes/_app.tsx` (create) — TanStack root route; renders ``, ``, ``. Wires `keyboardController` to `document` on mount, `teardown()` on unmount. +- **4.10** `web/src/routes/{projects,stats,health,settings,search}.tsx` (create) — five files, each renders `` and a `` strip. +- **4.11** `web/src/routes/__root.tsx`, `web/src/routeTree.gen.ts` (create — generated by router plugin on first build). Route tree wiring follows TanStack Router file-based convention. +- Commit: `web: shell, theme, keyboard, stub routes, palette skeleton` + +### Phase 5 — Home route (real data) + +- **5.1** `web/src/api/client.ts` (create) — `apiFetch(path: string, init?: RequestInit): Promise` — 401 throws `AuthError`, 4xx/5xx with `application/problem+json` body throws `APIError(detail, code, status)`. JSON decode otherwise. +- **5.2** `web/src/api/adapters.ts` (create) — TDD. + - `type SessionDTO = { owner; tool; host; session_id; started_at; ended_at; working_dir?; summary; turn_count; tokens_in_total; tokens_out_total; model? }` (Go shape) + - `type Session = { id; tool; host; cwd; model?; started; ended; summary; turns; tokensIn; tokensOut; hasError }` (prototype shape) + - `adaptSession(d: SessionDTO): Session` — `id = ${tool}/${host}/${session_id}`; `cwd = working_dir ?? ''`; `started/ended` = ISO from unix; `hasError = false` (deferred). +- **5.3** `web/src/api/adapters.test.ts` (create) — TDD targets: ISO conversion for unix=0; missing working_dir → cwd=''; missing model → `model` undefined; round-trip composite ID. +- **5.4** `web/src/features/home/useSessions.ts` (create) — TanStack Query hook keyed `['sessions', filters]`; calls `apiFetch('/api/v1/sessions?…')`; pipes through `adaptSession`. + - Signature: `function useSessions(filters: HomeFilters): UseQueryResult` + - `type HomeFilters = { since?: '1d'|'7d'|'30d'|'90d'|'all'; tool?: Tool; host?: Host }` — `since` translates to a unix `since` query param. +- **5.5** `web/src/features/home/FilterChips.tsx` (create) — chip bar: `since`, `tool`, `host`, `+ filter`. Each chip a ``; click opens an absolute-positioned popover. State held in URL search params (TanStack Router-managed). +- **5.6** `web/src/features/home/SessionsTable.tsx` (create) — port `proto-home.jsx`. Grid columns: `STARTED · TOOL · HOST · SUMMARY · TURNS · TOK · CWD`. Cursor row `--accent-soft` bg + 2 px `--accent` left border. Click row → router push `/session/$tool/$host/$session_id`. EmptyState when result is empty after filters. + - Signature: `SessionsTable(props: { sessions: Session[]; cursor: number; onCursor: (i: number) => void; onOpen: (s: Session) => void }): JSX.Element` +- **5.7** `web/src/features/home/useHomeCursor.ts` (create) — small hook holding the cursor index, exposing `move(d)` and `activate()` for the keyboard controller. +- **5.8** `web/src/routes/index.tsx` (create) — Home route. Reads filters from URL, calls `useSessions`, wires `useHomeCursor`, registers cursor actions with the shell's keyboard controller via context, renders ``. +- **5.9** `web/src/styles/home.css` (create) — port `.home-table`, `.home-row`, etc. from `prototype.css`. +- Commit: `web: home route with real session list, filters, keyboard cursor` + +### Phase 6 — Session route (real data) + +- **6.1** `web/src/features/session/useSession.ts` (create) — TanStack Query hook keyed `['session', tool, host, sessionId]`; returns `{ session, turns }`. + - `adaptTurn(d: TurnDTO): Turn` — `i = seq`, role passthrough, body = content, optional model/tokens, `toolName/toolKind` parsed from `tool_calls` raw JSON when role=tool (best-effort; fall back to displaying raw content). +- **6.2** `web/src/features/session/TurnList.tsx` (create) — 240 px aside; rows of `# · role-glyph · preview · tok`. Selected row `--paper-2` bg + 2 px accent left border. Scrolls independently. +- **6.3** `web/src/features/session/Transcript.tsx` (create) — linear list of turn cards. USER (`--ink-4` left bar), ASSISTANT (`--accent` left bar), TOOL (no bar, `--turn-tool` bg, mono content). Markdown for user/assistant via `react-markdown` (or a 30-line minimal renderer if the dep is undesirable; flag in execute). +- **6.4** `web/src/routes/session.$tool.$host.$id.tsx` (create) — Session route. SubBar = breadcrumb + tags. Body = ` ` two-column. Click in TurnList scrolls to corresponding turn in Transcript and updates the cursor. +- **6.5** `web/src/styles/session.css` (create) — port `.session-aside`, `.turn`, `.turn.user`, `.turn.assistant`, `.turn.tool` rules. +- Commit: `web: session view with turn list and transcript` + +### Test strategy + +- **Phase 1** (TDD): the four behaviors enumerated under 1.4. SQL is exercised end-to-end through `Repository.List` against the in-memory test SQLite. +- **Phase 3**: SPA fallback + API bypass coverage in `server_test.go` (3.4). +- **Phase 4** (TDD): `theme.test.ts`, `keyboard.test.ts` per 4.2 and 4.4. +- **Phase 5** (TDD): `adapters.test.ts` per 5.3. +- **Phase 6**: not TDD'd; visual primitives + integration verified manually against the prototype. + +### Order & dependencies + +- Phase 1 ⊥ Phases 2–6 (Go-only; can land first or in parallel). +- Phase 2 → Phase 3 (embed needs `dist/` to exist; placeholder `.gitkeep` + stub `index.html` makes Go build green before npm build runs). +- Phase 3 → Phase 4 (router needs index.html to be served). +- Phase 4 → Phase 5 → Phase 6 (Home opens Session; Home needs aggregates from Phase 1). +- A phase 5 PR ships unblocked even if Phase 6 hasn't started — Home is the user-visible value gate. + +### Open questions / risks / rollback + +- **Markdown renderer (Phase 6.3)**: prototype uses none (it ships raw markdown text). Adding `react-markdown` is ~50 KB gzip; a 30-line custom renderer covers the 90% case. Decide during execute; default to the custom renderer unless the prototype's session screenshots show full markdown rendering (which they do — code blocks, lists, headings). Likely outcome: ship `react-markdown`. +- **Aggregate query plan (Phase 1)**: correlated subqueries are O(n) in the result page (40 by default). On `LIMIT 40` this is fine; on `LIMIT 200` it could matter. If `EXPLAIN QUERY PLAN` shows table scans on `turns`, add a covering index `(owner, tool, host, session_id, role, seq)` in a sibling commit. Not blocking the phase. +- **Rollback**: Phase 1 alone is safe to revert (additive JSON fields). Phases 2–6 are SPA-only; reverting any phase leaves the Go server fully functional with the existing `/api/v1`. The embed in Phase 3 means a revert there leaves the binary serving 404 on `/` — the current behavior — so it's a no-op for API users. + +### Backwards-compat check + +Restating from Design: only `GET /api/v1/sessions` changes shape, additively. The five new fields (`summary`, `turn_count`, `tokens_in_total`, `tokens_out_total`, `model`) are added; nothing is removed or renamed. No client today depends on the response shape (only consumer is the integration test, which we update). No schema migrations; no wire-type changes; no config-key changes.