Files
boocode/openspec/changes/v1.14.x-html-artifact-panes/tasks.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

7.0 KiB
Raw Blame History

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 Pane discriminated union and locate the workspace splitter component
  • Read MessageBubble.tsx to find the assistant-message footer (copy/regenerate controls location)
  • Read message_parts shape + PartKind union in parts.ts
  • Read stream-phase.ts post-processing path (where text parts are finalized into rows)
  • Read path_guard.ts to 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.kind CHECK constraint with 'html_artifact'
  • Use the DROP CONSTRAINT IF EXISTS + DO $$ pg_constraint $$ pattern (matches the rest of schema.sql)
  • Confirm idempotent on re-run: apply twice in dev, no error

B4 — Backend: HTML detection

  • Extend PartKind union in apps/server/src/services/inference/parts.ts with 'html_artifact'
  • In stream-phase.ts post-processing: detect text parts starting with <!DOCTYPE html> (case-insensitive, trimmed) OR wrapped in fenced ```html block
  • Title resolution helper: <title> tag → first <h1> text → first 80 chars of inner text
  • Write the html_artifact part with payload {html_content, char_count, title} via the existing insertParts helper
  • 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_artifact part

B5 — Backend: artifacts service

  • NEW apps/server/src/services/artifacts.ts
  • deriveMarkdownSlug(messageContent: string): string — first # heading → first 6 words → lowercase + hyphenate
  • deriveHtmlSlug(payload: HtmlArtifactPayload): string<title> → first <h1> → first 6 words of inner text → lowercase + hyphenate
  • writeMarkdownArtifact(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 new artifacts.ts route file — decide during impl)
  • Zod schema on ?fmt= query param
  • Resolve message + (for HTML) the html_artifact part
  • Call writeMarkdownArtifact or writeHtmlArtifact per fmt
  • Return {path, url}
  • Error path: 404 if fmt=html requested but no html_artifact part exists

B7 — Backend: STOP checkpoint after B3B6

  • 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=md against a real message
  • Hand back diff + curl output

B8 — Frontend: Pane discriminated union extension

  • Extend Pane discriminated union with two variants:
    • { kind: 'markdown_artifact', message_id: string }
    • { kind: 'html_artifact', message_id: string, html_content: string, title: string }
  • Update validatePanes to handle the new variants (no-op if message_id still exists)
  • Mirror types in apps/web/src/api/types.ts (MessagePart discriminator + 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
  • 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_artifact part → open as html_artifact pane (with title + html_content from the part)
    • Else → open as markdown_artifact pane
  • 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 B8B11

  • npx tsc -p apps/web/tsconfig.app.json --noEmit — 0 errors (root tsc may miss web errors per CLAUDE.md)
  • pnpm -C apps/web build succeeds (including the U+2500-259F guard)
  • 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 by connect-src 'none'
  • HTML security DOM access — sandbox without allow-same-origin enforces 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.md checkboxes complete after each step
  • Append retrospective bullet to bottom of v1.14.x-html section in boocode_roadmap.md
  • Add CHANGELOG.md entry 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