feat: gen trace web viewer#22
Conversation
A localhost-only web UI for inspecting session transcripts under
~/.gen/projects/<encoded-cwd>/transcripts/. Read-only, single binary
(assets embedded via go:embed), no build step on the frontend.
Backend (internal/trace):
- HTTP server with three endpoints:
GET / SPA shell
GET /api/sessions list transcripts in the project
GET /api/sessions/{id}/records paginated record fetch
GET /api/sessions/{id}/stream SSE live tail
- The wire format is the JSONL on disk verbatim — server doesn't
reshape records. One schema, not two.
- Polling-based tailer (500ms tick); no fsnotify dependency.
- Path-traversal guard rejects sessionIDs containing slashes.
Frontend (internal/trace/ui/assets):
- Vanilla JS, ~200 lines. EventSource for SSE.
- Sessions sidebar | colored timeline | JSON detail panel.
- Per-group filter checkboxes (state, tools, system, inference,
message).
- Auto-scrolls when near the bottom; pauses on user scroll-up.
CLI (cmd/gen/trace.go):
- `gen trace` binds 127.0.0.1 on a random port by default, opens the
browser, blocks until Ctrl-C.
- --addr to pin a port; refuses any non-loopback host.
- --no-open to skip the browser launch (for headless / CI).
Tests cover the records endpoint, empty-project case, and the
path-traversal guard. Smoke-tested locally against real session files.
Two issues in the viewer's frontend. P1 (defense-in-depth XSS): s.id from /api/sessions flows into the sidebar li's innerHTML unescaped. Today the ID is always a UUID by construction, but filesystem-derived strings should never be trusted as HTML — the title beside it is already escaped, the inconsistency suggests an oversight. Wrap s.id.slice(0, 12) in escapeHTML(). P2 (timeline duplicates on reconnect): browsers auto-reconnect EventSource on transient drops (laptop sleep, localhost blip). The server starts each stream from offset 0 and replays the entire file, so every reconnect re-emitted every record and onmessage pushed them all into state.records. After a sleep/wake the timeline doubled, tripled, etc. Fix: maintain a state.seenIDs Set keyed by each record's stable id field. Skip on duplicate. Cleared in openSession alongside records[]. The server-side replay strategy is left unchanged — a follow-up could honor Last-Event-ID to skip already-streamed bytes, but dedup-on-client is the correctness fix and lets the timeline survive reconnects today.
|
Both fixed in cd53cbc (#23 rebased on top). P1 — XSS escape: `s.id.slice(0, 12)` is now wrapped in `escapeHTML()`, consistent with how the adjacent title is handled. Today's IDs are UUIDs and safe by construction, but treating filesystem-derived strings as HTML-trusted is the wrong default — fixing in case the ID generator changes or someone hand-renames a file. P2 — SSE reconnect dedup: added `state.seenIDs: Set` keyed by each record's stable `id` field. `openSession` clears it alongside `records[]`. The `onmessage` handler skips records whose id is already known, so a browser auto-reconnect (which replays the file from offset 0 server-side) no longer duplicates the timeline. Left as a separate follow-up: honoring `Last-Event-ID` on the server so reconnects skip the replay entirely. The client-side dedup is the correctness fix; the server-side optimization is just bandwidth. The MVP can ship without it. |
PR 4 of 5 — stacked on #21.
```
gen trace # opens viewer in browser
gen trace --addr 127.0.0.1:38080 # pin port
gen trace --no-open # print URL only
```
Test
🤖 Generated with Claude Code