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

125 lines
7.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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