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)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.
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.
| 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 "<q>" 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.
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.
web/)web/
├── package.json
├── vite.config.ts # proxy + Remote-User injection
├── tsconfig.json
├── index.html # entry; <html data-theme="…">
├── 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
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.web job that runs npm ci && npm run build && npm test && npm run lint. Existing Go job stays as-is.node:20-alpine step that produces web/dist/ before the Go build.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).prefers-color-scheme on first load; no user toggle in foundation. (Toggle ships with Settings.) data-theme="light|dark" attribute on <html>.compact in foundation (README's recommended default). Toggle ships with Settings.<html> accent variable writes from Prototype.html are dropped — the CSS rules suffice; the runtime "accent off" toggle is exploration-only and not shipped.| 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.
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 /./ 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).Per the up:test-driven-development applicability rule — "deterministic, reusable code where regressions would warrant a CI red light":
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).Tag, ToolDot, Spark), shell layout components, routing wiring — verified by eye against the prototype./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.web/dist/ is git-ignored. Production embeds via //go:embed all:dist/* only — no committed build output.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.fetch in components.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.EmptyState and do not reserve layout for unimplemented content.api/adapters.ts). Components consume the prototype shapes; nothing else touches snake_case.prototype.css.proto-atoms.jsx → primitives/, proto-home.jsx → features/home/) when porting, but rename to match repo conventions (PascalCase TS components, camelCase hooks).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.
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)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.
Get path unchanged.internal/domain/session/repository.go:194-242 (modify) — Repository.List swaps to sessionListSelectColumns. ORDER/LIMIT/WHERE clauses unchanged.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).internal/domain/session/handler_test.go (modify if any test asserts exact JSON shape) — extend fixtures so list-response assertions cover the new fields.session: extend List response with summary, turn_count, token totals, modelweb/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.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.web/tsconfig.json, web/tsconfig.node.json, web/.eslintrc.cjs, web/.prettierrc (create) — strict mode, react-jsx, ES2022 target.web/index.html (create) — <html lang="en">, body class density-compact, font preconnects.web/src/main.tsx (create) — React root, QueryClientProvider, RouterProvider, theme bootstrap call.web/src/styles/tokens.css (create) — port prototype.css :root and [data-theme="dark"] blocks verbatim. Drop the [data-theme="dark"] { --accent: ...; ... } inline-on-<html> accent writes from Prototype.html (the CSS rules suffice).web/src/styles/primitives.css (create) — port .tag, .tool-dot, .spark, .status-dot, .empty-state, .sub rules from prototype.css.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.ElementToolDot(props: { tool: 'claude-code'|'opencode'|'crush'|'pi'|'kimi' }): JSX.ElementSpark(props: { points: number[]; w?: number; h?: number; accent?: boolean }): JSX.ElementStatusDot(props: { status: 'ok'|'warn'|'err' }): JSX.ElementEmptyState(props: { glyph: string; copy: string }): JSX.Elementweb/.gitignore (create) — node_modules/, dist/, coverage/..gitignore (modify) — append web/dist/, web/node_modules/.web: scaffold vite/react/ts project, port design tokens and primitivesinternal/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.
func Handler() http.Handlerweb/dist/ ignored; embed fails the build if absent.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.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 /.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).Justfile (modify, line 1-) — add targets:
web-install: cd web && npm ciweb-dev: cd web && npm run devweb-build: cd web && npm run buildweb-test: cd web && npm testweb-lint: cd web && npm run lint && npm run typecheckbuild to depend on web-build (or document just web-build && just build).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..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.server: embed web SPA at /, wire build pipelineweb/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 <html data-theme> 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).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.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 <input>); ↵ activates; g then h|p|s|i navigates.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.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).
TopBar(props: { onPaletteOpen: () => void }): JSX.Elementweb/src/shell/SubBar.tsx (create) — outlet/slot used by routes; renders nothing if no children.
SubBar(props: { children?: React.ReactNode }): JSX.Elementweb/src/shell/Palette.tsx (create) — modal scrim; single <input>; 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.
Palette(props: { open: boolean; onClose: () => void }): JSX.Elementweb/src/styles/{shell,palette}.css (create) — port .topbar, .subbar, .palette rules from prototype.css.web/src/routes/_app.tsx (create) — TanStack root route; renders <TopBar>, <Outlet>, <Palette>. Wires keyboardController to document on mount, teardown() on unmount.web/src/routes/{projects,stats,health,settings,search}.tsx (create) — five files, each renders <EmptyState glyph="∅" copy="coming in <task-slug>" /> and a <Sub> strip.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.web: shell, theme, keyboard, stub routes, palette skeletonweb/src/api/client.ts (create) — apiFetch<T>(path: string, init?: RequestInit): Promise<T> — 401 throws AuthError, 4xx/5xx with application/problem+json body throws APIError(detail, code, status). JSON decode otherwise.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).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.web/src/features/home/useSessions.ts (create) — TanStack Query hook keyed ['sessions', filters]; calls apiFetch('/api/v1/sessions?…'); pipes through adaptSession.
function useSessions(filters: HomeFilters): UseQueryResult<Session[]>type HomeFilters = { since?: '1d'|'7d'|'30d'|'90d'|'all'; tool?: Tool; host?: Host } — since translates to a unix since query param.web/src/features/home/FilterChips.tsx (create) — chip bar: since, tool, host, + filter. Each chip a <Tag class="click">; click opens an absolute-positioned popover. State held in URL search params (TanStack Router-managed).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.
SessionsTable(props: { sessions: Session[]; cursor: number; onCursor: (i: number) => void; onOpen: (s: Session) => void }): JSX.Elementweb/src/features/home/useHomeCursor.ts (create) — small hook holding the cursor index, exposing move(d) and activate() for the keyboard controller.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 <SubBar><FilterChips/></SubBar><SessionsTable/>.web/src/styles/home.css (create) — port .home-table, .home-row, etc. from prototype.css.web: home route with real session list, filters, keyboard cursorweb/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).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.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).web/src/routes/session.$tool.$host.$id.tsx (create) — Session route. SubBar = breadcrumb + tags. Body = <TurnList/> <Transcript/> two-column. Click in TurnList scrolls to corresponding turn in Transcript and updates the cursor.web/src/styles/session.css (create) — port .session-aside, .turn, .turn.user, .turn.assistant, .turn.tool rules.web: session view with turn list and transcriptRepository.List against the in-memory test SQLite.server_test.go (3.4).theme.test.ts, keyboard.test.ts per 4.2 and 4.4.adapters.test.ts per 5.3.dist/ to exist; placeholder .gitkeep + stub index.html makes Go build green before npm build runs).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.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./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.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.