Skip to content

feat: gen trace web viewer#22

Merged
yanmxa merged 2 commits into
trace/3-recorderfrom
trace/4-viewer
May 15, 2026
Merged

feat: gen trace web viewer#22
yanmxa merged 2 commits into
trace/3-recorderfrom
trace/4-viewer

Conversation

@yanmxa
Copy link
Copy Markdown
Member

@yanmxa yanmxa commented May 15, 2026

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
```

  • Backend (`internal/trace`): localhost HTTP server. Three routes (`/api/sessions`, `/records`, `/stream`). Polling-based tailer (500ms). SSE for live updates. JSONL is the wire format.
  • Frontend (`internal/trace/ui/assets`): vanilla JS, no build step. Sidebar + timeline + JSON detail. Filter checkboxes. Auto-scroll near bottom.
  • CLI (`cmd/gen/trace.go`): refuses non-loopback `--addr`. Path-traversal guard on session IDs.

Test

  • `go test ./...` — 51 packages pass (new `internal/trace` tests)
  • Smoke: built binary, real session data renders in browser

🤖 Generated with Claude Code

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.
@yanmxa
Copy link
Copy Markdown
Member Author

yanmxa commented May 15, 2026

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.

@yanmxa yanmxa merged commit ddd3d88 into trace/3-recorder May 15, 2026
@yanmxa yanmxa deleted the trace/4-viewer branch May 15, 2026 15:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant