~bigbes/lethe

ref: b100feee542aaacd159d90f8c35b2c68a26e8893 lethe/docs/tasks/lethe-web-ui-foundation.md -rw-r--r-- 26.5 KiB
b100feee — Eugene Blikh web: home route with real session list, filters, keyboard cursor a month ago

#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 "<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.