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>
7.0 KiB
7.0 KiB
v1.14.x-html-artifact-panes tasks
B1 — Backups
apps/server/src/schema.sql.bak-v1.14.x-html-<YYYYMMDD>apps/server/src/services/inference/stream-phase.ts.bak-v1.14.x-html-<YYYYMMDD>apps/server/src/services/inference/parts.ts.bak-v1.14.x-html-<YYYYMMDD>apps/server/src/routes/messages.ts.bak-v1.14.x-html-<YYYYMMDD>apps/web/src/components/MessageBubble.tsx.bak-v1.14.x-html-<YYYYMMDD>apps/web/src/api/client.ts.bak-v1.14.x-html-<YYYYMMDD>apps/web/src/api/types.ts.bak-v1.14.x-html-<YYYYMMDD>data/AGENTS.md.bak-v1.14.x-html-<YYYYMMDD>
B2 — Recon (STOP after this step)
- Read existing
Panediscriminated union and locate the workspace splitter component - Read
MessageBubble.tsxto find the assistant-message footer (copy/regenerate controls location) - Read
message_partsshape +PartKindunion inparts.ts - Read
stream-phase.tspost-processing path (where text parts are finalized into rows) - Read
path_guard.tsto confirm write semantics for/opt/<project>/.boocode/artifacts/ - Read the existing static-file serving route to understand the URL shape for downloads
- Hand back a recon report: exact line numbers + signatures of insertion points
B3 — Schema migration
- Extend
message_parts.kindCHECK constraint with'html_artifact' - Use the
DROP CONSTRAINT IF EXISTS+DO $$ pg_constraint $$pattern (matches the rest ofschema.sql) - Confirm idempotent on re-run: apply twice in dev, no error
B4 — Backend: HTML detection
- Extend
PartKindunion inapps/server/src/services/inference/parts.tswith'html_artifact' - In
stream-phase.tspost-processing: detect text parts starting with<!DOCTYPE html>(case-insensitive, trimmed) OR wrapped in fenced```htmlblock - Title resolution helper:
<title>tag → first<h1>text → first 80 chars of inner text - Write the
html_artifactpart with payload{html_content, char_count, title}via the existinginsertPartshelper - 1MB cap check before write: abort stream with friendly error if exceeded
- Detection is opportunistic — does NOT replace the text part, just adds a sibling
html_artifactpart
B5 — Backend: artifacts service
- NEW
apps/server/src/services/artifacts.ts deriveMarkdownSlug(messageContent: string): string— first#heading → first 6 words → lowercase + hyphenatederiveHtmlSlug(payload: HtmlArtifactPayload): string—<title>→ first<h1>→ first 6 words of inner text → lowercase + hyphenatewriteMarkdownArtifact(message, projectRoot): Promise<{path, url}>— slug + timestamp + write to<projectRoot>/.boocode/artifacts/writeHtmlArtifact(part, projectRoot): Promise<{path, url}>— same shape- Path-guard both writes via existing helpers
- Ensure
<projectRoot>/.boocode/artifacts/exists (mkdir recursive)
B6 — Backend: download endpoint
- NEW endpoint registration:
POST /api/chats/:id/messages/:msg_id/artifacts/download?fmt=md|html - Fastify route in
apps/server/src/routes/messages.ts(or newartifacts.tsroute file — decide during impl) - Zod schema on
?fmt=query param - Resolve message + (for HTML) the
html_artifactpart - Call
writeMarkdownArtifactorwriteHtmlArtifactperfmt - Return
{path, url} - Error path: 404 if
fmt=htmlrequested but no html_artifact part exists
B7 — Backend: STOP checkpoint after B3–B6
npx tsc --noEmit -p apps/server— 0 errors- Smoke download endpoint via
curl http://100.114.205.53:9500/api/chats/<id>/messages/<msg_id>/artifacts/download?fmt=mdagainst a real message - Hand back diff + curl output
B8 — Frontend: Pane discriminated union extension
- Extend
Panediscriminated union with two variants:{ kind: 'markdown_artifact', message_id: string }{ kind: 'html_artifact', message_id: string, html_content: string, title: string }
- Update
validatePanesto handle the new variants (no-op if message_id still exists) - Mirror types in
apps/web/src/api/types.ts(MessagePartdiscriminator + new pane variants)
B9 — Frontend: pane components
- NEW
apps/web/src/components/MarkdownArtifactPane.tsx- Header: title + Copy button (raw source via
navigator.clipboard.writeText) + Download button + close-pane affordance - Body: reuse the same Markdown render component used in
MessageBubble
- Header: title + Copy button (raw source via
- NEW
apps/web/src/components/HtmlArtifactPane.tsx- Header: title + Download button + close-pane affordance (NO Copy)
- Body:
<iframe srcdoc={html_content} sandbox="allow-scripts allow-clipboard-write allow-downloads" className="w-full h-full" />
- Wire both into the workspace splitter's pane-type registry
B10 — Frontend: MessageBubble affordance
- Add "Open in pane" icon button to assistant message footer (next to existing copy/regenerate controls)
- On click: dispatch workspace-pane action
- If message has
html_artifactpart → open as html_artifact pane (with title + html_content from the part) - Else → open as markdown_artifact pane
- If message has
- Mobile tap target:
max-md:min-h-[44px] max-md:min-w-[44px]
B11 — Frontend: API client
api.messages.downloadArtifact(chatId, msgId, fmt: 'md' | 'html')→ POST to the new endpoint- Returns
{path, url}— Copy button uses raw text from the message; Download button uses the returned URL
B12 — Frontend: STOP checkpoint after B8–B11
npx tsc -p apps/web/tsconfig.app.json --noEmit— 0 errors (root tsc may miss web errors per CLAUDE.md)pnpm -C apps/web buildsucceeds (including theU+2500-259Fguard)- Hand back diff
B13 — AGENTS.md guidance
- Add HTML-on-request rule to
data/AGENTS.md - Inline "avoid AI slop" design conventions (no centered layouts, no purple gradients, no uniform rounded corners, no Inter font)
- Cite Thariq Shihipar's blog post (May 2026) as the source
B14 — Smoke (STOP at end, full report)
- Markdown pane happy path (open, render, copy, download)
- HTML pane happy path (open, render, JS executes, download — no Copy button)
- HTML security exfil attempt —
fetch()blocked byconnect-src 'none' - HTML security DOM access — sandbox without
allow-same-originenforces opaque origin - Opt-in opportunistic detection — first reply Markdown, follow-up "render as HTML" produces artifact
- 1MB cap — synthetic test, streaming aborts with friendly error
- Path-guard on download — hand-crafted
../slug rejected - Persistence — pane state survives hard reload via
sessions.workspace_panes
B15 — OpenSpec docs + release
- Mark this
tasks.mdcheckboxes complete after each step - Append retrospective bullet to bottom of v1.14.x-html section in
boocode_roadmap.md - Add
CHANGELOG.mdentry with the assigned tag (e.g.v1.14.1-html-artifact-panes— final patch number assigned at ship time depending on order vs v1.14 outer loop) - Hand back to Sam for tag + commit