feat(chat): render assistant markdown + collapse tool-call runs + copy-raw (ADR-0018) #14

Merged
dominik.polakovics merged 1 commit from feat/chat-markdown-toolgroups-copy into main 2026-07-07 10:08:54 +02:00

Implements #13 — three phone-first render enhancements to the embedded Chat (issue #7 / ADR-0016). Pure frontend + CSS: the message schema, provider seam, API, SSE, migrations and the transcript fold are all untouched, and no runtime npm dependency is added (deps stay solid-js + @solidjs/router).

What changed

  • Markdown renderingweb/src/lib/markdown.ts: a hand-rolled, zero-dep parser emitting a node tree the view maps to Solid JSX directly (never innerHTML, never a sanitizer) → XSS-safe by construction, link scheme allowlist http/https/mailto. Headings, bold/italic, inline + fenced code, bullet/ordered/nested lists, GFM tables, blockquotes, links + bare-URL autolink, hr. Applies to every text/thinking message, any role. Total: partially-streamed / malformed input degrades to visible text — never throws, never drops a character.
  • Tool-call groupingweb/src/lib/toolGroups.ts: render-time coalescing of 2+ consecutive tool calls into one "N tool calls" disclosure. Thinking folds in (uncounted); error rollup (· N failed); live · running…; count is tools-only. The outer group's open state is controlled, keyed by the first tool's seq, so an expanded live group survives SSE refetches.
  • Copy-raw — code-block header bar (language label + copy of the raw fence source) and a whole-message copy on assistant text; icon→check feedback, inline SVG, navigator.clipboard.
  • ADR-0018 records the deliberate hand-rolled-no-dependency renderer and the tool-grouping model.

Verification

  • web/: 366 unit tests pass (28 files), tsc --noEmit clean, eslint clean, vite build succeeds.
  • New tests: markdown constructs + an adversarial/partial-input battery (markdown.test.ts), grouping rules (toolGroups.test.ts), and RunChat coverage for markdown render, href-injection rejection, grouping/error-rollup/live-summary, controlled open-state survival across refetch, in-group thinking visibility, and copy actions.
  • Ran a 7-lens adversarial review (parser robustness/correctness, XSS, grouping, Solid reactivity, CSS/mobile, tests). All 6 confirmed findings are fixed with regression tests: ragged table rows keep every cell; nested link-in-label degrades to literal source; inline-code / code-block contrast in the accent user bubble now uses neutral surfaces (WCAG AA); long heading tokens wrap; decision-9 in-group thinking visibility is tested.

Out of scope (per issue #13)

Syntax highlighting · non-http autolink schemes · stacked-card table reflow · copy on user/thinking messages · rendering markdown in tool I/O · any backend/schema/API/SSE change.

Closes #13

🤖 Generated with Claude Code

Implements #13 — three phone-first render enhancements to the embedded **Chat** (issue #7 / ADR-0016). Pure frontend + CSS: the message schema, provider seam, API, SSE, migrations and the transcript fold are all untouched, and **no runtime npm dependency** is added (deps stay `solid-js` + `@solidjs/router`). ## What changed - **Markdown rendering** — `web/src/lib/markdown.ts`: a hand-rolled, zero-dep parser emitting a node tree the view maps to Solid JSX **directly** (never `innerHTML`, never a sanitizer) → XSS-safe by construction, link scheme allowlist `http/https/mailto`. Headings, bold/italic, inline + fenced code, bullet/ordered/**nested** lists, **GFM tables**, blockquotes, links + bare-URL autolink, hr. Applies to every text/thinking message, any role. **Total**: partially-streamed / malformed input degrades to visible text — never throws, never drops a character. - **Tool-call grouping** — `web/src/lib/toolGroups.ts`: render-time coalescing of 2+ consecutive tool calls into one "N tool calls" disclosure. Thinking folds in (uncounted); error rollup (`· N failed`); live `· running…`; count is tools-only. The outer group's open state is **controlled, keyed by the first tool's `seq`**, so an expanded live group survives SSE refetches. - **Copy-raw** — code-block header bar (language label + copy of the raw fence source) and a whole-message copy on assistant text; icon→check feedback, inline SVG, `navigator.clipboard`. - **ADR-0018** records the deliberate hand-rolled-no-dependency renderer and the tool-grouping model. ## Verification - `web/`: **366 unit tests pass** (28 files), `tsc --noEmit` clean, `eslint` clean, `vite build` succeeds. - New tests: markdown constructs + an adversarial/partial-input battery (`markdown.test.ts`), grouping rules (`toolGroups.test.ts`), and RunChat coverage for markdown render, href-injection rejection, grouping/error-rollup/live-summary, controlled open-state survival across refetch, in-group thinking visibility, and copy actions. - Ran a 7-lens adversarial review (parser robustness/correctness, XSS, grouping, Solid reactivity, CSS/mobile, tests). All **6 confirmed findings are fixed with regression tests**: ragged table rows keep every cell; nested link-in-label degrades to literal source; inline-code / code-block contrast in the accent user bubble now uses neutral surfaces (WCAG AA); long heading tokens wrap; decision-9 in-group thinking visibility is tested. ## Out of scope (per issue #13) Syntax highlighting · non-http autolink schemes · stacked-card table reflow · copy on user/thinking messages · rendering markdown in tool I/O · any backend/schema/API/SSE change. Closes #13 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(chat): render assistant markdown + collapse tool-call runs + copy-raw (ADR-0018)
Some checks failed
ci / flake-check (pull_request) Has been cancelled
6f8b2a07ca
Three phone-first render enhancements to the embedded Chat (issue #13),
all pure frontend/CSS — the message schema, provider seam, API, SSE, and
transcript fold (ADR-0016) are untouched, and no runtime npm dependency
is added (deps stay solid-js + @solidjs/router).

- Markdown: hand-rolled zero-dep parser (web/src/lib/markdown.ts) emitting
  a node tree the view maps to Solid JSX directly — no innerHTML, no
  sanitizer, XSS-safe by construction, link scheme allowlist
  (http/https/mailto). Headings, bold/italic, inline+fenced code, nested
  lists, GFM tables, blockquotes, links + bare-URL autolink, hr. Total:
  partial/malformed streamed input degrades to visible text, never throws,
  never drops a character (adversarial test battery).
- Tool grouping: web/src/lib/toolGroups.ts coalesces runs of 2+ tool calls
  into one "N tool calls" disclosure (thinking folds in uncounted, error
  rollup, live "running…"); open state is controlled and keyed by the first
  tool's seq so it survives SSE refetches.
- Copy-raw: code-block header bar (language + copy) and a whole-message
  copy on assistant text, claude.ai-style icon→check feedback, inline SVG.

Addresses all six confirmed findings from the adversarial review: table
cells beyond the header width are kept (no drop), nested link-in-label
degrades to literal source, inline-code/code-block contrast in the accent
user bubble now uses neutral surfaces (WCAG AA), long heading tokens wrap,
and decision-9 in-group thinking visibility is tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NP2Ai6CBkYY7Fj1fujtRDD
Author
Owner

This was generated by AI while landing a PR.

Validation: PASS

Verification signal. CI (ci / flake-check) has no runner on this instance and stays pending, so it cannot vouch. The diff is frontend-only (web/ + one ADR doc), so I ran the web slice of the gate directly:

  • eslint . — clean
  • vitest run366/366 pass (28 files)
  • tsc --noEmit + vite build — succeed

(I did not run the full nix flake check; its extra Go/store suites are unrelated to this frontend-only diff.)

Diff review.

  • lib/markdown.ts — total parser: block scanner always advances, MAX_DEPTH cap, inline fallbacks emit the literal delimiter (never drop a char), table pads short rows but never drops long-row cells. XSS-safe by construction — emits a node tree the view maps to JSX; no innerHTML anywhere in web/src. Link href gated by an http/https/mailto allowlist.
  • lib/toolGroups.ts — pure render-time coalescing; thinking folds in uncounted; 2+ threshold; error/running rollup; group key = first tool seq.
  • routes/RunChat.tsx — node-tree → Solid JSX; links only via scheme-filtered href with rel="noopener noreferrer"; heading level clamped; tool-group open-state is a parent Set<number> keyed by seq, reset on route change (survives SSE refetch); CopyButton guards navigator.clipboard? with a silent catch + timer cleanup.
  • base.css — tables/code fences get their own overflow-x containers, overflow-wrap: anywhere elsewhere; no page-level horizontal scroll.

Conventions. Title is Conventional Commits; body carries a working Closes #13; no new runtime npm dependency (deps stay solid-js + @solidjs/router). Forgejo reports no conflicts with main.

Tests assert the security-critical path (a javascript: href is never emitted and degrades to visible text), the grouping/error-rollup rules, and the folded-thinking visibility gate.

> *This was generated by AI while landing a PR.* **Validation: PASS** **Verification signal.** CI (`ci / flake-check`) has no runner on this instance and stays pending, so it cannot vouch. The diff is frontend-only (`web/` + one ADR doc), so I ran the web slice of the gate directly: - `eslint .` — clean - `vitest run` — **366/366 pass** (28 files) - `tsc --noEmit` + `vite build` — succeed (I did not run the full `nix flake check`; its extra Go/store suites are unrelated to this frontend-only diff.) **Diff review.** - `lib/markdown.ts` — total parser: block scanner always advances, `MAX_DEPTH` cap, inline fallbacks emit the literal delimiter (never drop a char), table pads short rows but never drops long-row cells. XSS-safe by construction — emits a node tree the view maps to JSX; **no `innerHTML` anywhere in `web/src`**. Link `href` gated by an http/https/mailto allowlist. - `lib/toolGroups.ts` — pure render-time coalescing; thinking folds in uncounted; 2+ threshold; error/running rollup; group key = first tool `seq`. - `routes/RunChat.tsx` — node-tree → Solid JSX; links only via scheme-filtered `href` with `rel="noopener noreferrer"`; heading level clamped; tool-group open-state is a parent `Set<number>` keyed by `seq`, reset on route change (survives SSE refetch); `CopyButton` guards `navigator.clipboard?` with a silent catch + timer cleanup. - `base.css` — tables/code fences get their own `overflow-x` containers, `overflow-wrap: anywhere` elsewhere; no page-level horizontal scroll. **Conventions.** Title is Conventional Commits; body carries a working `Closes #13`; **no new runtime npm dependency** (deps stay `solid-js` + `@solidjs/router`). Forgejo reports **no conflicts** with `main`. Tests assert the security-critical path (a `javascript:` href is never emitted and degrades to visible text), the grouping/error-rollup rules, and the folded-thinking visibility gate.
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
Cloonar/coding-lab!14
No description provided.