Files
boocode/openspec/changes/v1.14.x-html-artifact-panes/proposal.md
indifferentketchup ad45b28250 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>
2026-05-23 12:43:13 +00:00

14 KiB

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 <!DOCTYPE html>...</html> 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 <!DOCTYPE html> (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: <title> 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:

-- 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.tsPartKind 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.tsapi.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).