feat(chat): render assistant markdown + collapse tool-call runs + copy-raw (mobile) #13

Closed
opened 2026-07-07 09:10:58 +02:00 by dominik.polakovics · 0 comments

Summary

Follow-up to the embedded Chat (issue #7, ADR-0016). Three UI enhancements to web/src/routes/RunChat.tsx, all mobile-first:

  1. Render assistant markdown. Today assistant/user text is plain text (<p class="chat-msg-text">{m().text}</p>, white-space: pre-wrap) — **bold**, headings, code fences, lists, tables render as literal characters. Parse and render markdown.
  2. Collapse runs of tool calls. When 2+ tool calls occur back-to-back with no assistant prose between them, show one "N tool calls" disclosure that expands to the individual chips (each still expanding to its input/output).
  3. Copy-raw on code blocks and whole assistant messages, claude.ai style.

Design was fully grilled with the operator on 2026-07-07; the decisions below are settled — do not re-litigate them. This extends the existing chat surface; the message schema and provider seam are untouched (this is pure frontend + CSS).

Decisions (settled)

A. Markdown rendering

  1. Hand-rolled Solid-node renderer — no library, no new npm deps. Emit Solid JSX nodes directly (never an HTML string, never innerHTML/dangerouslySetInnerHTML), so the renderer stays XSS-safe by construction and needs no sanitizer dependency. This is deliberate and matches the repo's twice-documented "no markdown engine — no heavy deps" ethos (web/src/routes/IssueDetail.tsx:2). Deps stay solid-js + router only.
  2. Constructs in scope: headings (#######), bold, italic, inline code, fenced code blocks, bullet + numbered lists, nested lists, tables, blockquotes, links, horizontal rules. Any unsupported/malformed syntax degrades to visible plain text — never crash, never drop characters.
  3. Applies to every text and thinking message (any role — assistant, user, and thinking alike). Tool input/output stay plain <pre> (they are literal command I/O — parsing them would corrupt JSON/diffs).
  4. Links: [text](url) and bare http(s)://… URLs are clickable (autolink bare URLs). Scheme allowlist http/https/mailto — anything else (javascript:, data:, file:, relative paths) renders as plain text (closes the href-injection channel, which escaped-text-node safety does not cover). External links: target="_blank" rel="noopener noreferrer". No syntax highlighting (needs a heavy dep + grammar) — plain monospace only.
  5. Mobile: tables and code fences each get their own overflow-x: auto scroll container (faithful shape, swipe sideways — no stacked-card reflow); everything else overflow-wrap: anywhere. The message column must never force horizontal page scroll — only individual wide elements scroll internally (same as today's tool <pre> blocks). Readable base font/line-height; comfortable link tap targets.
  6. Robustness (hard requirement, tested): transcript text arrives partially during live streaming (flushed mid-turn), so the parser regularly sees incomplete markdown — unclosed code fence, dangling **, [text]( with no closing paren, half-streamed table. All must degrade to visible plain text, not throw and not eat content.

B. Tool-call grouping

  1. Frontend render-time coalescing of consecutive kind:'tool' messages. The server already emits a flat message list; this is a pure display concern — no backend/schema/API change.
  2. Run boundary: a run breaks on text, dialog, and lifecycle messages. thinking folds into the run (does not break it) — Claude interleaves thinking → tool → thinking → tool, and thinking is hidden-by-default noise, so folding it makes a run read as one "the bot did a bunch of work" block. Threshold = 2+: a single lone tool call renders exactly as today (one chip).
  3. Two-level disclosure: outer "N tool calls" <details> → ordered list of individual chips → each chip expands to input/output (today's chat-tool behavior, unchanged). Interleaved thinking messages still render in order inside the expanded group.
  4. Summary line: collapsed by default. Count = tool messages only (folded-in thinking is not counted, e.g. 3 tools + 2 thinkings → "3 tool calls"). Error rollup on the summary — when any child tool errored, surface it in the collapsed summary in the error color (e.g. 5 tool calls · 1 failed); a hidden failure is an unacceptable UX trap. No tool-name preview (keep the summary short for mobile).
  5. Live behavior = consistent collapse. The trailing still-growing run collapses like a sealed one; the summary shows liveness (4 tool calls · running…, count ticks up as tools arrive). Watching live detail is one tap. (Chosen over "stay expanded until sealed" to avoid the scroll-anchoring jank of a run shrinking mid-scroll.)
  6. Controlled open-state keyed by the first tool's seq, held in a signal that survives SSE refetchesmandatory. The group is a derived structure recomputed on every run.messages.changed refetch; with native <details> state alone, an expanded live group would slam shut on the next SSE tick, defeating decision 11. seq is the immutable cursor, so it is the stable key.

C. Copy-raw (claude.ai style)

  1. Code blocks: header bar per fenced block — language label left, copy button right. Copies the block's raw source (parser retains the literal fence content). Icon swaps to check + "Copied" ~1.5s. Always visible (mobile has no hover).
  2. Whole message: small copy-icon button in an action row under each assistant text message; copies the message's raw markdown (m().text is retained — we render a parsed view but never discard the source). Same icon→check feedback. (User replies are plain and already selectable; thinking is hidden noise — assistant-only for v1.)
  3. Icons = inline SVG (no icon-library dep). Clipboard via navigator.clipboard.writeText (localhost/https is a secure context in the embedded server).

Implementation notes (seams, not prescriptions)

  • New pure lib helper web/src/lib/markdown.ts + markdown.test.ts. Signature roughly parse(src) → node tree consumed by the view (or emits Solid nodes directly). Match the repo's pure-helper-with-test convention (web/src/lib/chatStream.ts, web/src/lib/conversation.ts). Tests must include the adversarial/partial-input cases from decision 6.
  • New pure helper web/src/lib/toolGroups.ts + test (or fold into chatStream.ts): groupMessages(messages) → renderable items. Tests cover threshold 2, thinking-folds-in, error rollup, count-tools-only, sealed vs. live trailing run.
  • web/src/routes/RunChat.tsxMessageView render branches (~lines 369–411): swap the kind:'text' branch to use the markdown renderer; add the grouped-tool render path; add copy affordances. Keep the view thin — logic lives in the lib helpers. The open-state signal (decision 12) lives in RunChatView.
  • web/src/base.css — chat styles live at ~1322–1559 (.chat-msg-text at 1415–1419, .mono at 91). Add markdown element styles (headings, lists, tables, code, blockquote, hr), the two scroll containers, the code-block header bar, and the copy buttons here — plain global CSS, CSS custom properties (var(--accent) etc.), no CSS modules/Tailwind.
  • No backend change. provider.Message/ToolInfo, the API, SSE events, migrations, and the transcript fold are all untouched. web/src/api.ts types (ChatMessage, ToolInfo, ~411–445) are unchanged.

Deliverables

  • web/src/lib/markdown.ts + markdown.test.ts (hand-rolled, zero-dep, adversarial/partial-input tests).
  • web/src/lib/toolGroups.ts (or folded into chatStream.ts) + tests for the grouping rules.
  • RunChat.tsx: markdown render branch, grouped-tool disclosure, copy-raw on code blocks + assistant messages, controlled open-state signal.
  • base.css: markdown element styles, table/code scroll containers, code-block header bar, copy buttons — mobile-first, dark via prefers-color-scheme (keep v0 design language: max-width 760px, 44px touch targets).
  • RunChat.test.tsx: coverage for markdown rendering, tool grouping (incl. error rollup + live summary), and copy actions.
  • ADR at docs/adr/<next-free-number>-*.md (or an addendum to ADR-0016) recording the deliberate hand-rolled-no-dependency markdown renderer decision and the tool-grouping model, in the house style. NOTE: take the next ADR number free on main at implementation time — do not hardcode.
  • Verify no new runtime npm dependency is added (deps stay solid-js + @solidjs/router).

Out of scope (explicitly deferred)

Syntax highlighting in code blocks · bare-URL autolink for non-http schemes · stacked-card table reflow on mobile · copy-raw on user/thinking messages · message-level actions beyond copy (retry/edit/feedback) · rendering markdown in tool input/output · any backend, schema, API, or SSE change.

## Summary Follow-up to the embedded **Chat** (issue #7, ADR-0016). Three UI enhancements to `web/src/routes/RunChat.tsx`, all **mobile-first**: 1. **Render assistant markdown.** Today assistant/user text is plain text (`<p class="chat-msg-text">{m().text}</p>`, `white-space: pre-wrap`) — `**bold**`, headings, code fences, lists, tables render as literal characters. Parse and render markdown. 2. **Collapse runs of tool calls.** When 2+ tool calls occur back-to-back with no assistant prose between them, show one **"N tool calls"** disclosure that expands to the individual chips (each still expanding to its input/output). 3. **Copy-raw** on code blocks and whole assistant messages, claude.ai style. Design was fully grilled with the operator on 2026-07-07; the decisions below are **settled — do not re-litigate them**. This extends the existing chat surface; the message schema and provider seam are untouched (this is pure frontend + CSS). ## Decisions (settled) ### A. Markdown rendering 1. **Hand-rolled Solid-node renderer — no library, no new npm deps.** Emit Solid JSX nodes directly (never an HTML string, never `innerHTML`/`dangerouslySetInnerHTML`), so the renderer stays **XSS-safe by construction** and needs **no sanitizer dependency**. This is deliberate and matches the repo's twice-documented "no markdown engine — no heavy deps" ethos (`web/src/routes/IssueDetail.tsx:2`). Deps stay `solid-js` + router only. 2. **Constructs in scope:** headings (`#`–`######`), bold, italic, inline code, fenced code blocks, bullet + numbered lists, **nested lists**, **tables**, blockquotes, links, horizontal rules. Any unsupported/malformed syntax **degrades to visible plain text** — never crash, never drop characters. 3. **Applies to** every `text` and `thinking` message (any role — assistant, user, and thinking alike). Tool `input`/`output` stay plain `<pre>` (they are literal command I/O — parsing them would corrupt JSON/diffs). 4. **Links:** `[text](url)` **and bare `http(s)://…` URLs** are clickable (autolink bare URLs). Scheme allowlist `http`/`https`/`mailto` — anything else (`javascript:`, `data:`, `file:`, relative paths) renders as plain text (closes the href-injection channel, which escaped-text-node safety does not cover). External links: `target="_blank" rel="noopener noreferrer"`. **No syntax highlighting** (needs a heavy dep + grammar) — plain monospace only. 5. **Mobile:** tables and code fences each get their own `overflow-x: auto` scroll container (faithful shape, swipe sideways — no stacked-card reflow); everything else `overflow-wrap: anywhere`. **The message column must never force horizontal page scroll** — only individual wide elements scroll internally (same as today's tool `<pre>` blocks). Readable base font/line-height; comfortable link tap targets. 6. **Robustness (hard requirement, tested):** transcript text arrives **partially during live streaming** (flushed mid-turn), so the parser regularly sees incomplete markdown — unclosed code fence, dangling `**`, `[text](` with no closing paren, half-streamed table. All must degrade to visible plain text, not throw and not eat content. ### B. Tool-call grouping 7. **Frontend render-time coalescing** of consecutive `kind:'tool'` messages. The server already emits a flat message list; this is a pure display concern — no backend/schema/API change. 8. **Run boundary:** a run breaks on `text`, `dialog`, and `lifecycle` messages. **`thinking` folds *into* the run (does not break it)** — Claude interleaves `thinking → tool → thinking → tool`, and thinking is hidden-by-default noise, so folding it makes a run read as one "the bot did a bunch of work" block. **Threshold = 2+**: a single lone tool call renders exactly as today (one chip). 9. **Two-level disclosure:** outer **"N tool calls"** `<details>` → ordered list of individual chips → each chip expands to input/output (today's `chat-tool` behavior, unchanged). Interleaved thinking messages still render in order inside the expanded group. 10. **Summary line:** collapsed by default. **Count = tool messages only** (folded-in thinking is not counted, e.g. 3 tools + 2 thinkings → "3 tool calls"). **Error rollup on the summary** — when any child tool errored, surface it in the collapsed summary in the error color (e.g. `5 tool calls · 1 failed`); a hidden failure is an unacceptable UX trap. No tool-name preview (keep the summary short for mobile). 11. **Live behavior = consistent collapse.** The trailing still-growing run collapses like a sealed one; the summary shows liveness (`4 tool calls · running…`, count ticks up as tools arrive). Watching live detail is one tap. (Chosen over "stay expanded until sealed" to avoid the scroll-anchoring jank of a run shrinking mid-scroll.) 12. **Controlled open-state keyed by the first tool's `seq`, held in a signal that survives SSE refetches** — **mandatory**. The group is a derived structure recomputed on every `run.messages.changed` refetch; with native `<details>` state alone, an expanded live group would slam shut on the next SSE tick, defeating decision 11. `seq` is the immutable cursor, so it is the stable key. ### C. Copy-raw (claude.ai style) 13. **Code blocks:** header bar per fenced block — **language label left, copy button right**. Copies the block's **raw source** (parser retains the literal fence content). Icon swaps to check + "Copied" ~1.5s. **Always visible** (mobile has no hover). 14. **Whole message:** small copy-icon button in an action row under each **assistant `text`** message; copies the message's **raw markdown** (`m().text` is retained — we render a parsed view but never discard the source). Same icon→check feedback. (User replies are plain and already selectable; thinking is hidden noise — assistant-only for v1.) 15. **Icons = inline SVG** (no icon-library dep). Clipboard via `navigator.clipboard.writeText` (localhost/https is a secure context in the embedded server). ## Implementation notes (seams, not prescriptions) - **New pure lib helper `web/src/lib/markdown.ts` + `markdown.test.ts`.** Signature roughly `parse(src) → node tree` consumed by the view (or emits Solid nodes directly). Match the repo's pure-helper-with-test convention (`web/src/lib/chatStream.ts`, `web/src/lib/conversation.ts`). Tests must include the adversarial/partial-input cases from decision 6. - **New pure helper `web/src/lib/toolGroups.ts` + test** (or fold into `chatStream.ts`): `groupMessages(messages) → renderable items`. Tests cover threshold 2, thinking-folds-in, error rollup, count-tools-only, sealed vs. live trailing run. - **`web/src/routes/RunChat.tsx`** — `MessageView` render branches (~lines 369–411): swap the `kind:'text'` branch to use the markdown renderer; add the grouped-tool render path; add copy affordances. Keep the view thin — logic lives in the lib helpers. The open-state signal (decision 12) lives in `RunChatView`. - **`web/src/base.css`** — chat styles live at ~1322–1559 (`.chat-msg-text` at 1415–1419, `.mono` at 91). Add markdown element styles (headings, lists, tables, code, blockquote, hr), the two scroll containers, the code-block header bar, and the copy buttons here — plain global CSS, CSS custom properties (`var(--accent)` etc.), no CSS modules/Tailwind. - **No backend change.** `provider.Message`/`ToolInfo`, the API, SSE events, migrations, and the transcript fold are all untouched. `web/src/api.ts` types (`ChatMessage`, `ToolInfo`, ~411–445) are unchanged. ## Deliverables - [ ] `web/src/lib/markdown.ts` + `markdown.test.ts` (hand-rolled, zero-dep, adversarial/partial-input tests). - [ ] `web/src/lib/toolGroups.ts` (or folded into `chatStream.ts`) + tests for the grouping rules. - [ ] `RunChat.tsx`: markdown render branch, grouped-tool disclosure, copy-raw on code blocks + assistant messages, controlled open-state signal. - [ ] `base.css`: markdown element styles, table/code scroll containers, code-block header bar, copy buttons — mobile-first, dark via `prefers-color-scheme` (keep v0 design language: max-width 760px, 44px touch targets). - [ ] `RunChat.test.tsx`: coverage for markdown rendering, tool grouping (incl. error rollup + live summary), and copy actions. - [ ] **ADR** at `docs/adr/<next-free-number>-*.md` (or an addendum to ADR-0016) recording the deliberate **hand-rolled-no-dependency markdown renderer** decision and the tool-grouping model, in the house style. NOTE: take the next ADR number free on `main` at implementation time — do not hardcode. - [ ] Verify no new runtime npm dependency is added (deps stay `solid-js` + `@solidjs/router`). ## Out of scope (explicitly deferred) Syntax highlighting in code blocks · bare-URL autolink for non-http schemes · stacked-card table reflow on mobile · copy-raw on user/thinking messages · message-level actions beyond copy (retry/edit/feedback) · rendering markdown in tool input/output · any backend, schema, API, or SSE change.
Sign in to join this conversation.
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#13
No description provided.