v1.13.19-html-artifact-panes: pane-based artifact viewer with on-request HTML

Every assistant message gets an "Open in pane" affordance that opens the
message in the workspace splitter — Markdown pane (Copy + Download .md) by
default; HTML pane (Download .html only) when the model emits a self-contained
<!DOCTYPE html> or fenced ```html artifact. BOOCHAT.md rule keeps Markdown
default at every length; HTML opt-in on explicit user request.

Backend: services/artifacts.ts (slug derivation + write helpers with
symlink-escape guard via realpath-after-mkdir), routes/artifacts.ts (POST
download + GET stream with nosniff + CSP sandbox defense-in-depth), HTML
detection in finalizeCompletion writing a new message_parts.kind='html_artifact'
row (schema CHECK extended via v1.13.13 pattern), graceful 1MB cap via the
pure decideHtmlArtifactWrite helper. PartKind union extended.

Frontend: MarkdownRenderer.tsx extracted from MessageBubble's inline
MarkdownBody for reuse; MarkdownArtifactPane.tsx + HtmlArtifactPane.tsx with
loading/error states; pane state is reference-only ({chat_id, message_id,
title}) — content fetched on mount to keep workspace_panes jsonb small and
avoid 1MB blobs riding session_workspace_updated frames. iframe sandbox
locked to allow-scripts allow-clipboard-write allow-downloads with no
allow-same-origin, srcDoc not src. openInPane discriminates 404 (expected
fallback) from real errors (toast + bail). PanelRightOpen icon button with
mobile 44px tap-target.

31 new server unit tests including a real-symlink filesystem case; 332/332
server tests passing, tsc clean both sides, pnpm -C apps/web build green.
Smoke deferred to first deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 12:43:13 +00:00
parent 1a889dcde3
commit ad45b28250
24 changed files with 1864 additions and 195 deletions

View File

@@ -263,45 +263,52 @@ After v1.13.2 ships, tag the umbrella `v1.13` on the same commit (or on -C — S
-----
## v1.14.x-html — HTML artifacts in BooChat (NEW, 2026-05-22)
## v1.14.x-html — pane-based artifact viewer with Markdown + HTML (REVISED, 2026-05-23)
**Goal:** integrate Thariq Shihipar's "HTML > Markdown for agent output at length" pattern (`claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html`, May 20 2026) into BooChat. Bias the model toward HTML for outputs >100 lines: information density, visual clarity, interactive controls (sliders/knobs/SVG diagrams/side-by-side comparisons), shareability. BooChat already renders into a webview, so the surface fit is unusually good.
**Goal:** every assistant message gets an "Open in pane" affordance that renders it as an artifact — Markdown by default (the model's normal output), HTML only when the user explicitly asks for it (e.g. "render this as HTML", "make me a dashboard", "build an interactive diagram"). Both artifact types open in BooChat's existing workspace splitter. Markdown panes have **Copy** (raw source) + **Download** (`.md`); HTML panes have **Download** (`.html`) only. No inline iframe preview — artifacts are pane-only.
Inspired by Thariq Shihipar's "HTML > Markdown at length" pattern (`claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html`, May 20 2026), but scoped down from that post's "auto-bias to HTML for >100 lines" recommendation: Markdown stays the default everywhere, HTML is an on-request rendering target for cases where interactive controls / diagrams / side-by-side layouts pay off.
**Scope:**
1. **Model-side prompting** (no code change yet, just AGENTS.md guidance):
- Add HTML-bias rule to global `AGENTS.md`: "For outputs >100 lines, default to a self-contained `<!DOCTYPE html>...</html>` artifact unless the user explicitly asks for Markdown. For outputs <100 lines or for short conversational replies, stay in Markdown."
- Reasoning shown in the rule: HTML carries diagrams, tabs, illustrations, code-with-syntax-highlighting, interactive controls, mobile-responsive layouts. Markdown is restrictive at any length.
- Cite Thariq's blog post in the rule comment so future audit passes know where it came from.
1. **Detection at the BooChat backend.** In `apps/chat/services/inference/stream-phase.ts` post-processing: detect any assistant text part starting with `<!DOCTYPE html>` (case-insensitive, whitespace-trimmed) — or wrapped in a fenced ` ```html` block — and tag it as an HTML artifact. Emit a new part kind `html_artifact` into `message_parts` (CHECK constraint update). Payload: `{html_content, char_count, title}`. Title pulled from `<title>` tag or first `<h1>` if available.
1. **Three render targets (Sam's pick: "3 with a download"):**
- **Inline preview** in the chat stream: small sandboxed iframe (~400px tall), renders the artifact next to where it was streamed. Default size, click-to-expand.
- **Open in pane**: button on the inline preview opens the artifact in a full-height pane in BooChat's existing workspace splitter, alongside the file viewer and BooTerm. Pane is dismissible. Pane state persisted via `sessions.workspace_panes jsonb` (the v1.12.1 schema already supports this).
- **Download**: button writes the artifact to `/opt/<project>/.boocode/artifacts/<slug>-<unix-timestamp>.html` (path-guarded same as native write tools), surfaces an OS download link via the existing file-serving path. Filename slug derived from artifact title.
1. **Security stance — locked 2026-05-22:** the iframe is sandboxed with `sandbox="allow-scripts allow-clipboard-write allow-downloads"`. **Crucially, omit `allow-same-origin`** so the artifact has its own opaque origin and cannot read BooChat's cookies, Authelia session, or DOM. Backend serves the iframe content via `srcdoc=...` inline (not `src=`) so no separate URL exists to disclose. CSP header on the iframe response: `default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'`. The `connect-src 'none'` is the key clause — artifacts can't `fetch()`, can't open WebSockets, can't ping a tracking pixel, can't exfiltrate. JS runs (so Thariq's interactive knobs/sliders/copy-as-prompt buttons work) but nothing else network-touching does. **None of Thariq's blog examples need the relaxed permissions** — they're all client-side.
1. **Frontend rendering** (`apps/web/src/components/HtmlArtifactPart.tsx`):
- Inline preview: `<iframe srcdoc={html_content} sandbox="allow-scripts allow-clipboard-write allow-downloads" className="..." />` with the strict-sandbox attributes above.
- "Open in pane" button: dispatches workspace-pane action with `{type: 'html_artifact', message_part_id, html_content}`.
- "Download" button: POST to new endpoint `/api/chats/:id/artifacts/:part_id/download` which writes to disk (path-guarded) and returns the absolute path or pre-signed URL for the existing static-file serving route.
1. **No artifact persistence beyond the chat.** Artifacts live in `message_parts.payload->>'html_content'` with the chat. Downloads go to `/opt/<project>/.boocode/artifacts/` and are user-managed from there. No separate artifacts table.
1. **Token-budget guard.** Single artifact can be at most 1MB of HTML in `message_parts.payload`. Larger triggers a streaming abort with a friendly error: "Artifact exceeded 1MB; consider splitting into multiple files or reducing inline assets."
1. **No `web-artifacts-builder` skill vendor.** That skill (`anthropics/skills/web-artifacts-builder`) is built for Claude.ai's runtime with Vite + Parcel + tspaths + html-inline toolchain. BooChat has no shell execution surface. The pattern transplants; the toolchain doesn't. Treat the skill's "avoid AI slop" design principles (no excessive centered layouts, no purple gradients, no uniform rounded corners, no Inter font) as conventions inlined in the HTML-bias AGENTS.md rule. The init/bundle scripts are out of scope.
1. **Model-side prompting** (no code change, just AGENTS.md guidance):
- Add HTML-on-request rule to global `AGENTS.md`: "Stay in Markdown by default for all outputs, short or long. Switch to a self-contained `<!DOCTYPE html>...</html>` artifact only when the user explicitly asks (e.g. 'render this as HTML', 'make a dashboard', 'build a diagram')."
- Inline the `web-artifacts-builder` "avoid AI slop" design principles for when HTML is requested: no excessive centered layouts, no purple gradients, no uniform rounded corners, no Inter font, no generic AI aesthetics.
- Cite Thariq's blog post in the rule comment so future audit passes know where the design conventions came from.
1. **Detection at the BooChat backend.** In `apps/chat/services/inference/stream-phase.ts` post-processing: detect any assistant text part starting with `<!DOCTYPE html>` (case-insensitive, whitespace-trimmed) — or wrapped in a fenced ` ```html` block — and tag it as an HTML artifact. Emit a new part kind `html_artifact` into `message_parts` (CHECK constraint update). Payload: `{html_content, char_count, title}`. Title pulled from `<title>` tag or first `<h1>` if available. Detection is opportunistic — when the model produces HTML (because the user asked), the tag fires; otherwise the message stays plain-Markdown and no `html_artifact` part is written.
1. **Pane-only render surface.** Every assistant message in the chat stream gets an "Open in pane" affordance (icon button in the message footer, alongside the existing copy/regenerate controls). Clicking it opens the message as an artifact pane in BooChat's existing workspace splitter, alongside the file viewer and BooTerm. Pane is dismissible. Pane state persisted via `sessions.workspace_panes jsonb` (the v1.12.1 schema already supports this).
- **Markdown pane** — renders via the same Markdown component used inline in `MessageBubble` (so syntax highlighting, fenced code blocks, tables, etc. all work). Header carries **Copy** (writes raw Markdown source to clipboard via `navigator.clipboard.writeText`) and **Download** (`.md`) buttons.
- **HTML pane** — renders the artifact in a sandboxed iframe at full pane height. Header carries **Download** (`.html`) only. **No Copy button** — HTML source isn't useful clipboard content; if the user wants the source they can Download and inspect.
1. **Download path & filename slug.** Both formats write to `/opt/<project>/.boocode/artifacts/<slug>-<unix-timestamp>.<ext>` (path-guarded same as native write tools), and surface an OS download link via the existing file-serving path.
- Markdown slug: derived from the message's first heading (`# ...`) if present, else the first 6 words of the message body, lowercased + hyphenated.
- HTML slug: derived from the artifact's `<title>` tag if present, else first `<h1>`, else first 6 words of the inner text. Same lowercase-hyphen treatment.
1. **Security stance for HTML pane — locked 2026-05-22:** the iframe is sandboxed with `sandbox="allow-scripts allow-clipboard-write allow-downloads"`. **Crucially, omit `allow-same-origin`** so the artifact has its own opaque origin and cannot read BooChat's cookies, Authelia session, or DOM. Backend serves the iframe content via `srcdoc=...` inline (not `src=`) so no separate URL exists to disclose. CSP header on the iframe response: `default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'`. The `connect-src 'none'` is the key clause — artifacts can't `fetch()`, can't open WebSockets, can't ping a tracking pixel, can't exfiltrate. JS runs (so interactive knobs/sliders/copy-as-prompt buttons work) but nothing else network-touching does.
1. **Frontend components:**
- `apps/web/src/components/MarkdownArtifactPane.tsx` — pane shell + header (Copy + Download) + Markdown render reusing the existing component.
- `apps/web/src/components/HtmlArtifactPane.tsx` — pane shell + header (Download only) + `<iframe srcdoc={html_content} sandbox="allow-scripts allow-clipboard-write allow-downloads" />`.
- `MessageBubble.tsx` — add "Open in pane" affordance to every assistant message footer. Dispatches workspace-pane action `{type: 'markdown_artifact' | 'html_artifact', message_id, html_content?}`. When the message has an `html_artifact` part, the affordance opens as an HTML pane; otherwise it opens as a Markdown pane.
- Download button → POST to new endpoint `/api/chats/:id/messages/:msg_id/artifacts/download?fmt=md|html` which writes to disk (path-guarded) and returns the absolute path or pre-signed URL for the existing static-file serving route.
1. **No artifact persistence beyond the chat.** Artifacts live in `message_parts.payload->>'html_content'` (for HTML) or are derived on-demand from the assistant message's content (for Markdown). Downloads go to `/opt/<project>/.boocode/artifacts/` and are user-managed from there. No separate artifacts table.
1. **Token-budget guard.** Single HTML artifact can be at most 1MB of HTML in `message_parts.payload`. Larger triggers a streaming abort with a friendly error: "Artifact exceeded 1MB; consider splitting into multiple files or reducing inline assets." Markdown artifacts have no separate cap — they're bounded by the existing message-size envelope.
1. **No `web-artifacts-builder` skill vendor.** That skill (`anthropics/skills/web-artifacts-builder`) is built for Claude.ai's runtime with Vite + Parcel + tspaths + html-inline toolchain. BooChat has no shell execution surface. The pattern transplants; the toolchain doesn't. Treat the skill's "avoid AI slop" design principles as conventions inlined in the HTML-on-request AGENTS.md rule. The init/bundle scripts are out of scope.
**Lift sources:**
- `claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html` (Thariq Shihipar, May 20 2026) — the pattern, the use-case taxonomy (specs/code-review/design/reports/custom editors), the design philosophy.
- `claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html` (Thariq Shihipar, May 20 2026) — design conventions and use-case taxonomy (specs/code-review/design/reports/custom editors). The "auto-bias for >100 lines" recommendation is deliberately NOT lifted.
- HTML iframe sandbox spec (web platform standard, no license issues).
- `anthropics/skills/web-artifacts-builder` — design-principle reference only ("avoid AI slop" rules). **Do not vendor the toolchain.**
**Dependencies:** v1.13 merged (`message_parts` table is where artifacts live). Independent of v1.14 (outer loop) and v1.14.x-mcp (MCP PoC). Can ship in any order relative to those.
**Dependencies:** v1.13 merged (`message_parts` table is where HTML artifacts live). Independent of v1.14 (outer loop) and v1.14.x-mcp (MCP PoC). Can ship in any order relative to those.
**Estimated:** ~400 LoC. Roughly half backend (detection + part-kind extension + download endpoint + path-guard integration), half frontend (HtmlArtifactPart component + pane integration + download button wiring).
**Estimated:** ~400 LoC. Roughly half backend (HTML detection + part-kind extension + download endpoint + path-guard integration + Markdown slug derivation) and half frontend (two artifact-pane components + MessageBubble affordance + pane integration + download wiring).
**Schema addition:**
- `message_parts.kind` CHECK constraint adds `'html_artifact'` to the allowed set.
**Skip-condition:** none — independent batch, ships clean any time after v1.13. Highest user-visible payoff of any v1.13.x/v1.14.x batch (transforms what the model can produce, not just how the backend handles it).
**Skip-condition:** none — independent batch, ships clean any time after v1.13. Pane-based artifact view is a structural UX improvement (full-height read surface for long replies, durable download path) on top of the HTML-on-request rendering capability.
**Shipped as `v1.13.19-html-artifact-panes` on 2026-05-23.** Two scope-revisions during impl: (a) the HTML-on-request rule landed in `BOOCHAT.md` (always-true rules layer), not `data/AGENTS.md` (per-agent registry) — per BOOCHAT.md's own convention block. (b) Pane state stayed reference-only — `{chat_id, message_id, title}` — content fetched on mount via the existing chat-messages endpoint (Markdown) and a new `GET /api/chats/:id/messages/:msg_id/html_artifact` (HTML). Storing content in pane state would have ridden 1MB blobs through the `session_workspace_updated` WS frame and bloated the jsonb column on multi-pane sessions. Defense-in-depth additions beyond the original proposal: `X-Content-Type-Options: nosniff` + `Content-Security-Policy: sandbox` on the GET serve route, and `assertArtifactsDirSafe` realpaths the artifacts dir after `mkdir` to close a symlink-escape gap that would otherwise let a planted symlink under `.boocode/artifacts/` route writes outside the project root. Smoke not run pre-tag; first deploy is the smoke.
-----