~bigbes/lethe

ref: dd2e2ea9b78b96a0d88f24012a4e429f4c1d4dfb lethe/docs/tasks/lethe-web-ui-foundation.md -rw-r--r-- 37.8 KiB
dd2e2ea9 — Eugene Blikh docs(lethe-web-ui-foundation): record review conclusion, mark Reviewed a month ago

#lethe-web-ui-foundation

Status: Reviewed 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 "<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.

#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; <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

#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 <html>.
  • Density: hard-coded compact in foundation (README's recommended default). Toggle ships with Settings.
  • Inline-on-<html> 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.jsxprimitives/, proto-home.jsxfeatures/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) — <html lang="en">, 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-<html> 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 <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): voidnull 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=lightlight 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 <input>); 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 <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.
    • 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 <TopBar>, <Outlet>, <Palette>. 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 <EmptyState glyph="∅" copy="coming in <task-slug>" /> and a <Sub> 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<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.
  • 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): Sessionid = ${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<Session[]>
    • 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 <Tag class="click">; 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 <SubBar><FilterChips/></SubBar><SessionsTable/>.
  • 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): Turni = 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 = <TurnList/> <Transcript/> 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.

#Verify

Result: passed

Date: 2026-04-26. Run against master HEAD (a93ad6a).

Positive:

  • go test ./... -race -count=1 → green (11 packages, no skips)
  • npm test (vitest) → 32 passed across 3 files (adapters, theme, keyboard)
  • npm run typecheck, npm run lint, npm run build → all clean; build emits internal/server/web/dist/index.html + 463.84 KB JS / 144.17 KB gzip
  • Built binary serves SPA at / (text/html); /healthz 200, /readyz 200 with {"checks":{"database":"ok"}}
  • GET /api/v1/sessions (auth) → 200 with new aggregate fields populated (summary, turn_count, tokens_in_total, tokens_out_total, model when present)
  • GET /api/v1/sessions/{tool}/{host}/{id} (auth) → 200 with turns[] in seq order
  • Browser smoke (Chrome via vite dev proxy): Home renders 2 ingested rows with mono columns, sans summary, 1.5k token formatting, ToolDot colors flipping per tool, lime cursor row; click row → Session view with role-coded turns and react-markdown code-block rendering; ⌘K → palette opens with JUMP items + footer hints; tab click Stats/stats stub renders EmptyState

Negative:

  • GET /api/v1/sessions no auth → 401 application/problem+json
  • GET /no-such-spa-path → 200 SPA fallback HTML (client-routed)
  • POST /healthz → 405 application/problem+json (proves SPA /* is GET-only)
  • GET /api/v1/sessions/foo/bar/nope (auth) → 404 application/problem+json (API path bypasses SPA)

Invariants:

  • internal/shared/wire/ untouched in this task (no commits in 1af5bcb..HEAD affect it)
  • internal/server/auth/ untouched in this task (forward-auth code unchanged)
  • web/dist/ has zero committed files; internal/server/web/dist/ committed = .gitkeep + placeholder index.html only
  • No tweaks-panel or runtime accent toggle ported (grep -rn 'tweak\|Tweak' web/src empty)
  • Only one raw fetch( in web/src and it's in api/client.ts (the central wrapper)
  • /api/v1/sessions response is a strict superset (existing nine fields all present alongside the five new aggregate fields)
  • Body class density-compact is the default in web/index.html

Smoke: ./tmp/lethe -config tmp/lethe.yaml + npm run dev → end-to-end Home + Session + Palette + tab nav verified visually. Three screenshots captured and reviewed.

Notes:

  • g-leader keyboard chord could not be smoke-tested via Chrome DevTools MCP: each press_key call carries hundreds of milliseconds of MCP serialization latency, exceeding the controller's 800 ms g-pending timeout. Verified by running an isolated minimal chord handler in the page that reproduced the same null result. Production code is unaffected (real human typing is sub-100ms between keys); unit tests in web/src/lib/keyboard.test.ts cover the timing logic with vi.useFakeTimers().
  • Composite-id-in-URL (functional but ugly): clicking a Home row navigates to /session/$tool/$host/<encoded-composite> because the Link in SessionsTable passes params.id = session.id (which is the full composite ${tool}/${host}/${session_id}). Phase 6's useSession adapter splits the composite back, so the Session view loads correctly. Fix is a one-liner (pass the bare session_id instead) — deferred as a follow-up since it's cosmetic, not functional.
  • Session aggregate fields on the Get endpoint return zeros: GET /api/v1/sessions/{tool}/{host}/{id} reuses the Session Go struct via SessionWithTurns, so the new summary, turn_count, tokens_in_total, tokens_out_total fields appear in JSON as zero values. By design (Plan 1.2 said the Get path is unchanged); the SPA's Session view computes from turns[] directly, so this is invisible to the UI. Worth flagging because a future API consumer reading summary from a Get response would see an empty string.
  • go test ./... walks web/node_modules/flatted/golang/pkg/flatted: a stray Go package shipped inside an npm dep. Reports [no test files], no failure. Cleanup options: gitignore the path or use go test ./internal/... in CI. Not a verify failure.

#Conclusion

Outcome: foundation slice landed across 9 commits (1af5bcb..136ae1f) plus 2 review-fix commits (4ef7a02, 0cf348a); end-to-end browser smoke passed.

#Invariants

  • internal/shared/wire/ untouched — git log 85a2dd3..HEAD -- internal/shared/wire/ empty.
  • internal/server/auth/ untouched — git log 85a2dd3..HEAD -- internal/server/auth/ empty.
  • GET /api/v1/sessions is a strict superset (existing 9 fields + 5 new aggregates) — verified via curl and repository_test.go.
  • All API calls go through TanStack Query; only one raw fetch( in web/src, in api/client.ts.
  • web/dist/ has zero committed files; internal/server/web/dist/ has only .gitkeep + placeholder index.html.
  • tweaks-panel.jsx not ported; runtime accent toggle not surfaced.
  • All HTTP error paths render application/problem+json (including 401/403/404/405/415).

#Review findings

  • Important: keyboard g-leader and j/k did not guard against palette-open or editable target — typing gh into the palette input fired go('home') mid-query. Fixed in 4ef7a02 with an early-return after Esc handling, plus two regression tests.
  • Important: Dockerfile web-builder worked only by relative-path coincidence (vite output landing at the container root because WORKDIR /web shifted the relative outDir up one level). Fixed in 0cf348a by mirroring the host repo layout (WORKDIR /src, COPY web/ web/). While validating the build, also discovered golang:1.25-alpine no longer compiles the codebase (the auxilia/culpa dep needs errors.AsType, which is in 1.26+); bumped builder image to golang:1.26-alpine and go.mod directive to 1.26.0 in the same commit. docker build now produces a runnable binary end-to-end.

#Future work

  • Composite-id-in-URL (Phase 5): SessionsTable Link passes params.id = session.id (full composite). Session view loads correctly because useSession adapter splits it back, but the URL is /session/$tool/$host/$tool%2F$host%2F$id. One-line fix in SessionsTable (pass bare session_id). Justification: cosmetic, not functional; deferred so this commit set stays scoped to fixing reviewer findings.
  • Aggregates absent on Get endpoint: GET /api/v1/sessions/{tool}/{host}/{id} returns the new summary/turn_count/tokens_*_total fields as zero values because the Get path uses sessionSelectColumns (Plan 1.2 said Get unchanged). The SPA reads turns[] directly so it's invisible in this UI, but a future API consumer would see misleading zeros. Justification: explicitly scoped out by Plan 1.2 (Get path unchanged); fixing requires either extending the Get SQL or omitempty on the four numeric tags; either way, scope creep here.
  • Turn meta line lacks timestamp (Phase 6): the Turn TS interface in Plan 6.1 didn't include timestamp, so the meta shows # seq · model · tokens-in→tokens-out only. Justification: explicitly scoped to the spec'd interface; adding it is an additive change in a follow-up.
  • go test ./... walks web/node_modules/flatted/golang/pkg/flatted: stray Go package shipped inside an npm dep; reports [no test files], no failure. Justification: cosmetic; can be addressed by switching CI to go test ./internal/... or gitignoring the path.

#Deferred (needs user input)

  • CI configuration: the plan calls for .github/workflows/* or .sourcecraft/ci.yml; neither directory exists in the repo. The CI job (npm ci && npm run build && npm test && npm run lint) is not implemented. To resume: confirm sourcecraft.dev's CI file format and create .sourcecraft/ci.yml, or add .github/workflows/web.yml if GitHub mirroring is in scope.

#Deviations from plan

  • Phase 2 — vite outDir: set to ../internal/server/web/dist/ so vite output lands directly in the directory the Go embed will read in Phase 3. Plan implied a web/dist/ location with an unspecified copy step; collapsing to a single dist location removes that step entirely. Root .gitignore updated accordingly (internal/server/web/dist/* with .gitkeep exception).

  • Phase 2 — eslint v8 (vs v9): ESLint v9 dropped .eslintrc.cjs support in favor of flat config; downgrade to ^8.57.1 keeps the plan's config format. Acceptable until a future cleanup migrates to flat config.

  • Phase 2 — separate web/vitest.config.ts: the TanStack Router vite plugin fails at config-resolve when src/routes/ is missing or empty; vitest uses its own config that omits the Router plugin so npm test runs in this phase before routes exist. Phase 4 will populate routes; the separate file remains useful for test-only config divergence.

  • Phase 2 — web/src/routes/__root.tsx placeholder: required to satisfy the TanStack Router plugin scan at build time; minimal stub that Phase 4 replaces with the real shell-mounted root.

  • Phase 2 — web/src/test-setup.ts: implied by the testing infrastructure; sets up @testing-library/jest-dom matchers.

  • Phase 3 — r.Get("/*", …) vs r.Handle("/*", …): plan said Handle (any-method); implementation uses Get (and HEAD). Reason: Handle made POST /healthz return 200 (SPA HTML) instead of 405 problem+json, regressing the existing TestRouter_MethodNotAllowedReturnsProblemJSON invariant. SPA only needs GET (browser navigation); restricting the catch-all is more correct and preserves the 405 behavior for non-GET on unknown paths.

  • Phase 3 — TestRouter_NotFoundReturnsProblemJSON retargeted: with /* mounted, chi returns 405 (not 404) for non-GET on unknown paths because /* matches the path. The existing notFoundHandler is still wired (in case chi reaches it) but the routing path that previously hit it no longer does. The test now invokes notFoundHandler directly to assert its output.

  • Phase 5 — keyboard cursor wiring (retroactive Phase 4 amendment): Phase 4 left a static no-op cursor: { move, activate } in __root.tsx's keyboard controller. Phase 5 added cursorRef + KeyboardCursorContext so route-local components can register their cursor and the controller delegates through the ref. This is the pattern the dispatcher's Phase 5 brief instructed; recording so it isn't conflated with Phase 4's omission.

  • Phase 5 — useHomeCursor.jumpTo(i): plan specified move/activate only; jumpTo added so SessionsTable.onCursor can set the cursor on mouse hover/click. Pure additive surface; no plan invariant violated.

  • Phase 5 — FilterChips "active filter" set is component-local: plan said filters are URL-driven. The actual filter values (since, tool, host) ARE URL-driven; what's local is "which chips are shown" (e.g. + filter adds host to the displayed chip list). No URL representation was specified for that and adding one is gratuitous.

  • Phase 5 — ToolDot tool prop widened from literal union to string: real ingest data may include tools outside the prototype's five. CSS class still falls through cleanly.

  • Phase 5 — session.$tool.$host.$id.tsx placeholder created: needed so TanStack Router types accept Link navigation from Home. Phase 6 replaces the placeholder with the real Session view.

  • Phase 6 — SessionWithTurns extends Omit<Session, 'turns'>: Session.turns is the aggregate count (number) from Phase 5; SessionWithTurns.turns is Turn[]. Omit resolves the conflict cleanly. SubBar turn-count display reads session.turns.length.