Transcript viewer: read an archived Claude Code session in the browser #64

Merged
mph merged 19 commits from cc-chat into main 2026-08-20 14:36:59 +00:00
Owner

Batch close of the cc-chat effort worktree: one slice, 01-transcript-viewer.

The slice

Goal (frozen card). I can point the viewer at one of my own archived Claude Code sessions
and read that conversation in the browser — my turns, the agent's turns, and what the agent
did — accurately enough that I use it instead of reading the raw JSONL.

Fit test. Open a real session and read it: nothing I know happened is missing, nothing
foreign is present, and "what happened in this session?" is answered correctly.

Fit passed on 2026-08-20, on Michal's own read in a browser.

What ships

  • A vendor-neutral transcript contract (ui/shared, cross-built to JVM and Scala.js):
    TranscriptEntry (user message / agent message / activity), Transcript, a hand-written
    TranscriptCodec, TranscriptEndpoint, and TranscriptFailure carrying its own status.
  • Tapir endpoint descriptions (TranscriptApi) and pure serving logic (TranscriptHistory),
    JVM-only, so a consumer attaches serverLogic and interprets them with whatever Tapir server
    its effect system already runs. The browser bundle carries none of it.
  • A Claude Code adapter (ui/src/.../claudecode) — the only place that knows the vendor.
  • The <cc-chat> custom element (ui/js), Laminar, delivered as a content-hashed classpath
    resource from the jar. 192 KB gzip against the 250 KB CI gate.
  • ui.demo — an unpublished ZIO/Netty host that mounts the descriptions and reads the local
    archive, which is the proof that ui names neither ZIO nor a server.

What the archive taught us

Every fidelity decision here was measured against the local transcript files, not argued:

  • A user entry is not a person speaking. isMeta marks injected command and skill bodies;
    isCompactSummary marks the summary written on compaction. The parser dropped both, so the
    viewer put 85 compaction summaries across 1916 files into Michal's mouth.
  • ContentBlockParser discarded every tool input, and read tool_result content only in its
    string form — the archive writes 2196 as content blocks against 345 as strings.
  • A tool result is not always prose: 3551 tool-search results answer with tool_reference
    blocks and screen tools with image blocks. All of them rendered as an empty row.
  • Extended thinking is stored as a signature with empty text in 21830 of 22503 blocks. The
    emptiness is faithful; offering a disclosure that opens onto nothing was not.
  • Sub-agent demotion by parentToolUseId, recorded in ADR 0002 as knowledge to carry over,
    cannot happen: across 229818 entries, no entry carries that field and none is a sidechain.

Decisions recorded in ADR 0002

  • CC-D1 — no raw vendor JSONL on the wire; the endpoint serves the contract's own shape.
    This settles the ADR's deferred normalized-history question.
  • CC-D2 — one adapter per agent tool; the element and the cross-built sources name no
    vendor type, so a second agent tool is a second adapter and nothing else moves.

uispike is deleted — the card forbids two generations coexisting, and the rule the ADR sets
for procedures binds this repository first.

Review

Seven skills in parallel at close (style, testing, security, scala3, architecture, api, zio).
Full report: project-management/efforts/cc-chat/slices/01-transcript-viewer/review-slice-2026-08-20.md.

One critical, found independently by two reviewers and fixed: an archive I/O failure put the
absolute path it failed on into the 500 body. TranscriptFailure.Unreadable now carries no
payload at all, so the leak is impossible rather than merely avoided; the host logs the cause on
its own side.

Five more fixed in-loop: a rejected id is echoed back bounded and without control characters;
the Netty binding is released on interrupt; a docstring claiming a boundary the data path does
not apply now says what is true; the asset read is attemptBlocking; four untested refusal
paths in ContentBlockParser are covered.

Follow-ups (deferred deliberately, flushed to the effort map)

  • Presentation slice — the next slice. Markdown renders as literal ** and backticks;
    wrapper tags and ANSI codes show raw in activity bodies; nested tool inputs flatten into
    an unreadable run-on; the reading column is split across the full page width.
  • Contract decisions, cheapest now while there are no external consumers: ADR 0002 calls
    this "versioned" and nothing in it is; the session probably belongs in the path rather
    than a query parameter; the endpoint's bare String input makes a future paging cursor
    source-breaking; Schema.anyObject leaves generated docs empty; the transcript response
    sets no cache directive.
  • Published-model changes: isMeta/isCompactSummary should be one EntryOrigin enum;
    JsonValue.of should return an enum rather than Any.
  • Test debt: 17 positional ConversationLogEntry(_, _, …) patterns break on every
    envelope field addition; TranscriptElement.show is untested.
  • Sub-agent transcripts, session listing, paging, per-tool rendering vocabulary.

Gates

818/818 tests, checkFormat and fix --check clean, bundle 192 KB gzip against the 250 KB gate.
Refusals verified over HTTP: real session 200, unknown 404, ../../etc/passwd 400 with nothing
leaked, missing parameter 400.

🤖 Generated with Claude Code

Batch close of the `cc-chat` effort worktree: one slice, `01-transcript-viewer`. ## The slice **Goal (frozen card).** I can point the viewer at one of my own archived Claude Code sessions and read that conversation in the browser — my turns, the agent's turns, and what the agent did — accurately enough that I use it instead of reading the raw JSONL. **Fit test.** Open a real session and read it: nothing I know happened is missing, nothing foreign is present, and "what happened in this session?" is answered correctly. **Fit passed** on 2026-08-20, on Michal's own read in a browser. ## What ships - **A vendor-neutral transcript contract** (`ui/shared`, cross-built to JVM and Scala.js): `TranscriptEntry` (user message / agent message / activity), `Transcript`, a hand-written `TranscriptCodec`, `TranscriptEndpoint`, and `TranscriptFailure` carrying its own status. - **Tapir endpoint descriptions** (`TranscriptApi`) and pure serving logic (`TranscriptHistory`), JVM-only, so a consumer attaches `serverLogic` and interprets them with whatever Tapir server its effect system already runs. The browser bundle carries none of it. - **A Claude Code adapter** (`ui/src/.../claudecode`) — the only place that knows the vendor. - **The `<cc-chat>` custom element** (`ui/js`), Laminar, delivered as a content-hashed classpath resource from the jar. 192 KB gzip against the 250 KB CI gate. - **`ui.demo`** — an unpublished ZIO/Netty host that mounts the descriptions and reads the local archive, which is the proof that `ui` names neither ZIO nor a server. ## What the archive taught us Every fidelity decision here was measured against the local transcript files, not argued: - A `user` entry is not a person speaking. `isMeta` marks injected command and skill bodies; `isCompactSummary` marks the summary written on compaction. The parser dropped both, so the viewer put **85 compaction summaries across 1916 files** into Michal's mouth. - `ContentBlockParser` discarded every tool input, and read `tool_result` content only in its string form — the archive writes 2196 as content blocks against 345 as strings. - A tool result is not always prose: **3551 tool-search results** answer with `tool_reference` blocks and screen tools with `image` blocks. All of them rendered as an empty row. - Extended thinking is stored as a signature with empty text in **21830 of 22503 blocks**. The emptiness is faithful; offering a disclosure that opens onto nothing was not. - Sub-agent demotion by `parentToolUseId`, recorded in ADR 0002 as knowledge to carry over, **cannot happen**: across 229818 entries, no entry carries that field and none is a sidechain. ## Decisions recorded in ADR 0002 - **CC-D1** — no raw vendor JSONL on the wire; the endpoint serves the contract's own shape. This settles the ADR's deferred normalized-history question. - **CC-D2** — one adapter per agent tool; the element and the cross-built sources name no vendor type, so a second agent tool is a second adapter and nothing else moves. `uispike` is deleted — the card forbids two generations coexisting, and the rule the ADR sets for `procedures` binds this repository first. ## Review Seven skills in parallel at close (style, testing, security, scala3, architecture, api, zio). Full report: `project-management/efforts/cc-chat/slices/01-transcript-viewer/review-slice-2026-08-20.md`. **One critical, found independently by two reviewers and fixed:** an archive I/O failure put the absolute path it failed on into the 500 body. `TranscriptFailure.Unreadable` now carries no payload at all, so the leak is impossible rather than merely avoided; the host logs the cause on its own side. Five more fixed in-loop: a rejected id is echoed back bounded and without control characters; the Netty binding is released on interrupt; a docstring claiming a boundary the data path does not apply now says what is true; the asset read is `attemptBlocking`; four untested refusal paths in `ContentBlockParser` are covered. ## Follow-ups (deferred deliberately, flushed to the effort map) - [ ] **Presentation slice** — the next slice. Markdown renders as literal `**` and backticks; wrapper tags and ANSI codes show raw in activity bodies; nested tool inputs flatten into an unreadable run-on; the reading column is split across the full page width. - [ ] **Contract decisions, cheapest now while there are no external consumers:** ADR 0002 calls this "versioned" and nothing in it is; the session probably belongs in the path rather than a query parameter; the endpoint's bare `String` input makes a future paging cursor source-breaking; `Schema.anyObject` leaves generated docs empty; the transcript response sets no cache directive. - [ ] **Published-model changes:** `isMeta`/`isCompactSummary` should be one `EntryOrigin` enum; `JsonValue.of` should return an enum rather than `Any`. - [ ] **Test debt:** 17 positional `ConversationLogEntry(_, _, …)` patterns break on every envelope field addition; `TranscriptElement.show` is untested. - [ ] Sub-agent transcripts, session listing, paging, per-tool rendering vocabulary. ## Gates 818/818 tests, `checkFormat` and `fix --check` clean, bundle 192 KB gzip against the 250 KB gate. Refusals verified over HTTP: real session 200, unknown 404, `../../etc/passwd` 400 with nothing leaked, missing parameter 400. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
mph added 17 commits 2026-08-20 12:34:56 +00:00
Carve the first slice of the cc-chat effort: a transcript viewer for one
session, named by id, newest page only.

Record CC-D2 — the ui module owns a vendor-neutral transcript model and
reaches each agent tool through an adapter. Includes the measured correction
that sub-agent turns are separate transcripts, not sidechain entries in the
main one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `ui` module owns the transcript a reader sees — user message, agent
message, activity — with a hand-written JSON codec whose keys are this
contract's own (CC-D1) and which skips entry kinds a reader's version does
not know.

The Claude Code adapter reads that model out of conversation log entries.
A `user` log entry is not the same thing as a person speaking: Claude Code
writes expanded slash-command and skill bodies, tool results, and its own
notifications as `user` entries too. Measured across the local archive,
`isMeta` marks exactly the text the tool injected (11 of 444 turns: command
bodies, skill bodies, resumption prompts), and a further 7 turns are nothing
but a wrapper block. Both read as activity, so no words are put in the
person's mouth. The parser was dropping `isMeta`, so the envelope now
carries it.
The history endpoint is described once — path, session parameter, and the
refusals a reader can meet — and compiled into both halves, so the client
builds the request the host reads. The element loads it and renders the three
categories as text, never markup; a collapsed detail keeps a 500-line command
body from swallowing the page.

The linked bundle ships in the ui jar under a content-hashed path, and the CI
size gate now measures it: 191 KB gzip against the 250 KB gate.

ui.demo is the reaction surface — not published, and the only place that names
an archive interpreter or an HTTP server. It reads the session named in the URL
from the local archive at request time; no transcript is stored here.
Reading a real session in the browser showed the viewer answering "which
tools ran" but not "what happened". Three causes, all real:

- ContentBlockParser threw every tool input away (`Map.empty // Simplified
  for now`), so a call could never say what it ran. It also read tool_result
  content only in its string form, and across 60 local transcripts that form
  is the minority — 2196 results are written as content blocks against 345 as
  strings, so most result text was dropped. Both are now read; a result's
  string form is kept verbatim, and a block list yields its text.
- The one definition of "JSON as plain data" now lives in JsonValue, instead
  of the same nine lines copied into two parsers.
- Activity rows showed a bare label. They now carry a one-line preview of the
  body, so a column of tool rows reads as a story; nested inputs are spelled
  out rather than printed in Scala's own collection notation.

The element's disclosure was also unusable: the marker was hidden and the
opened body was squeezed to the width of its own summary by a flex parent.

Bundle: 192 KB gzip, gate 256 KB.
Tapir belongs here after all, and my earlier reasoning had the premise wrong.
I weighed it as a way for the client to derive its call, priced that against
the browser bundle, and left it out. The value is on the other side: a
consumer gets endpoint descriptions it attaches `serverLogic` to and mounts
on whichever Tapir server its own effect system uses. So Tapir is a JVM-only
dependency of `ui` — the cross-built contract sources stay free of it and the
element still fetches — and it costs the bundle nothing. TranscriptApi is
built from TranscriptEndpoint's constants, so the description and the URL the
element builds cannot drift; a test holds them together.

TranscriptHistory is the serving logic as pure functions, so a host supplies
only the archive read it cannot avoid, and every effect system answers alike.
The demo now mounts those descriptions on tapir-netty-server-zio, which is
the point: ui names neither ZIO nor a server, and it still runs on both.

Claude Code keeps extended thinking as a signature with empty text far more
often than not — 1251 of 1592 blocks across 80 local transcripts — so the
viewer was offering 42 disclosures per session that opened onto nothing. An
activity now drops a body with nothing to read.
Claude Code writes the summary it makes when a session runs out of context
as a user entry, marked isCompactSummary. The parser did not read that flag,
so the viewer showed 85 such summaries across the local archive as words the
person typed. The entry now carries the flag, and the adapter shows it as a
conversation summary — worth reading, but not a turn in the conversation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A tool result is not always text. A tool that searches for tools answers with
tool_reference blocks, and a tool that looks at a screen answers with an image.
The parser read only text blocks, so 3551 tool-search results and 13 screenshot
results across the local archive lost their whole body and showed as an activity
row with nothing in it. Each kind is now said: tools by name, a picture as a
marker rather than its bytes.

Two tests that pinned the old behaviour asserted an assumption the archive
disproves, and now assert what the data shows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The spike is superseded by the ui module, which is built test-first, and the
card forbids two generations coexisting. The CI bundle gate already measures
ui.js, so nothing depended on it.

ADR 0002 recorded sub-agent demotion by parentToolUseId as knowledge to carry
over from the prior art. The archive disproves it: across 229818 entries in
1916 local transcript files no entry carries that field and none is a sidechain.
A sub-agent writes its own file, joined by the tool_use id in its .meta.json.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two reviewers found the same defect independently: an archive IO failure put
the absolute path it failed on into the 500 body, because failureFor forwarded
ArchiveError.message verbatim. TranscriptFailure.Unreadable now carries no
payload, so the leak is impossible rather than merely avoided; the host logs
the cause where it has the effects to do so.

Also: a rejected id is echoed back bounded and stripped of control characters,
the way ArchiveError already treats one; the Netty binding is released on
interrupt; the asset read is attemptBlocking; a docstring that claimed a
boundary the data path does not apply now says what is true; and the four
untested refusal paths in ContentBlockParser are covered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Point the project config at Forgejo, where the repository actually lives
All checks were successful
CI / Check Formatting (pull_request) Successful in 1m1s
CI / Compile (pull_request) Successful in 1m27s
CI / Check Linting (pull_request) Successful in 57s
CI / Run Tests (pull_request) Successful in 1m17s
CI / Check Bundle Size (pull_request) Successful in 47s
CI / Run Integration Tests (pull_request) Successful in 1m40s
20b90c44b7
origin answers Forgejo's API at code.iterative.works, but the config said
github, so project-context reported GitLab and told every workflow to use
glab. The project name also now matches this worktree.

Co-Authored-By: Michal Příhoda <michal@iterative.works>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
workflow(sl): close 01-transcript-viewer (flush parked items)
All checks were successful
CI / Check Formatting (pull_request) Successful in 42s
CI / Compile (pull_request) Successful in 59s
CI / Check Linting (pull_request) Successful in 56s
CI / Run Tests (pull_request) Successful in 1m14s
CI / Check Bundle Size (pull_request) Successful in 47s
CI / Run Integration Tests (pull_request) Successful in 1m35s
cdfca6d31e
Record how to run a long-lived process without stalling the Mill build
All checks were successful
CI / Check Formatting (pull_request) Successful in 45s
CI / Compile (pull_request) Successful in 56s
CI / Check Linting (pull_request) Successful in 1m0s
CI / Run Tests (pull_request) Successful in 1m15s
CI / Check Bundle Size (pull_request) Successful in 49s
CI / Run Integration Tests (pull_request) Successful in 1m30s
67089b77af
Mill serialises tasks through one daemon, so <module>.run holds it for as long
as the process lives and every other ./mill command blocks — including the
checkFormat a commit hook runs, which makes git commit look like it hangs.
runBackground returns at once and leaves the daemon free; clean stops it.

Both lessons from this slice are now triaged and resolved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mph merged commit a63a7f339a into main 2026-08-20 14:36:59 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
iterative-works/claude-code-query!64
No description provided.