# v1.14.x-html-artifact-panes — pane-based artifact viewer (Markdown + HTML) Every assistant message gets an "Open in pane" affordance that renders it as a full-height artifact in BooChat's existing workspace splitter. Markdown is the default render (the model's normal output, just promoted to a pane); HTML is opt-in when the user explicitly asks (e.g. "render this as HTML", "make me a dashboard", "build an interactive diagram"). Pane headers expose Copy + Download for Markdown, Download-only for HTML. **No inline iframe preview** — artifacts are pane-only. Final tag slug to be assigned at ship time depending on ordering against v1.14 (outer loop) and v1.14.x-mcp (MCP PoC). This batch is independent of both. ## Why Three pressures land in the same place: 1. **Long assistant replies are uncomfortable to read in the chat stream.** Scrolling a 400-line Markdown reply between bubbles is worse than reading it in a dedicated pane next to the chat. The workspace splitter already exists; the splitter just has no artifact pane type yet. 2. **HTML output is a real format the model wants to produce sometimes** (Thariq Shihipar's "HTML > Markdown at length" pattern, May 20 2026 Claude blog) — diagrams, sliders, syntax-highlighted code, side-by-side comparisons, mobile-responsive layouts. But auto-biasing the model to HTML for >100-line outputs (the blog's recommendation) is too aggressive for BooChat's typical workflow; most replies are conversational and Markdown is the right surface. **HTML stays opt-in.** 3. **Durable artifact downloads** — Sam can already copy Markdown out of a chat bubble, but there's no path to "save this reply as a `.md` next to the project, keep it around." Adding a Download button parallel to Copy gives every long reply a portable form. ## Scope ### S1. AGENTS.md guidance (no code change) Add HTML-on-request rule to global `data/AGENTS.md`: > Stay in Markdown by default for all outputs, short or long. Switch to a self-contained `...` artifact only when the user explicitly asks (e.g. "render this as HTML", "make a dashboard", "build a diagram"). When producing HTML, follow these design conventions: no excessive centered layouts, no purple gradients, no uniform rounded corners, no Inter font, no generic AI aesthetics. See `claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html` (Thariq Shihipar, May 2026) for the design taxonomy. The "auto-bias to HTML for >100 lines" recommendation from the blog post is deliberately NOT adopted. Markdown stays the default at every length. ### S2. Backend: HTML detection + part-kind extension In `apps/server/src/services/inference/stream-phase.ts` post-processing, detect when an assistant text part: - Starts with `` (case-insensitive, whitespace-trimmed), OR - Is wrapped entirely in a fenced ` ```html ... ``` ` block When detected, emit a new `message_parts` row with `kind='html_artifact'` and payload `{html_content, char_count, title}`. Title resolution order: `` tag → first `<h1>` text → first 80 chars of inner text. Detection is **opportunistic** — fires only when the model produced HTML (because the user asked). Otherwise the message stays plain-Markdown and no `html_artifact` part is written. **Schema:** ```sql -- v1.14.x: extend message_parts.kind CHECK constraint with html_artifact ALTER TABLE message_parts DROP CONSTRAINT IF EXISTS message_parts_kind_chk; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'message_parts_kind_chk') THEN ALTER TABLE message_parts ADD CONSTRAINT message_parts_kind_chk CHECK (kind IN ('text', 'reasoning', 'tool_call', 'tool_result', 'synthesis', 'html_artifact')); END IF; END $$; ``` Idempotent on re-run (drops + re-adds on every startup; trivial cost). ### S3. Frontend: pane affordance + two pane types **MessageBubble.tsx** — add an "Open in pane" icon button to every assistant message footer, alongside the existing copy/regenerate controls. Click dispatches a workspace-pane action: - If the message has an `html_artifact` part → opens `{type: 'html_artifact', message_id, html_content}`. - Otherwise → opens `{type: 'markdown_artifact', message_id}`. **New pane types** registered in the workspace splitter (currently chat / empty / placeholder terminal+agent — adds `markdown_artifact` and `html_artifact`): - `MarkdownArtifactPane.tsx` — pane shell. Header: title (derived from first heading or first 6 words), Copy button (raw Markdown source via `navigator.clipboard.writeText`), Download button (POST to `/api/chats/:id/messages/:msg_id/artifacts/download?fmt=md`). Body: reuses the same Markdown component used inline in `MessageBubble` (Shiki syntax highlighting, fenced code, tables, all preserved). - `HtmlArtifactPane.tsx` — pane shell. Header: title (from `html_artifact.payload.title`), Download button only (`?fmt=html`). Body: `<iframe srcdoc={html_content} sandbox="allow-scripts allow-clipboard-write allow-downloads" />` at full pane height. **No Copy button** for HTML. Pane state persisted via `sessions.workspace_panes jsonb` (the v1.12.1 schema already supports arbitrary pane payloads — extend the `Pane` discriminated union with two new variants). ### S4. Download endpoint New endpoint `POST /api/chats/:id/messages/:msg_id/artifacts/download?fmt=md|html`: - Resolves the message and (for HTML) its `html_artifact` part. - Computes slug: - Markdown: first `# ` heading text, else first 6 words of message body, lowercased + hyphenated. - HTML: `<title>` tag content, else first `<h1>` text, else first 6 words of inner text. Same lowercase-hyphen treatment. - Writes to `/opt/<project>/.boocode/artifacts/<slug>-<unix-timestamp>.<ext>`. Path-guarded same as native write tools — must stay under the project root. - Returns `{path, url}` where `url` is the pre-signed link via the existing static-file serving route. ### S5. HTML iframe security stance Locked from the original 2026-05-22 design: ``` sandbox="allow-scripts allow-clipboard-write allow-downloads" ``` **No `allow-same-origin`** — artifact has its own opaque origin, 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 applied to the iframe content (via `<meta http-equiv="Content-Security-Policy">` injected into the artifact's `<head>` if not already present): ``` default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none' ``` `connect-src 'none'` is the key clause — artifacts can't `fetch()`, can't open WebSockets, can't ping tracking pixels, can't exfiltrate. JS runs (interactive controls work) but nothing network-touching does. ### S6. Token-budget guard Single HTML artifact: max 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. ## Hard rules - No git commit, no git push, no git pull during dispatch. Sam commits manually. - Backup every file before edit per the standard convention (`.bak-v1.14.x-html-<YYYYMMDD>`). - TS strict, no `any`. - No new deps. The Markdown renderer, Shiki, the workspace splitter, and `navigator.clipboard.writeText` are all already in the bundle. - Schema migration is additive only (extend CHECK constraint), idempotent on re-run. - Path-guard layer (`apps/server/src/services/path_guard.ts`) enforces that downloads stay under the project root. - Secret-file deny list still runs on the resolved download path. - HTML iframe sandbox attributes are non-negotiable — exact attribute string as written in S5. ## Non-goals - **No auto-bias to HTML for long outputs.** The AGENTS.md rule explicitly says Markdown is default at every length. - **No inline iframe preview in the chat stream.** Pane-only. - **No Copy button on HTML panes.** Download-only for HTML. - **No separate artifacts table.** Artifacts live in `message_parts` (HTML) or derive from the assistant message (Markdown). Downloads are user-managed on disk under `/opt/<project>/.boocode/artifacts/`. - **No vendor of `anthropics/skills/web-artifacts-builder`.** That skill is built for Claude.ai's Vite/Parcel runtime; BooChat has no shell execution surface. Just lift the design principles into AGENTS.md. - **No changes to `apps/booterm` or `apps/coder`.** This is a BooChat-only batch. ## Stop checkpoints 1. After recon (read existing `Pane` discriminated union + workspace splitter + MessageBubble + `message_parts` shape + path_guard): stop, hand back the recon report. 2. After backend edits (detection + schema + download endpoint), before frontend work: stop, hand back diff + curl test of the download endpoint. 3. After frontend edits, before schema migration applies in dev: stop, hand back diff. 4. After schema migration applies in dev: stop, run smoke plan, report. ## Smoke plan 1. **Markdown pane — happy path.** Send a chat that produces a long Markdown reply (e.g. "explain the inference loop in detail"). Click "Open in pane" on the assistant message. Confirm: - Pane opens in the workspace splitter at full height. - Markdown renders with syntax highlighting on fenced code blocks (Shiki working). - Header shows a sensible title (first heading or first 6 words). - Copy button writes raw Markdown source to clipboard — paste into a text editor and verify it's the same source the assistant emitted. - Download button writes `/opt/boocode/.boocode/artifacts/<slug>-<ts>.md` and the file contains the raw source. 2. **HTML pane — happy path.** Send "render a simple HTML dashboard with three interactive sliders that update a div in real time." Confirm: - Model produces `<!DOCTYPE html>...` content. - `message_parts` row with `kind='html_artifact'` is written. - Click "Open in pane" — HTML pane renders the artifact in a sandboxed iframe. - Sliders work (JS runs inside the iframe). - Download button writes `.html` to the artifacts dir. - No Copy button on the HTML pane. 3. **HTML security — exfil attempt.** Send "render an HTML page that tries to fetch('https://example.com/exfil') and display the result." Confirm: - Iframe loads but the `fetch()` is blocked by `connect-src 'none'`. - Browser devtools shows the CSP violation. - No network request leaves the iframe. 4. **HTML security — DOM access attempt.** Send "render an HTML page with `<script>document.cookie</script>`." Confirm the script sees the iframe's own (empty) cookie jar, NOT BooChat's parent cookies — sandbox without `allow-same-origin` enforces opaque origin. 5. **Markdown opt-in HTML.** Send a normal "summarize the codebase" reply (Markdown), then a follow-up "now render that as HTML." Confirm the second reply produces an HTML artifact while the first stays plain-Markdown — detection is opportunistic, doesn't auto-promote. 6. **1MB cap.** Construct a synthetic test that asks for a >1MB HTML artifact. Confirm the streaming aborts with the friendly error message; no `message_parts` row with oversized payload is written. 7. **Path-guard enforcement on download.** Try to download with a hand-crafted slug containing `../`. Confirm the path-guard rejects it. 8. **Persistence across reload.** Open both a Markdown and an HTML pane. Hard-reload the browser. Confirm both panes restore via `sessions.workspace_panes`. ## Done when - Backend: `stream-phase.ts` detects HTML, writes `html_artifact` part. Schema migration shipped. Download endpoint live + path-guarded. - Frontend: `MarkdownArtifactPane` + `HtmlArtifactPane` components shipped. MessageBubble has the "Open in pane" affordance. Workspace `Pane` discriminated union extended. - AGENTS.md updated with the HTML-on-request rule. - Smoke plan green (all 8 steps). - Tag + CHANGELOG entry + roadmap retrospective bullet at the bottom of the v1.14.x-html roadmap section. ## Files expected to touch **Backend:** - `apps/server/src/schema.sql` — extend `message_parts.kind` CHECK constraint - `apps/server/src/services/inference/stream-phase.ts` — HTML detection in post-processing - `apps/server/src/services/inference/parts.ts` — `PartKind` union adds `'html_artifact'` - `apps/server/src/routes/messages.ts` — new `POST /api/chats/:id/messages/:msg_id/artifacts/download` endpoint (or new `artifacts.ts` route file) - `apps/server/src/services/artifacts.ts` — NEW. `writeMarkdownArtifact(msg, projectRoot)` + `writeHtmlArtifact(part, projectRoot)` + slug derivation helpers - `apps/server/src/services/path_guard.ts` — no change expected; existing guard handles the artifacts dir as a project-scoped write target **Frontend:** - `apps/web/src/components/MessageBubble.tsx` — add "Open in pane" affordance to assistant message footer - `apps/web/src/components/MarkdownArtifactPane.tsx` — NEW - `apps/web/src/components/HtmlArtifactPane.tsx` — NEW - `apps/web/src/types/panes.ts` (or wherever `Pane` lives) — extend discriminated union with `markdown_artifact` + `html_artifact` variants - `apps/web/src/api/client.ts` — `api.messages.downloadArtifact(msgId, fmt)` - `apps/web/src/api/types.ts` — mirror the new pane variants and `html_artifact` part kind **Docs:** - `data/AGENTS.md` — HTML-on-request rule - `boocode_roadmap.md` — retrospective bullet at the bottom of the v1.14.x-html section - `CHANGELOG.md` — new `##` entry with the tag ## Estimate ~400 LoC total. Backend ~200 LoC (detection + part-kind extension + download endpoint + slug derivation). Frontend ~200 LoC (two pane components + MessageBubble affordance + pane integration + API client wiring).