Skip to content

Make session export a lightweight chat log#1544

Open
srid wants to merge 16 commits into
masterfrom
export-sess-v2
Open

Make session export a lightweight chat log#1544
srid wants to merge 16 commits into
masterfrom
export-sess-v2

Conversation

@srid

@srid srid commented Jun 23, 2026

Copy link
Copy Markdown
Member

Session export now starts from the thing people actually want to read: the conversation. The export action opens a small picker for Chat log, Full transcript, or Both, with Chat log producing a plain back-and-forth HTML file instead of carrying hidden tool payloads or a rendered mini-app.

What changed

Mode Output
Chat log Human and AI messages only, high-contrast HTML, no Pierre/Shiki renderer, no hidden tool payloads
Full transcript The same conversation plus collapsed reasoning, tool calls, tool results, and subtasks
Both Downloads one chat file and one full transcript file

The export uses a conversation-ledger design with hard role colours, visible borders, dark code blocks, and fixed ↑/↓ controls for jumping between human prompts. Those controls are the only runtime script, emitted only when there is more than one human prompt.

The RPC now requires an explicit mode, so there is no silent default export shape. The renderer still consumes the existing vendor-neutral TranscriptEvent stream; it just renders it as a small self-contained document instead of a second app.

Project fit

  • Fail fast: callers must pass chat or full; old implicit HTML export calls now fail schema validation.
  • Volatility boundaries: no new package boundary was needed; transcript HTML remains a leaf renderer over the existing transcript IR.
  • Reuse source of truth: the UI uses existing dialog/download helpers, and the server keeps the same transcript loading path.

Checks run

  • nix develop -c pnpm --filter kolu-common test:unit -- transcript.test.ts
  • nix develop -c pnpm --filter kolu-server test:unit -- exportTranscriptHtml.test.ts
  • nix develop -c pnpm --filter kolu-transcript-html typecheck
  • nix develop -c pnpm --filter kolu-client typecheck
  • nix build .#pnpmDeps
  • nix develop -c just fmt
  • nix develop -c just check
  • nix develop -c just atlas::check-sync
  • Playwright smoke check against a generated export: next → human-1, next → human-2, previous → human-1

Review gauntlet (resumed /be)

Codex did the implementation, so the codex-debate slot was replaced by an independent Claude review pass up front, then the rest of the gauntlet ran in order:

  • Review pass — found that marked passed raw HTML tokens through verbatim, so a message containing <img onerror=…> / <script> became stored XSS in any browser that opened the shared export. Overrode the html renderer to escape it (verified against the installed marked; test added). Also removed the transcriptToHtml default mode (a fail-fast / no-fallbacks fix). [57245ce]
  • lens-debate (lowy ⇄ hickey) — consensus in 2 rounds: single-sourced the export-mode enum in kolu-transcript-core, modeled "both" as a modes array instead of a widened mode value, factored the blob/URL lifecycle into one helper, one MODE_LABEL table, named the dialog close intent.
  • simplify — gated the prompt-jump nav and its script on one hasPromptJump threshold.
  • code-police — made the modes parameter a non-empty tuple end to end (compile-time "at least one mode"), dropped a duplicate humanMessageCount scan, narrowed renderDetailEvent, removed dead branches.

Evidence: rendered chat-log + full-transcript documents (desktop + phone) posted below. CI: green on both platforms — also fixed a stale pnpmDeps FOD hash the trimmed dependency set surfaced. [334b06c]

Generated by /be on Codex (model gpt-5), review gauntlet resumed on Claude (Opus 4.8).

srid added 15 commits June 23, 2026 16:17
…cit mode

Review pass over the Codex implementation (in place of codex-debate):

- marked passes raw HTML tokens through verbatim by default, so an
  assistant/user message containing `<img src=x onerror=…>` or `<script>`
  became stored XSS in any browser that opened the shared export. Override
  the `html` renderer to escape raw HTML to its literal text — also the
  faithful rendering of a transcript. Covered by a new test.
- Drop the `{ mode: "chat" }` default on `transcriptToHtml`: every caller
  already passes mode explicitly, so the default only masked a missing
  argument — a fail-fast / no-fallbacks violation.
Move TranscriptHtmlMode to kolu-transcript-core (the low layer both packages
already depend on); kolu-common derives TranscriptHtmlModeSchema via z.enum over
the core values, and transcript-html imports the type instead of re-declaring it.

Agreed by the lowy ⇄ hickey lens debate (finding lowy-1, raised by lowy). Not pushed or merged.
exportSessionAsHtml/exportSessionHtml/exportMode now carry
TranscriptHtmlMode[]; a single-element array opens in a tab and a
multi-element array downloads each over the same loop. The synthetic
"both" member and every `=== "both"` discrimination disappear, keeping the
render-mode contract clean and separating the orchestration axis.

Agreed by the lowy ⇄ hickey lens debate (finding lowy-2, raised by lowy). Not pushed or merged.
openExport and downloadExport no longer duplicate the
Blob + createObjectURL + revoke-after-60s lifecycle; that resource
management lives once in withBlobUrl, leaving the two functions as one-line
delivery strategies (open-tab-with-fallback vs. force-download).

Agreed by the lowy ⇄ hickey lens debate (finding lowy-3, raised by lowy). Not pushed or merged.
Add MODE_LABEL: Record<TranscriptHtmlMode, string> beside the mode enum in
kolu-transcript-core and read it everywhere a single mode needs a label —
the renderer's meta line and document title, and the single-mode export
success toast — so "Chat log" / "Full transcript" can no longer drift across
sites. The count-based loading/multi toasts stay length conditionals.

Agreed by the lowy ⇄ hickey lens debate (finding hickey-4, raised by hickey). Not pushed or merged.
Add `close: () => setOpen(false)` to the Disclosure triplet and call
exportSessionDialog.close() from the Cancel button and exportMode, instead of
reaching past the abstraction with the magic `onOpenChange(false)` literal.

Agreed by the lowy ⇄ hickey lens debate (finding hickey-5, raised by hickey). Not pushed or merged.
…eshold

/simplify pass: the nav HTML and its runtime script are one feature with one
gate (more than one human message). Compute hasPromptJump once instead of
repeating the >= 2 threshold in promptJumpHtml's internal guard and the
script-inclusion line.
- Make the export-modes parameter a non-empty tuple
  ([Mode, ...Mode[]]) end to end, so "at least one mode" is a compile-time
  guarantee and the unreachable runtime guard drops out.
- renderEvents takes humanTotal as a parameter instead of recomputing the
  O(n) human-message scan.
- Inline the single-use fetchHtml wrapper.
- Narrow renderDetailEvent to the detail event union; drop the dead
  user/assistant branches.
- firstLine() computed once in the patch summary; remove the unreachable
  `?? String(value)` in prettyJson (JSON.stringify never returns null here).
@srid

srid commented Jun 24, 2026

Copy link
Copy Markdown
Member Author

⚖️ Lowy ⇄ Hickey lens debate

Consensus after 2 round(s) · lowy + hickey · base badebf2ba51e

Independent findings: lowy=4, hickey=6

Applied (8)

  • lowy-1 TranscriptHtmlMode receptacle is wired up twice (duplicated mode contract) — commit 8cf7c8a3f
  • lowy-2 "both" is a third mode value that bypasses the receptacle instead of being one of its generators — commit fc467ede8
  • lowy-3 openExport / downloadExport are the same wire with a one-line difference — commit 93c3b06b5
  • hickey-1 TranscriptHtmlMode defined twice — one concept, two independent declarations — (uncommitted)
  • hickey-2 "both" is a client-only pseudo-mode braided into the real mode type at every layer — (uncommitted)
  • hickey-3 openExport and downloadExport duplicate the blob+url+revoke lifecycle — (uncommitted)
  • hickey-4 Toast wording switched on mode by a parallel ternary instead of a label map — commit e8a6bda17
  • hickey-5 exportMode hand-rolls the close-then-act sequence instead of using the disclosure's open state — commit 1b5471a9c

Agreed — no change (2)

  • lowy-4 Exhaustive ToolInput switch appears in both transform.ts and the renderer — confirm two activities, not duplicated dispatch (packages/transcript-html/src/index.tsx:213 (detailSummary) vs packages/transcript-core/src/transform.ts:53 (transformToolInput))
  • hickey-6 filename mode suffix derived on server while open/download decision lives on client (packages/server/src/router.ts:325 and packages/client/src/exportSessionAsHtml.ts:54-58)

@srid

srid commented Jun 24, 2026

Copy link
Copy Markdown
Member Author

Evidence

Rendered from the real transcriptToHtml renderer (driven through the server package's vitest so the workspace IR/loaders resolve exactly as in production) over a representative Claude Code session. These are the actual self-contained HTML documents the export produces.

Chat log (mode: "chat") — desktop

The lightweight conversation-only document: hard role colours (HUMAN / AI), visible borders, a dark code block, masthead with repo + PR link, and the fixed ↑/↓ prompt-jump controls (the only runtime script, emitted only because there's more than one human prompt). No tool payloads, no Pierre/Shiki renderer.

Chat log export — desktop

Full transcript (mode: "full") — desktop

Same conversation shell, plus the collapsed audit detail rows interleaved between messages (Ran just check, Tool result). Note the meta line now reads Full transcript · 2 prompts · 2 replies · 2 details.

Full transcript export — desktop

Chat log — phone width (390×844)

The export carries a viewport meta + media queries: messages stack full-width, the code block scrolls horizontally instead of clipping, and the prompt-jump nav stays pinned bottom-right.

Chat log export — mobile

Security — raw HTML is escaped in a shared export

Covered by a unit test (exportTranscriptHtml.test.ts): a message containing <img src=x onerror=alert(1)> or <script>alert(2)</script> renders as literal text (&lt;img …&gt;), so opening or sharing the file can't execute embedded scripts. marked passes raw HTML through by default; the renderer now overrides the html token to escape it.

The export opens in a new tab / downloads a file, and the picker is the shared ModalDialog (size="sm") — the substantive visual surface is the generated document above, captured directly.

The export rework drops marked's old pierre/shiki/preact siblings from
packages/transcript-html, changing pnpm-lock.yaml and the vendored pnpm
store. Update the fetchPnpmDeps FOD hash to match (both platforms compute
the same store). Fixes ci::pnpm-hash-fresh and the cascading nix builds.
@srid srid marked this pull request as ready for review June 24, 2026 03:58
@srid

srid commented Jun 24, 2026

Copy link
Copy Markdown
Member Author

🧪 CI metrics — leased pool box

The x86_64-linux lane ran on kolu-ci-1 — commit dbd0196a9, failed

  • Lane wall (pipeline): 1m55s
recipe duration
ci::home-manager 1m21s ✗ (exit 1)
ci::nix 1m8s ✗ (exit 1)
ci::smoke 43s ✗ (exit 1)
ci::e2e 43s ✗ (exit 1)
ci::unit 36s
_ci-setup 34s
ci::flake-check 33s ✗ (exit 102)
ci::atlas-sync 28s
ci::pnpm-hash-fresh 24s ✗ (exit 1)
ci::surface-app-example-build 19s
ci::render 19s
ci::surface-example-build 18s
ci::biome 18s
ci::fmt 17s
ci::install 10s

Pool status (8 boxes)

box location state
kolu-ci-1 ? ✓ idle
kolu-ci-2 ? ✓ idle
kolu-ci-3 ? ✓ idle
kolu-ci-4 ? ✓ idle
kolu-ci-5 ? ✓ idle
kolu-ci-6 ? ✓ idle
kolu-ci-7 ? ✓ idle
kolu-ci-8 ? ✓ idle

Posted by ci/pu/report.sh. Lane timings from .ci/dbd0196/timings.jsonl; pool state is a live flock probe.

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