Compare commits

..

1 Commits

Author SHA1 Message Date
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
24 changed files with 1864 additions and 195 deletions

View File

@@ -28,6 +28,13 @@
- Prefer codecontext (`search_symbols`, `get_symbol_info`, `get_dependencies`) over `grep` for symbol-level questions. Fall back to `grep` / `view_file` when codecontext returns degraded or empty results — that signals an unsupported language or parse failure.
- Verify before reporting work complete: run the relevant test/build/smoke command and confirm output matches the claim. Evidence first, assertion second.
## Output format
- Stay in Markdown by default for every reply, 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 me a dashboard", "build an interactive diagram"). Detection is opportunistic — the BooChat backend tags the assistant message as an HTML artifact, opens it in a sandboxed pane, and offers Download. Do not emit HTML unprompted; long Markdown is the right answer for most explanatory output.
- When asked to produce HTML, avoid generic AI aesthetics: no excessive centered layouts, no purple gradients, no uniform rounded corners, no Inter font. Prefer interactive controls (sliders / knobs / SVG / side-by-side diffs) over passive prose-in-HTML. Pattern reference: claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html (Thariq Shihipar, May 2026).
- The HTML artifact is rendered in a sandboxed iframe with `connect-src 'none'``fetch()`, WebSockets, and tracking pixels do not work. All logic must be client-side.
## Convention: rules vs recipes
Always-true rules (process discipline, refusals, behavior contracts) live here in `BOOCHAT.md` — and in `BOOCODER.md` / `CLAUDE.md` per their scopes — where they are 100% present in every turn. On-demand recipes (specific procedures, scaffolds, checklists) live in `/data/skills/` and invoke roughly 6% of the time in clean multi-turn flow (Codeminer42 measurement, 2026). Don't file workflow rules as skills — they silently misfire. See Anthropic agent-skills best-practices (platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices) for the canonical conventions.

View File

@@ -2,6 +2,10 @@
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
## v1.13.19-html-artifact-panes — 2026-05-23
Pane-based artifact viewer with on-request HTML support. Every assistant message gets an "Open in pane" icon button (`PanelRightOpen`, mobile 44px tap-target) in `MessageBubble`'s ActionRow; click opens the message in the workspace splitter as either a Markdown pane (Copy raw source + Download `.md`) or an HTML pane (Download `.html` only, no Copy). The HTML path triggers when the model emits a self-contained `<!DOCTYPE html>` or fenced ` ```html` artifact (opt-in only — `BOOCHAT.md` rule says Markdown is default at every length; HTML only on explicit user request like "render this as HTML"). Backend detection in `finalizeCompletion` (`error-handler.ts`) writes a new `message_parts.kind='html_artifact'` row with payload `{html_content, char_count, title}` (`<title>` → first `<h1>` → first 80 chars of inner text). Schema CHECK extended via the v1.13.13 drop-and-re-add pattern. 1MB cap is graceful — over-cap artifacts skip the part write and plain content lands; decision factored into a pure `decideHtmlArtifactWrite` helper so the warn-and-skip branch is unit-testable without mocking the full InferenceContext. Pane state is reference-only (`{chat_id, message_id, title}`) — content is fetched on mount, keeping `sessions.workspace_panes` jsonb small and avoiding 1MB blobs riding the `session_workspace_updated` WS frame. New `services/artifacts.ts` ships slug derivation (Markdown: first `#` heading → first 6 words; HTML: `<title>``<h1>` → inner text) and write helpers that realpath the artifacts directory after `mkdir` to close a symlink-escape gap (`assertArtifactsDirSafe`). `routes/artifacts.ts` exposes POST `/api/chats/:id/messages/:msg_id/artifacts/download?fmt=md|html` (writes to `<projectRoot>/.boocode/artifacts/<slug>-<ts>.<ext>`) plus GET `/api/projects/:project_id/artifacts/:filename` with `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff`, and `Content-Security-Policy: sandbox` defense-in-depth on LLM-served HTML. iframe sandbox locks to `allow-scripts allow-clipboard-write allow-downloads` with no `allow-same-origin` and uses `srcDoc` (not `src`) for opaque-origin isolation. Frontend extracts `MarkdownRenderer.tsx` from `MessageBubble`'s inline `MarkdownBody` for reuse; `MarkdownArtifactPane.tsx` / `HtmlArtifactPane.tsx` render with loading + error states. 404-vs-real-error discrimination in `openInPane`: a real network/500 failure toasts and bails instead of silently masquerading as a Markdown pane. 31 new server unit tests (slug derivation, detection positive/negative, write helpers, symlink-escape, 1MB cap, real-symlink filesystem test); 332/332 server tests passing; `tsc -p apps/web/tsconfig.app.json --noEmit` clean; `pnpm -C apps/web build` green. Smoke deferred to first deploy.
## v1.13.18-codecontext-file-path — 2026-05-22
Fix: four codecontext wrappers (`get_file_analysis`, `get_symbol_info`, `get_dependencies`, `get_semantic_neighborhoods`) forwarded `file_path` to the sidecar unchanged, but the sidecar's index is keyed on absolute paths — every relative path from the model returned "File not found in graph" (three back-to-back failures in one chat at 17:56 UTC, ~48 s of wasted tool budget). New `resolveProjectPath` helper in `codecontext_client.ts:64-89` realpath-resolves the candidate, applies the same escape check as the existing `target_dir` resolver (matching the error template byte-for-byte except the field name), and falls through with the normalised absolute on ENOENT so the sidecar issues its own self-correctable "File not found" error. Wired into `callCodecontext` once at the args-spread site — all four wrappers benefit without per-wrapper edits. `.trim()` added to all four `file_path` Zod schemas to absorb trailing newlines from model output. Adversarial review caught a P2 escape-bypass: an absolute path with `..` (e.g. `<projectRoot>/../etc/passwd`) that ENOENTs at realpath would slip through the literal prefix-check, fixed by `resolve()`-normalising the absolute branch too. 9 new test cases in `codecontext_client.test.ts` (7 spec scenarios + symlink-out-of-root + absolute-with-`..` ENOENT) plus a 1-line update in `codecontext_tools.test.ts` asserting the new resolved-absolute contract. Pairs with `v1.13.17-cross-repo-reads` — both harden path traversal, but v1.13.18 stays inside the project root while v1.13.17 widens access outside it.

View File

@@ -10,6 +10,7 @@ import { registerProjectRoutes } from './routes/projects.js';
import { registerSessionRoutes } from './routes/sessions.js';
import { registerSettingsRoutes } from './routes/settings.js';
import { registerMessageRoutes } from './routes/messages.js';
import { registerArtifactRoutes } from './routes/artifacts.js';
import { registerChatRoutes } from './routes/chats.js';
import { registerSidebarRoutes } from './routes/sidebar.js';
import { registerWebSocket } from './routes/ws.js';
@@ -160,6 +161,7 @@ async function main() {
broker.publishFrame(sessionId, frame as import('./types/ws-frames.js').WsFrame);
},
});
registerArtifactRoutes(app, sql);
registerSkillsRoutes(app, sql, {
enqueueInference: (sessionId, chatId, assistantId, user) => {
inference.enqueue(sessionId, chatId, assistantId, user);

View File

@@ -0,0 +1,231 @@
// v1.14.x-html-artifact-panes: artifact download routes.
//
// Two endpoints:
// POST /api/chats/:id/messages/:msg_id/artifacts/download?fmt=md|html
// Materialises a file under <projectRoot>/.boocode/artifacts/ and
// returns {path, url}. fmt=html requires an existing html_artifact part
// on the message (404 otherwise). fmt=md works on any assistant
// message with non-empty content.
//
// GET /api/projects/:project_id/artifacts/:filename
// Streams a previously-written artifact back with
// Content-Disposition: attachment. Path-guarded to the project's
// artifacts dir; rejects traversal attempts.
import { createReadStream } from 'node:fs';
import { realpath, stat } from 'node:fs/promises';
import { resolve, sep, basename } from 'node:path';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import {
writeHtmlArtifact,
writeMarkdownArtifact,
type HtmlArtifactPayload,
} from '../services/artifacts.js';
const DownloadQuery = z.object({
fmt: z.enum(['md', 'html']),
});
// Filename safety: alnum, dash, dot, underscore only. Blocks `..`, slashes,
// nul bytes, etc. before we even touch the filesystem.
const FilenameRe = /^[A-Za-z0-9._-]+$/;
interface ChatRow {
id: string;
session_id: string;
project_id: string;
project_path: string;
}
interface MessageRow {
id: string;
chat_id: string;
role: string;
content: string;
}
export function registerArtifactRoutes(app: FastifyInstance, sql: Sql): void {
app.post<{
Params: { id: string; msg_id: string };
Querystring: { fmt?: string };
}>(
'/api/chats/:id/messages/:msg_id/artifacts/download',
async (req, reply) => {
const parsed = DownloadQuery.safeParse(req.query);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid query', details: parsed.error.flatten() };
}
const { fmt } = parsed.data;
const { id: chatId, msg_id: messageId } = req.params;
const chatRows = await sql<ChatRow[]>`
SELECT c.id, c.session_id, s.project_id, p.path AS project_path
FROM chats c
JOIN sessions s ON s.id = c.session_id
JOIN projects p ON p.id = s.project_id
WHERE c.id = ${chatId}
`;
if (chatRows.length === 0) {
reply.code(404);
return { error: 'chat not found' };
}
const chat = chatRows[0]!;
const msgRows = await sql<MessageRow[]>`
SELECT id, chat_id, role, content
FROM messages
WHERE id = ${messageId} AND chat_id = ${chatId}
`;
if (msgRows.length === 0) {
reply.code(404);
return { error: 'message not found' };
}
const msg = msgRows[0]!;
if (msg.role !== 'assistant') {
reply.code(400);
return { error: 'only assistant messages produce artifacts' };
}
const ctx = { projectId: chat.project_id, projectRoot: chat.project_path };
try {
if (fmt === 'md') {
if (!msg.content || msg.content.trim().length === 0) {
reply.code(400);
return { error: 'message has no content to export' };
}
const result = await writeMarkdownArtifact(
{ content: msg.content },
ctx,
);
return result;
}
// fmt === 'html': require an html_artifact part on the message.
const partRows = await sql<{ payload: HtmlArtifactPayload }[]>`
SELECT payload
FROM message_parts
WHERE message_id = ${messageId} AND kind = 'html_artifact'
ORDER BY sequence ASC
LIMIT 1
`;
if (partRows.length === 0) {
reply.code(404);
return { error: 'no html_artifact part on this message' };
}
const result = await writeHtmlArtifact(partRows[0]!.payload, ctx);
return result;
} catch (err) {
req.log.error({ err, messageId, fmt }, 'artifact write failed');
reply.code(500);
return {
error: err instanceof Error ? err.message : 'artifact write failed',
};
}
},
);
// v1.14.x-html-artifact-panes: HtmlArtifactPane needs the payload on click
// to render its iframe. Returns 404 when the message has no html_artifact
// sibling part — frontend uses that signal to open the markdown_artifact
// pane variant instead. Payload shape matches HtmlArtifactPayload in
// services/artifacts.ts.
app.get<{ Params: { id: string; msg_id: string } }>(
'/api/chats/:id/messages/:msg_id/html_artifact',
async (req, reply) => {
const { id: chatId, msg_id: messageId } = req.params;
const partRows = await sql<{ payload: HtmlArtifactPayload }[]>`
SELECT payload
FROM message_parts mp
JOIN messages m ON m.id = mp.message_id
WHERE mp.message_id = ${messageId}
AND m.chat_id = ${chatId}
AND mp.kind = 'html_artifact'
ORDER BY mp.sequence ASC
LIMIT 1
`;
if (partRows.length === 0) {
reply.code(404);
return { error: 'no html_artifact part on this message' };
}
return partRows[0]!.payload;
},
);
app.get<{ Params: { project_id: string; filename: string } }>(
'/api/projects/:project_id/artifacts/:filename',
async (req, reply) => {
const { project_id: projectId, filename } = req.params;
// Strip directory components defensively; only the basename is allowed.
const base = basename(filename);
if (base !== filename || !FilenameRe.test(base)) {
reply.code(400);
return { error: 'invalid filename' };
}
const projectRows = await sql<{ id: string; path: string }[]>`
SELECT id, path FROM projects WHERE id = ${projectId}
`;
if (projectRows.length === 0) {
reply.code(404);
return { error: 'project not found' };
}
const project = projectRows[0]!;
let resolvedRoot: string;
try {
resolvedRoot = await realpath(project.path);
} catch {
reply.code(404);
return { error: 'project path missing' };
}
const artifactsDir = resolve(resolvedRoot, '.boocode/artifacts');
const absPath = resolve(artifactsDir, base);
if (!absPath.startsWith(artifactsDir + sep)) {
reply.code(400);
return { error: 'path traversal rejected' };
}
// Close the symlink-escape gap: if `.boocode/artifacts` (or an
// ancestor) is a symlink pointing outside resolvedRoot, the lexical
// prefix check above passes but the actual read lands outside the
// sandbox. Realpath the artifacts dir and re-verify.
try {
const realArtifactsDir = await realpath(artifactsDir);
if (
realArtifactsDir !== resolvedRoot &&
!realArtifactsDir.startsWith(resolvedRoot + sep)
) {
reply.code(400);
return { error: 'path traversal rejected' };
}
} catch {
reply.code(404);
return { error: 'artifact not found' };
}
try {
await stat(absPath);
} catch {
reply.code(404);
return { error: 'artifact not found' };
}
const ext = base.toLowerCase().endsWith('.html')
? 'text/html; charset=utf-8'
: base.toLowerCase().endsWith('.md')
? 'text/markdown; charset=utf-8'
: 'application/octet-stream';
reply.header('Content-Type', ext);
// Defense-in-depth on LLM-generated HTML served through this route.
// Authelia gates the proxy; these headers limit blast radius if a
// payload tries to escape that boundary in-browser.
reply.header('X-Content-Type-Options', 'nosniff');
reply.header('Content-Security-Policy', 'sandbox');
reply.header(
'Content-Disposition',
`attachment; filename="${base.replace(/"/g, '')}"`,
);
return reply.send(createReadStream(absPath));
},
);
}

View File

@@ -13,12 +13,37 @@ const CreateBody = z.object({
agent_id: z.string().min(1).max(200).nullable().optional(),
});
// v1.14.x-html-artifact-panes: 'markdown_artifact' + 'html_artifact' added
// as pane kinds. Pane state is a reference only (chat_id + message_id +
// title) — the actual artifact body is fetched from the message row or
// message_parts.payload by the pane component on mount.
const MarkdownArtifactStateZ = z.object({
chat_id: z.string().min(1).max(200),
message_id: z.string().min(1).max(200),
title: z.string().max(500),
});
const HtmlArtifactStateZ = z.object({
chat_id: z.string().min(1).max(200),
message_id: z.string().min(1).max(200),
title: z.string().max(500),
});
const WorkspacePaneZ = z.object({
id: z.string().min(1).max(200),
kind: z.enum(['chat', 'terminal', 'agent', 'empty', 'settings']),
kind: z.enum([
'chat',
'terminal',
'agent',
'empty',
'settings',
'markdown_artifact',
'html_artifact',
]),
chatId: z.string().min(1).max(200).optional(),
chatIds: z.array(z.string().min(1).max(200)).max(50),
activeChatIdx: z.number().int(),
markdown_artifact_state: MarkdownArtifactStateZ.optional(),
html_artifact_state: HtmlArtifactStateZ.optional(),
});
const WorkspacePanesBody = z.object({

View File

@@ -51,7 +51,7 @@ CREATE TABLE IF NOT EXISTS message_parts (
kind text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT message_parts_kind_chk CHECK (kind IN ('text', 'tool_call', 'tool_result', 'reasoning', 'step_start', 'synthesis')),
CONSTRAINT message_parts_kind_chk CHECK (kind IN ('text', 'tool_call', 'tool_result', 'reasoning', 'step_start', 'synthesis', 'html_artifact')),
CONSTRAINT message_parts_seq_uniq UNIQUE (message_id, sequence)
);
CREATE INDEX IF NOT EXISTS message_parts_msg_seq_idx ON message_parts (message_id, sequence);
@@ -79,6 +79,10 @@ CREATE INDEX IF NOT EXISTS message_parts_hidden_idx
-- 'synthesis'; drop + re-add the constraint with the extended enum. Fresh
-- installs hit the inline constraint above (already updated) and skip this
-- block via the pg_constraint guard.
-- v1.14.x-html-artifact-panes: extend the same constraint with 'html_artifact'.
-- DROP IF EXISTS + DO $$ pg_constraint $$ guard remains idempotent across
-- both v1.13.13 and v1.14.x boots; the IN list below is the union of every
-- kind ever shipped.
ALTER TABLE message_parts DROP CONSTRAINT IF EXISTS message_parts_kind_chk;
DO $$
BEGIN
@@ -87,7 +91,7 @@ BEGIN
) THEN
ALTER TABLE message_parts
ADD CONSTRAINT message_parts_kind_chk
CHECK (kind IN ('text', 'tool_call', 'tool_result', 'reasoning', 'step_start', 'synthesis'));
CHECK (kind IN ('text', 'tool_call', 'tool_result', 'reasoning', 'step_start', 'synthesis', 'html_artifact'));
END IF;
END $$;

View File

@@ -0,0 +1,261 @@
import { mkdtemp, mkdir, readFile, rm, symlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
decideHtmlArtifactWrite,
deriveHtmlSlug,
deriveHtmlTitle,
deriveMarkdownSlug,
detectHtmlArtifact,
HTML_ARTIFACT_MAX_BYTES,
writeHtmlArtifact,
writeMarkdownArtifact,
} from '../artifacts.js';
import { PathScopeError } from '../path_guard.js';
describe('deriveMarkdownSlug', () => {
it('uses the first # heading when present', () => {
expect(deriveMarkdownSlug('# Hello World\n\nbody')).toBe('hello-world');
});
it('falls back to first 6 words', () => {
const s = deriveMarkdownSlug('the quick brown fox jumps over the lazy dog');
expect(s).toBe('the-quick-brown-fox-jumps-over');
});
it('returns "artifact" for empty input', () => {
expect(deriveMarkdownSlug('')).toBe('artifact');
});
it('caps at 60 chars and lowercases', () => {
const long = '# ' + 'A'.repeat(200);
const s = deriveMarkdownSlug(long);
expect(s.length).toBeLessThanOrEqual(60);
expect(s).toMatch(/^[a-z0-9-]+$/);
});
it('strips trailing punctuation', () => {
expect(deriveMarkdownSlug('# Hello, World!!!')).toBe('hello-world');
});
});
describe('deriveHtmlSlug', () => {
it('prefers payload.title when set', () => {
expect(
deriveHtmlSlug({ html_content: '<html></html>', title: 'My Title' }),
).toBe('my-title');
});
it('falls back to <title> tag', () => {
expect(
deriveHtmlSlug({
html_content: '<html><head><title>Page Title</title></head></html>',
title: null,
}),
).toBe('page-title');
});
it('falls back to first <h1> when no <title>', () => {
expect(
deriveHtmlSlug({
html_content: '<html><body><h1>Heading One</h1></body></html>',
title: null,
}),
).toBe('heading-one');
});
it('falls back to inner text words', () => {
expect(
deriveHtmlSlug({
html_content: '<div>one two three four five six seven</div>',
title: null,
}),
).toBe('one-two-three-four-five-six');
});
});
describe('deriveHtmlTitle', () => {
it('returns <title> content', () => {
expect(deriveHtmlTitle('<html><head><title>T</title></head></html>')).toBe('T');
});
it('falls back to <h1>', () => {
expect(deriveHtmlTitle('<body><h1>H</h1></body>')).toBe('H');
});
it('falls back to first 80 chars of inner text', () => {
const html = '<div>' + 'x '.repeat(100) + '</div>';
const t = deriveHtmlTitle(html);
expect(t).not.toBeNull();
expect(t!.length).toBeLessThanOrEqual(80);
});
it('returns null for empty html', () => {
expect(deriveHtmlTitle('')).toBeNull();
});
});
describe('detectHtmlArtifact', () => {
it('detects <!DOCTYPE html> prefix case-insensitively', () => {
const html = '<!doctype HTML><html><body>x</body></html>';
expect(detectHtmlArtifact(html)).toBe(html);
});
it('strips leading/trailing whitespace before matching', () => {
const html = '\n\n<!DOCTYPE html>\n<html></html>\n';
expect(detectHtmlArtifact(html)).toBe(html.trim());
});
it('detects fenced ```html block wrapping entire message', () => {
const wrapped = '```html\n<!DOCTYPE html>\n<html></html>\n```';
expect(detectHtmlArtifact(wrapped)).toContain('<!DOCTYPE html>');
});
it('rejects plain markdown', () => {
expect(detectHtmlArtifact('# heading\n\nsome text')).toBeNull();
});
it('rejects message with prose before the doctype', () => {
expect(
detectHtmlArtifact('Here you go: <!DOCTYPE html><html></html>'),
).toBeNull();
});
it('rejects empty input', () => {
expect(detectHtmlArtifact('')).toBeNull();
expect(detectHtmlArtifact(' \n ')).toBeNull();
});
it('rejects fenced block without doctype/<html>', () => {
expect(detectHtmlArtifact('```html\n<div>x</div>\n```')).toBeNull();
});
it('accepts fenced block containing <html> tag (no doctype)', () => {
const r = detectHtmlArtifact('```html\n<html><body>x</body></html>\n```');
expect(r).toContain('<html>');
});
});
describe('writeMarkdownArtifact / writeHtmlArtifact', () => {
let projectRoot: string;
beforeEach(async () => {
projectRoot = await mkdtemp(join(tmpdir(), 'artifacts-test-'));
});
afterEach(async () => {
await rm(projectRoot, { recursive: true, force: true });
});
it('writes a markdown artifact under .boocode/artifacts/', async () => {
const result = await writeMarkdownArtifact(
{ content: '# Hello\n\nbody' },
{ projectId: 'pid', projectRoot },
);
expect(result.path).toMatch(/\.boocode\/artifacts\/hello-\d+\.md$/);
expect(result.url).toMatch(/^\/api\/projects\/pid\/artifacts\/hello-\d+\.md$/);
const written = await readFile(result.path, 'utf8');
expect(written).toBe('# Hello\n\nbody');
});
it('writes an html artifact', async () => {
const result = await writeHtmlArtifact(
{
html_content: '<!DOCTYPE html><html><head><title>X</title></head></html>',
char_count: 56,
title: 'X',
},
{ projectId: 'pid', projectRoot },
);
expect(result.path).toMatch(/\.boocode\/artifacts\/x-\d+\.html$/);
const written = await readFile(result.path, 'utf8');
expect(written).toContain('<!DOCTYPE html>');
});
it('creates the artifacts directory if absent', async () => {
// Confirm the writer mkdir-recursive's the artifacts dir on first call.
const result = await writeMarkdownArtifact(
{ content: '# T' },
{ projectId: 'pid', projectRoot },
);
expect(result.path).toContain('.boocode/artifacts');
});
});
describe('1MB cap behavior', () => {
it('reports the correct byte threshold', () => {
expect(HTML_ARTIFACT_MAX_BYTES).toBe(1_048_576);
});
it('exceeds threshold for oversize payload', () => {
const oversize = '<!DOCTYPE html>' + 'A'.repeat(HTML_ARTIFACT_MAX_BYTES);
expect(Buffer.byteLength(oversize, 'utf8')).toBeGreaterThan(
HTML_ARTIFACT_MAX_BYTES,
);
});
it('detectHtmlArtifact still returns content above the cap (cap is checked by caller)', () => {
// Detection is content-shape; the cap check lives in finalizeCompletion
// (error-handler.ts). This test pins that contract: the helper does not
// silently drop oversize payloads on the floor.
const big = '<!DOCTYPE html>' + 'x'.repeat(2_000_000);
expect(detectHtmlArtifact(big)).not.toBeNull();
});
});
describe('decideHtmlArtifactWrite', () => {
// Pure helper extracted from finalizeCompletion's cap-skip branch. Pins
// the warn-and-skip decision without mocking the full InferenceContext.
it('returns write=true for payloads under the cap', () => {
const html = '<!DOCTYPE html><html></html>';
const decision = decideHtmlArtifactWrite(html);
expect(decision.write).toBe(true);
expect(decision.byteLen).toBe(Buffer.byteLength(html, 'utf8'));
});
it('returns write=false with cap_exceeded reason for oversize payloads', () => {
const big = '<!DOCTYPE html>' + 'x'.repeat(HTML_ARTIFACT_MAX_BYTES);
const decision = decideHtmlArtifactWrite(big);
expect(decision.write).toBe(false);
if (!decision.write) {
expect(decision.reason).toBe('cap_exceeded');
expect(decision.byteLen).toBeGreaterThan(HTML_ARTIFACT_MAX_BYTES);
}
});
it('accepts payload exactly at the cap (boundary)', () => {
// byteLen === cap should write; only strictly greater skips.
const exact = 'x'.repeat(HTML_ARTIFACT_MAX_BYTES);
const decision = decideHtmlArtifactWrite(exact);
expect(decision.write).toBe(true);
expect(decision.byteLen).toBe(HTML_ARTIFACT_MAX_BYTES);
});
});
describe('symlink escape protection', () => {
// Closes the gap where `.boocode/artifacts` is a symlink pointing
// outside the project root. The lexical prefix check on the resolved
// candidate path passes (it's under projectRoot textually), but the
// post-mkdir realpath verification must catch the escape.
let projectRoot: string;
let outside: string;
beforeEach(async () => {
projectRoot = await mkdtemp(join(tmpdir(), 'artifacts-symlink-root-'));
outside = await mkdtemp(join(tmpdir(), 'artifacts-symlink-outside-'));
});
afterEach(async () => {
await rm(projectRoot, { recursive: true, force: true });
await rm(outside, { recursive: true, force: true });
});
it('throws PathScopeError when .boocode/artifacts is a symlink to outside the project', async () => {
// Create .boocode dir, then make `artifacts` a symlink pointing outside.
await mkdir(join(projectRoot, '.boocode'), { recursive: true });
await symlink(outside, join(projectRoot, '.boocode', 'artifacts'));
await expect(
writeMarkdownArtifact(
{ content: '# Hello' },
{ projectId: 'pid', projectRoot },
),
).rejects.toBeInstanceOf(PathScopeError);
});
});

View File

@@ -0,0 +1,255 @@
// v1.14.x-html-artifact-panes: artifact writer + slug derivation.
//
// Writes Markdown and HTML artifacts to `<projectRoot>/.boocode/artifacts/`
// as plain files. Returns `{path, url}` where:
// - path is the absolute on-disk path
// - url is a project-scoped REST URL pointing at the GET download route
// registered in routes/artifacts.ts. The route streams the file with
// Content-Disposition: attachment.
//
// Path safety: we do NOT use path_guard.ts (it realpaths and throws ENOENT
// for files that don't exist yet, which artifact creation requires).
// Instead we mirror the v1.13.18 codecontext_client.ts pattern: resolve
// the candidate path against the realpath'd projectRoot, then verify the
// result starts with projectRoot + sep (or equals projectRoot).
import { mkdir, realpath, writeFile } from 'node:fs/promises';
import { resolve, sep } from 'node:path';
import { PathScopeError } from './path_guard.js';
import type { Message } from '../types/api.js';
export interface HtmlArtifactPayload {
html_content: string;
char_count: number;
title: string | null;
}
export interface ArtifactWriteResult {
path: string;
url: string;
}
const ARTIFACT_SUBDIR = '.boocode/artifacts';
// ---- slug helpers ----
// Lowercase, replace non-alnum runs with '-', trim leading/trailing '-',
// collapse repeated '-', cap at 60 chars. Empty → 'artifact'.
function slugify(input: string): string {
const cleaned = input
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-')
.slice(0, 60)
.replace(/^-+|-+$/g, '');
return cleaned || 'artifact';
}
function firstHeading(md: string): string | null {
// Match the first `# ` ATX heading at the start of a line.
const m = md.match(/^[ \t]*#[ \t]+(.+?)\s*$/m);
if (!m) return null;
const text = m[1]?.trim() ?? '';
return text.length > 0 ? text : null;
}
function firstNWords(s: string, n: number): string {
const words = s.trim().split(/\s+/).filter(Boolean).slice(0, n);
return words.join(' ');
}
export function deriveMarkdownSlug(messageContent: string): string {
const heading = firstHeading(messageContent);
if (heading) return slugify(heading);
const sixWords = firstNWords(messageContent, 6);
return slugify(sixWords);
}
// Strip HTML tags for inner-text extraction. Crude but sufficient for slug
// derivation — we're not rendering, just finding readable words.
function stripTags(html: string): string {
return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, ' ')
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function extractTitleTag(html: string): string | null {
const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
if (!m) return null;
const text = stripTags(m[1] ?? '').trim();
return text.length > 0 ? text : null;
}
function extractH1(html: string): string | null {
const m = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
if (!m) return null;
const text = stripTags(m[1] ?? '').trim();
return text.length > 0 ? text : null;
}
export function deriveHtmlSlug(payload: {
html_content: string;
title: string | null;
}): string {
if (payload.title && payload.title.trim().length > 0) {
return slugify(payload.title);
}
const title = extractTitleTag(payload.html_content);
if (title) return slugify(title);
const h1 = extractH1(payload.html_content);
if (h1) return slugify(h1);
const inner = stripTags(payload.html_content);
return slugify(firstNWords(inner, 6));
}
// Derive title for the html_artifact part payload: <title> → first <h1> →
// first 80 chars of inner text. Returns null if nothing useful is found.
export function deriveHtmlTitle(html: string): string | null {
const t = extractTitleTag(html);
if (t) return t;
const h1 = extractH1(html);
if (h1) return h1;
const inner = stripTags(html);
if (inner.length === 0) return null;
return inner.slice(0, 80);
}
// ---- HTML detection (B4) ----
// Returns the inner HTML content if `text` is a recognised HTML artifact:
// - starts with <!DOCTYPE html> (case-insensitive, whitespace-trimmed), OR
// - wrapped entirely in a fenced ```html ... ``` block.
// Returns null if neither matches.
export function detectHtmlArtifact(text: string): string | null {
const trimmed = text.trim();
if (trimmed.length === 0) return null;
if (/^<!doctype\s+html/i.test(trimmed)) {
return trimmed;
}
// Fenced ```html block consuming the entire (trimmed) message. Allow an
// optional trailing newline before the closing fence.
const fence = trimmed.match(/^```html\s*\n([\s\S]*?)\n?```\s*$/i);
if (fence) {
const inner = fence[1] ?? '';
if (/^\s*<!doctype\s+html/i.test(inner) || /<html[\s>]/i.test(inner)) {
return inner.trim();
}
}
return null;
}
// ---- path resolution ----
// Resolve `<projectRoot>/.boocode/artifacts/<filename>` and verify the
// result stays under projectRoot. Mirrors the v1.13.18 codecontext_client.ts
// approach: realpath projectRoot first, then prefix-check the candidate.
// Throws on escape.
async function resolveArtifactPath(
projectRoot: string,
filename: string,
): Promise<{ resolvedRoot: string; artifactsDir: string; absPath: string }> {
const resolvedRoot = await realpath(projectRoot);
const artifactsDir = resolve(resolvedRoot, ARTIFACT_SUBDIR);
const absPath = resolve(artifactsDir, filename);
// Lexical prefix check on the resolved candidates. (The `!== resolvedRoot`
// branch was dead — ARTIFACT_SUBDIR is non-empty so artifactsDir always
// differs from resolvedRoot.)
if (!artifactsDir.startsWith(resolvedRoot + sep)) {
throw new PathScopeError(
`artifacts dir escapes project root: ${artifactsDir}`,
);
}
if (!absPath.startsWith(artifactsDir + sep)) {
throw new PathScopeError(
`artifact filename escapes artifacts dir: ${filename}`,
);
}
return { resolvedRoot, artifactsDir, absPath };
}
// After mkdir, realpath the artifacts dir and re-verify it stays under
// resolvedRoot. Closes the symlink-escape gap: if `.boocode/artifacts` (or
// any ancestor below resolvedRoot) is a symlink pointing outside the
// project, the lexical check in resolveArtifactPath passes but the actual
// write lands outside the sandbox. Throws PathScopeError on escape.
async function assertArtifactsDirSafe(
artifactsDir: string,
resolvedRoot: string,
): Promise<void> {
const realDir = await realpath(artifactsDir);
if (realDir !== resolvedRoot && !realDir.startsWith(resolvedRoot + sep)) {
throw new PathScopeError(
`artifacts dir resolves outside project root: ${realDir}`,
);
}
}
// Pure decision helper for whether finalizeCompletion should write the
// `html_artifact` part. Exported for unit testing the cap-skip branch.
// Returns `{write: true, byteLen}` when the payload is under the cap, or
// `{write: false, byteLen, reason: 'cap_exceeded'}` when oversize.
export type HtmlArtifactDecision =
| { write: true; byteLen: number }
| { write: false; byteLen: number; reason: 'cap_exceeded' };
export function decideHtmlArtifactWrite(
htmlContent: string,
): HtmlArtifactDecision {
const byteLen = Buffer.byteLength(htmlContent, 'utf8');
if (byteLen > HTML_ARTIFACT_MAX_BYTES) {
return { write: false, byteLen, reason: 'cap_exceeded' };
}
return { write: true, byteLen };
}
function buildUrl(projectId: string, filename: string): string {
return `/api/projects/${projectId}/artifacts/${encodeURIComponent(filename)}`;
}
export interface WriteContext {
projectId: string;
projectRoot: string;
}
export async function writeMarkdownArtifact(
message: Pick<Message, 'content'>,
ctx: WriteContext,
): Promise<ArtifactWriteResult> {
const slug = deriveMarkdownSlug(message.content);
const filename = `${slug}-${Date.now()}.md`;
const { resolvedRoot, artifactsDir, absPath } = await resolveArtifactPath(
ctx.projectRoot,
filename,
);
await mkdir(artifactsDir, { recursive: true });
await assertArtifactsDirSafe(artifactsDir, resolvedRoot);
await writeFile(absPath, message.content, 'utf8');
return { path: absPath, url: buildUrl(ctx.projectId, filename) };
}
export async function writeHtmlArtifact(
payload: HtmlArtifactPayload,
ctx: WriteContext,
): Promise<ArtifactWriteResult> {
const slug = deriveHtmlSlug(payload);
const filename = `${slug}-${Date.now()}.html`;
const { resolvedRoot, artifactsDir, absPath } = await resolveArtifactPath(
ctx.projectRoot,
filename,
);
await mkdir(artifactsDir, { recursive: true });
await assertArtifactsDirSafe(artifactsDir, resolvedRoot);
await writeFile(absPath, payload.html_content, 'utf8');
return { path: absPath, url: buildUrl(ctx.projectId, filename) };
}
// 1MB cap on HTML artifacts (proposal S6). Larger payloads are not written
// to the `html_artifact` part — the assistant text lands as plain content
// and a warning is logged. Streaming abort was considered but the graceful
// "no artifact, plain text falls back" path is simpler and lossless from
// the user's perspective.
export const HTML_ARTIFACT_MAX_BYTES = 1_048_576;

View File

@@ -1,7 +1,14 @@
import type { MessageMetadata, Session } from '../../types/api.js';
import {
decideHtmlArtifactWrite,
detectHtmlArtifact,
deriveHtmlTitle,
HTML_ARTIFACT_MAX_BYTES,
} from '../artifacts.js';
import * as modelContext from '../model-context.js';
import { maybeFlagForCompaction } from './payload.js';
import { insertParts, partsFromAssistantMessage } from './parts.js';
import type { PartInsert } from './parts.js';
import type { InferenceContext, StreamResult, TurnArgs } from './turn.js';
export async function handleAbortOrError(
@@ -120,17 +127,42 @@ export async function finalizeCompletion(
// a kind='reasoning' part alongside the text.
// TODO(v1.13.1): wrap the UPDATE above and this insertParts in a single
// sql.begin before flipping read authority to message_parts.
await insertParts(
ctx.sql,
partsFromAssistantMessage({
content,
tool_calls: null,
reasoning: result.reasoning,
}).map((p) => ({
...p,
message_id: assistantMessageId,
})),
);
const baseParts: PartInsert[] = partsFromAssistantMessage({
content,
tool_calls: null,
reasoning: result.reasoning,
}).map((p) => ({
...p,
message_id: assistantMessageId,
}));
// v1.14.x-html-artifact-panes: opportunistic HTML detection. Adds a
// SIBLING html_artifact part — never replaces the text part. 1MB cap is
// graceful: oversized payloads are skipped and the assistant message
// lands as plain content (warn logged).
const htmlContent = detectHtmlArtifact(content);
if (htmlContent !== null) {
const decision = decideHtmlArtifactWrite(htmlContent);
if (!decision.write) {
ctx.log.warn(
{ assistantMessageId, byteLen: decision.byteLen, cap: HTML_ARTIFACT_MAX_BYTES },
'html_artifact exceeded 1MB cap; skipping artifact part',
);
} else {
const title = deriveHtmlTitle(htmlContent);
const nextSeq = baseParts.reduce((m, p) => Math.max(m, p.sequence), -1) + 1;
baseParts.push({
message_id: assistantMessageId,
sequence: nextSeq,
kind: 'html_artifact',
payload: {
html_content: htmlContent,
char_count: htmlContent.length,
title,
},
});
}
}
await insertParts(ctx.sql, baseParts);
// v1.11: flag for compaction on the terminal turn too. Catches the common
// case of a turn that hit the limit without invoking tools.
await maybeFlagForCompaction(ctx, chatId, updated);

View File

@@ -11,13 +11,16 @@ import type { ToolCall, ToolResult } from '../../types/api.js';
// (schema.sql adds 'synthesis' to message_parts_kind_chk on startup). The
// dispatch's claim that no schema migration was needed assumed kind was a
// bare text column — it isn't; the constraint enumerates allowed values.
// v1.14.x-html-artifact-panes: 'html_artifact' added. Schema CHECK constraint
// in schema.sql updated in lockstep.
export type PartKind =
| 'text'
| 'tool_call'
| 'tool_result'
| 'reasoning'
| 'step_start'
| 'synthesis';
| 'synthesis'
| 'html_artifact';
export interface PartInsert {
message_id: string;

View File

@@ -50,7 +50,32 @@ export interface Session {
allowed_read_paths: string[];
}
export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty' | 'settings';
// v1.14.x-html-artifact-panes: 'markdown_artifact' + 'html_artifact' added.
// Optional payload state lives on the pane row itself so the jsonb survives
// a hard reload without needing a re-fetch.
export type WorkspacePaneKind =
| 'chat'
| 'terminal'
| 'agent'
| 'empty'
| 'settings'
| 'markdown_artifact'
| 'html_artifact';
// v1.14.x: reference-only — the actual artifact body lives in the message
// row (markdown) or message_parts.payload (html_artifact). Pane components
// fetch on mount.
export interface MarkdownArtifactState {
chat_id: string;
message_id: string;
title: string;
}
export interface HtmlArtifactState {
chat_id: string;
message_id: string;
title: string;
}
export interface WorkspacePane {
id: string;
@@ -58,6 +83,9 @@ export interface WorkspacePane {
chatId?: string;
chatIds: string[];
activeChatIdx: number;
// v1.14.x: populated only when kind === 'markdown_artifact' / 'html_artifact'.
markdown_artifact_state?: MarkdownArtifactState;
html_artifact_state?: HtmlArtifactState;
}
// v1.8.1: agents come from two sources. 'global' = /data/AGENTS.md (always

View File

@@ -276,6 +276,24 @@ export const api = {
request<void>(`/api/chats/${chatId}/messages/${messageId}`, {
method: 'DELETE',
}),
// v1.14.x-html-artifact-panes: write the artifact to
// <projectRoot>/.boocode/artifacts/<slug>-<ts>.<ext> and return the
// path + a /api/projects/.../artifacts/<filename> URL the browser can
// GET to download. fmt=html requires the assistant message to carry an
// html_artifact part (404 otherwise).
downloadArtifact: (chatId: string, messageId: string, fmt: 'md' | 'html') =>
request<{ path: string; url: string }>(
`/api/chats/${chatId}/messages/${messageId}/artifacts/download?fmt=${fmt}`,
{ method: 'POST' },
),
// v1.14.x-html-artifact-panes: fetch the html_artifact part payload so
// HtmlArtifactPane can render the iframe srcdoc. 404 = no html_artifact
// part on this message; MessageBubble uses that as a signal to fall back
// to the markdown pane variant.
getHtmlArtifact: (chatId: string, messageId: string) =>
request<{ html_content: string; char_count: number; title: string }>(
`/api/chats/${chatId}/messages/${messageId}/html_artifact`,
),
},
models: () => request<ModelInfo[]>('/api/models'),

View File

@@ -316,7 +316,37 @@ export interface AskUserAnswerSet {
// v1.9: 'settings' is an ephemeral pane kind — never persisted, always
// singleton per workspace. The pane hook filters it out before writing to
// localStorage and dedupes on insertion via toggleSettingsPane().
export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty' | 'settings';
// v1.14.x-html-artifact-panes: 'markdown_artifact' + 'html_artifact' added.
// Both carry payload state on the WorkspacePane row itself so
// useWorkspacePanes's JSON-string dedup + persisted jsonb stay self-contained
// — no extra fetch on rehydrate.
export type WorkspacePaneKind =
| 'chat'
| 'terminal'
| 'agent'
| 'empty'
| 'settings'
| 'markdown_artifact'
| 'html_artifact';
// v1.14.x: per-pane artifact payloads. Optional + namespaced so older saved
// pane rows (without these fields) deserialize unchanged.
// v1.14.x: pane state is a reference only — the pane component fetches the
// actual content on mount. This keeps sessions.workspace_panes jsonb small and
// makes the message body / html_artifact part the single source of truth.
export interface MarkdownArtifactState {
// chat_id is needed for the download endpoint
// (POST /api/chats/:chat_id/messages/:msg_id/artifacts/download).
chat_id: string;
message_id: string;
title: string;
}
export interface HtmlArtifactState {
chat_id: string;
message_id: string;
title: string;
}
export interface WorkspacePane {
id: string;
@@ -324,6 +354,9 @@ export interface WorkspacePane {
chatId?: string;
chatIds: string[];
activeChatIdx: number;
// v1.14.x: populated only when kind === 'markdown_artifact' / 'html_artifact'.
markdown_artifact_state?: MarkdownArtifactState;
html_artifact_state?: HtmlArtifactState;
}
export type WsFrame =

View File

@@ -0,0 +1,116 @@
// v1.14.x-html-artifact-panes: full-height HTML artifact viewer. Renders the
// model's HTML inside a sandboxed iframe — no allow-same-origin, srcdoc only
// (no separate URL), CSP injected by the backend writer. JS runs inside the
// iframe (interactive controls work) but fetch / WS / tracking pixels are
// blocked by connect-src 'none' on the CSP. NO Copy button per the spec.
//
// Pane state is a reference only (chat_id + message_id + title); the iframe
// payload is fetched on mount from
// GET /api/chats/:chat_id/messages/:msg_id/html_artifact so that
// sessions.workspace_panes jsonb stays small and message_parts.payload is the
// single source of truth.
import { useEffect, useState } from 'react';
import { Download, X } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { HtmlArtifactState } from '@/api/types';
interface Props {
chatId: string;
state: HtmlArtifactState;
onClose: () => void;
}
export function HtmlArtifactPane({ chatId, state, onClose }: Props) {
const [downloading, setDownloading] = useState(false);
const [htmlContent, setHtmlContent] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setHtmlContent(null);
setLoadError(null);
void (async () => {
try {
const payload = await api.messages.getHtmlArtifact(chatId, state.message_id);
if (cancelled) return;
setHtmlContent(payload.html_content);
} catch (err) {
if (cancelled) return;
setLoadError(err instanceof Error ? err.message : 'failed to load HTML artifact');
}
})();
return () => {
cancelled = true;
};
}, [chatId, state.message_id]);
async function download() {
if (downloading) return;
setDownloading(true);
try {
const { url, path } = await api.messages.downloadArtifact(
chatId,
state.message_id,
'html',
);
const a = document.createElement('a');
a.href = url;
a.rel = 'noopener';
a.click();
toast.success(`Saved to ${path}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'download failed');
} finally {
setDownloading(false);
}
}
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
<span className="text-xs text-muted-foreground truncate flex-1" title={state.title}>
{state.title || 'HTML artifact'}
</span>
<button
type="button"
onClick={() => void download()}
disabled={downloading || htmlContent === null}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Download HTML"
title="Download"
>
<Download size={12} />
</button>
<button
type="button"
onClick={onClose}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Close artifact pane"
title="Close"
>
<X size={12} />
</button>
</div>
<div className="flex-1 min-h-0 overflow-hidden bg-background">
{loadError ? (
<div className="p-4 text-sm text-destructive">Failed to load: {loadError}</div>
) : htmlContent === null ? (
<div className="p-4 text-sm text-muted-foreground">Loading HTML artifact</div>
) : (
<iframe
// Sandbox attributes are non-negotiable per the v1.14.x spec S5:
// no allow-same-origin → opaque origin → can't reach parent cookies
// or DOM. srcdoc (not src) means no URL exists to leak. JS runs
// (allow-scripts) but connect-src 'none' on the CSP inside the
// payload blocks fetch / WS / pixels.
srcDoc={htmlContent}
sandbox="allow-scripts allow-clipboard-write allow-downloads"
className="w-full h-full border-0"
title={state.title || 'HTML artifact'}
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,137 @@
// v1.14.x-html-artifact-panes: dedicated full-height Markdown viewer used
// when a user clicks "Open in pane" on an assistant message that has NO
// html_artifact part. Header carries Copy (raw source) + Download (server-
// materialised .md under <projectRoot>/.boocode/artifacts/) + close.
//
// Pane state is a reference only (chat_id + message_id + title); the markdown
// body is fetched on mount from GET /api/chats/:chat_id/messages by locating
// the matching message_id. This keeps sessions.workspace_panes jsonb small
// and the assistant message row remains the single source of truth.
import { useEffect, useState } from 'react';
import { Check, Copy, Download, X } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { MarkdownArtifactState } from '@/api/types';
import { MarkdownRenderer } from './MarkdownRenderer';
interface Props {
chatId: string;
state: MarkdownArtifactState;
onClose: () => void;
}
export function MarkdownArtifactPane({ chatId, state, onClose }: Props) {
const [justCopied, setJustCopied] = useState(false);
const [downloading, setDownloading] = useState(false);
const [content, setContent] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setContent(null);
setLoadError(null);
void (async () => {
try {
// No single-message GET endpoint exists; the chat-messages list is
// already cached server-side and the lookup is O(n) over a small
// window. Cheaper than adding a new route for one call site.
const messages = await api.chats.messages(chatId);
if (cancelled) return;
const msg = messages.find((m) => m.id === state.message_id);
if (!msg) {
setLoadError('Message not found');
return;
}
setContent(msg.content ?? '');
} catch (err) {
if (cancelled) return;
setLoadError(err instanceof Error ? err.message : 'failed to load message');
}
})();
return () => {
cancelled = true;
};
}, [chatId, state.message_id]);
async function copy() {
if (content === null) return;
try {
await navigator.clipboard.writeText(content);
setJustCopied(true);
setTimeout(() => setJustCopied(false), 1200);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'copy failed');
}
}
async function download() {
if (downloading) return;
setDownloading(true);
try {
const { url, path } = await api.messages.downloadArtifact(
chatId,
state.message_id,
'md',
);
// Trigger browser download from the returned URL. The endpoint stamps
// Content-Disposition: attachment so the click lands as a save.
const a = document.createElement('a');
a.href = url;
a.rel = 'noopener';
a.click();
toast.success(`Saved to ${path}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'download failed');
} finally {
setDownloading(false);
}
}
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
<span className="text-xs text-muted-foreground truncate flex-1" title={state.title}>
{state.title || 'Markdown artifact'}
</span>
<button
type="button"
onClick={() => void copy()}
disabled={content === null}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Copy markdown source"
title="Copy"
>
{justCopied ? <Check size={12} /> : <Copy size={12} />}
</button>
<button
type="button"
onClick={() => void download()}
disabled={downloading || content === null}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Download markdown"
title="Download"
>
<Download size={12} />
</button>
<button
type="button"
onClick={onClose}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Close artifact pane"
title="Close"
>
<X size={12} />
</button>
</div>
<div className="flex-1 min-h-0 overflow-auto px-4 py-3 text-sm">
{loadError ? (
<div className="text-destructive">Failed to load: {loadError}</div>
) : content === null ? (
<div className="text-muted-foreground">Loading</div>
) : (
<MarkdownRenderer content={content} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,148 @@
// v1.14.x-html-artifact-panes: extracted from MessageBubble.tsx so both the
// in-chat bubble renderer and the MarkdownArtifactPane share the same Shiki +
// remark-gfm + path-linkifier pipeline. Behavior preserved byte-for-byte from
// the original MessageBubble.MarkdownBody helper (and its linkify helpers).
import { Children, cloneElement, isValidElement } from 'react';
import type { ReactElement, ReactNode } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { CodeBlock } from './CodeBlock';
import { sessionEvents } from '@/hooks/sessionEvents';
// Match path-shaped substrings ending in `.ext`. Additionally require a `/`
// in the match to reduce false positives in prose (e.g. plain `foo.ts` won't
// match, but `src/foo.ts` will). False positives at the edges are accepted
// per Sam's design decision (2026-05-14).
const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g;
function isPathLike(s: string): boolean {
return s.includes('/');
}
function emitOpenFile(path: string): void {
sessionEvents.emit({ type: 'open_file_in_browser', path });
}
function linkifyPaths(text: string, keyPrefix: string): ReactNode {
const out: ReactNode[] = [];
let lastIdx = 0;
let idx = 0;
for (const match of text.matchAll(PATH_REGEX)) {
const matchedText = match[0];
const start = match.index ?? 0;
if (!isPathLike(matchedText)) continue;
if (start > lastIdx) out.push(text.slice(lastIdx, start));
out.push(
<button
key={`${keyPrefix}-${idx}`}
type="button"
onClick={() => emitOpenFile(matchedText)}
className="text-primary underline cursor-pointer hover:text-primary/80"
>
{matchedText}
</button>
);
lastIdx = start + matchedText.length;
idx += 1;
}
if (out.length === 0) return text;
if (lastIdx < text.length) out.push(text.slice(lastIdx));
return out;
}
function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode {
const arr = Children.toArray(children);
return arr.map((child, i) => {
if (typeof child === 'string') {
return (
<span key={`${keyPrefix}-${i}`}>
{linkifyPaths(child, `${keyPrefix}-${i}`)}
</span>
);
}
if (isValidElement(child)) {
const el = child as ReactElement<{ children?: ReactNode }>;
if (el.type === 'code' || el.type === CodeBlock) return child;
const grandchildren = el.props.children;
if (grandchildren === undefined) return child;
return cloneElement(el, {
key: el.key ?? `linkified-${i}`,
children: linkifyChildren(grandchildren, `${keyPrefix}-${i}`),
});
}
return child;
});
}
const codeRenderer = (props: { children?: unknown; className?: string }) => {
const { children, className, ...rest } = props;
const text = String(children ?? '').replace(/\n$/, '');
const langMatch = /language-([\w-]+)/.exec(className ?? '');
const isBlock = !!langMatch || text.includes('\n');
if (isBlock) {
return <CodeBlock code={text} lang={langMatch?.[1]} />;
}
return (
<code
{...rest}
className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]"
>
{children as React.ReactNode}
</code>
);
};
export function MarkdownRenderer({ content }: { content: string }) {
return (
<Markdown
remarkPlugins={[remarkGfm]}
components={{
pre: ({ children }) => <>{children}</>,
code: codeRenderer,
a: ({ children, href }) => (
<a
href={href}
target="_blank"
rel="noreferrer"
className="underline decoration-muted-foreground/40 underline-offset-2 hover:decoration-foreground"
>
{children}
</a>
),
ul: ({ children }) => (
<ul className="list-disc pl-5 space-y-1">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal pl-5 space-y-1">{children}</ol>
),
li: ({ children }) => <li>{linkifyChildren(children)}</li>,
p: ({ children }) => (
<p className="leading-relaxed">{linkifyChildren(children)}</p>
),
h1: ({ children }) => <h1 className="text-base font-semibold mt-2">{children}</h1>,
h2: ({ children }) => <h2 className="text-sm font-semibold mt-2">{children}</h2>,
h3: ({ children }) => <h3 className="text-sm font-semibold mt-1">{children}</h3>,
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-border pl-3 text-muted-foreground">
{children}
</blockquote>
),
table: ({ children }) => (
<div className="overflow-x-auto">
<table className="border-collapse text-xs">{children}</table>
</div>
),
th: ({ children }) => (
<th className="border border-border px-2 py-1 text-left font-medium">{children}</th>
),
td: ({ children }) => (
<td className="border border-border px-2 py-1">
{linkifyChildren(children)}
</td>
),
}}
>
{content}
</Markdown>
);
}

View File

@@ -1,16 +1,14 @@
import { Children, cloneElement, isValidElement, useEffect, useState } from 'react';
import type { ReactElement, ReactNode } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, PanelRightOpen } from 'lucide-react';
import { toast } from 'sonner';
import type { Chat, ErrorReason, Message } from '@/api/types';
import { api } from '@/api/client';
import { api, ApiError } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
import { CapHitSentinel } from './CapHitSentinel';
import { DoomLoopSentinel } from './DoomLoopSentinel';
import { CodeBlock } from './CodeBlock';
import { MarkdownRenderer } from './MarkdownRenderer';
import { Button } from '@/components/ui/button';
import {
ContextMenu,
@@ -90,76 +88,20 @@ const ERROR_REASON_LABELS: Record<ErrorReason, string> = {
summary_after_cap_failed: 'Summary after tool budget hit failed',
};
// Match path-shaped substrings ending in `.ext`. Additionally require a `/`
// in the match to reduce false positives in prose (e.g. plain `foo.ts` won't
// match, but `src/foo.ts` will). False positives at the edges are accepted
// per Sam's design decision (2026-05-14).
const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g;
// v1.14.x-html-artifact-panes: MarkdownBody and its path-linkifier helpers
// moved to apps/web/src/components/MarkdownRenderer.tsx so the new artifact
// panes can render assistant content with the same Shiki + remark-gfm setup.
function isPathLike(s: string): boolean {
return s.includes('/');
}
function emitOpenFile(path: string): void {
sessionEvents.emit({ type: 'open_file_in_browser', path });
}
// Split a plain string into a flat array of strings and clickable button
// nodes for path-shaped substrings. If no matches, returns the original
// string verbatim (no array wrapping).
function linkifyPaths(text: string, keyPrefix: string): ReactNode {
const out: ReactNode[] = [];
let lastIdx = 0;
let idx = 0;
for (const match of text.matchAll(PATH_REGEX)) {
const matchedText = match[0];
const start = match.index ?? 0;
if (!isPathLike(matchedText)) continue;
if (start > lastIdx) out.push(text.slice(lastIdx, start));
out.push(
<button
key={`${keyPrefix}-${idx}`}
type="button"
onClick={() => emitOpenFile(matchedText)}
className="text-primary underline cursor-pointer hover:text-primary/80"
>
{matchedText}
</button>
);
lastIdx = start + matchedText.length;
idx += 1;
}
if (out.length === 0) return text;
if (lastIdx < text.length) out.push(text.slice(lastIdx));
return out;
}
// Walk react-markdown children, linkifying string text nodes. Children of
// <code> nodes (CodeBlock and inline code) are left untouched — the regex
// shouldn't run inside code spans.
function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode {
const arr = Children.toArray(children);
return arr.map((child, i) => {
if (typeof child === 'string') {
return (
<span key={`${keyPrefix}-${i}`}>
{linkifyPaths(child, `${keyPrefix}-${i}`)}
</span>
);
}
if (isValidElement(child)) {
const el = child as ReactElement<{ children?: ReactNode }>;
// Skip inline/block code — paths in code spans aren't link targets.
if (el.type === 'code' || el.type === CodeBlock) return child;
const grandchildren = el.props.children;
if (grandchildren === undefined) return child;
return cloneElement(el, {
key: el.key ?? `linkified-${i}`,
children: linkifyChildren(grandchildren, `${keyPrefix}-${i}`),
});
}
return child;
});
// Pane-header title derivation for a markdown artifact. Order matches the
// server slug logic in services/artifacts.ts: first `# ` heading → first 6
// words of the body → 'Markdown artifact'. Truncated to keep the pane header
// readable.
function deriveMarkdownTitle(content: string): string {
const headingMatch = content.match(/^\s*#\s+(.+?)\s*$/m);
if (headingMatch && headingMatch[1]) return headingMatch[1].slice(0, 80);
const words = content.trim().split(/\s+/).slice(0, 6).join(' ');
if (words) return words.slice(0, 80);
return 'Markdown artifact';
}
interface Props {
@@ -170,80 +112,6 @@ interface Props {
capHitInfo?: { position: number; isLatest: boolean };
}
function MarkdownBody({ content }: { content: string }) {
return (
<Markdown
remarkPlugins={[remarkGfm]}
components={{
pre: ({ children }) => <>{children}</>,
code: (props) => {
const { children, className, ...rest } = props as {
children?: unknown;
className?: string;
};
const text = String(children ?? '').replace(/\n$/, '');
const langMatch = /language-([\w-]+)/.exec(className ?? '');
const isBlock = !!langMatch || text.includes('\n');
if (isBlock) {
return <CodeBlock code={text} lang={langMatch?.[1]} />;
}
return (
<code
{...rest}
className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]"
>
{children as React.ReactNode}
</code>
);
},
a: ({ children, href }) => (
<a
href={href}
target="_blank"
rel="noreferrer"
className="underline decoration-muted-foreground/40 underline-offset-2 hover:decoration-foreground"
>
{children}
</a>
),
ul: ({ children }) => (
<ul className="list-disc pl-5 space-y-1">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal pl-5 space-y-1">{children}</ol>
),
li: ({ children }) => <li>{linkifyChildren(children)}</li>,
p: ({ children }) => (
<p className="leading-relaxed">{linkifyChildren(children)}</p>
),
h1: ({ children }) => <h1 className="text-base font-semibold mt-2">{children}</h1>,
h2: ({ children }) => <h2 className="text-sm font-semibold mt-2">{children}</h2>,
h3: ({ children }) => <h3 className="text-sm font-semibold mt-1">{children}</h3>,
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-border pl-3 text-muted-foreground">
{children}
</blockquote>
),
table: ({ children }) => (
<div className="overflow-x-auto">
<table className="border-collapse text-xs">{children}</table>
</div>
),
th: ({ children }) => (
<th className="border border-border px-2 py-1 text-left font-medium">{children}</th>
),
td: ({ children }) => (
<td className="border border-border px-2 py-1">
{linkifyChildren(children)}
</td>
),
}}
>
{content}
</Markdown>
);
}
function StatsLine({ message }: { message: Message }) {
const tokens = message.tokens_used;
if (typeof tokens !== 'number' || tokens <= 0) return null;
@@ -337,6 +205,54 @@ function ActionRow({
const canRegen = isAssistant && message.status !== 'streaming';
const canFork = message.status === 'complete';
const canDelete = message.status !== 'streaming';
const [openingPane, setOpeningPane] = useState(false);
// v1.14.x-html-artifact-panes: probe for an html_artifact part. If present,
// open the HTML pane variant; otherwise fall back to the markdown variant.
// Title derivation for markdown: first `# ` heading → first 6 words of the
// body → 'Markdown artifact' (mirrors the slug logic in
// services/artifacts.ts).
async function openInPane() {
if (openingPane || message.status === 'streaming') return;
setOpeningPane(true);
try {
try {
const payload = await api.messages.getHtmlArtifact(
message.chat_id,
message.id,
);
sessionEvents.emit({
type: 'open_html_artifact_pane',
state: {
chat_id: message.chat_id,
message_id: message.id,
title: payload.title,
},
});
return;
} catch (err) {
// 404 (no html_artifact part) is the expected fall-through path —
// markdown variant opens below. Any other error (network, 500) is
// a real failure; toast and bail rather than masquerading as markdown.
const status = err instanceof ApiError ? err.status : null;
if (status !== 404) {
toast.error(err instanceof Error ? err.message : 'open in pane failed');
return;
}
}
const title = deriveMarkdownTitle(message.content);
sessionEvents.emit({
type: 'open_markdown_artifact_pane',
state: {
chat_id: message.chat_id,
message_id: message.id,
title,
},
});
} finally {
setOpeningPane(false);
}
}
return (
<>
@@ -350,6 +266,18 @@ function ActionRow({
>
{justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
</button>
{isAssistant && (
<button
type="button"
onClick={() => void openInPane()}
disabled={openingPane || message.status === 'streaming'}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Open in pane"
title="Open in pane"
>
<PanelRightOpen className="size-3" />
</button>
)}
{isAssistant && (
<button
type="button"
@@ -588,7 +516,7 @@ function SummaryCard({ message }: { message: Message }) {
</div>
{expanded && (
<div className="px-3 pb-3 text-xs leading-relaxed border-t pt-2">
<MarkdownBody content={message.content} />
<MarkdownRenderer content={message.content} />
</div>
)}
</div>
@@ -667,7 +595,7 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
{(hasContent || isStreaming) && (
<SendToTerminalMenu>
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
{hasContent ? <MarkdownBody content={message.content} /> : null}
{hasContent ? <MarkdownRenderer content={message.content} /> : null}
{isStreaming && (
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
)}

View File

@@ -8,6 +8,8 @@ import { terminalsRegistry } from '@/lib/events';
import { ChatPane } from '@/components/panes/ChatPane';
import { SettingsPane } from '@/components/panes/SettingsPane';
import { TerminalPane } from '@/components/panes/TerminalPane';
import { MarkdownArtifactPane } from '@/components/MarkdownArtifactPane';
import { HtmlArtifactPane } from '@/components/HtmlArtifactPane';
import { ChatTabBar } from '@/components/ChatTabBar';
import { SessionLandingPage } from '@/components/SessionLandingPage';
import {
@@ -182,6 +184,7 @@ export function Workspace({
{panes.map((pane, idx) => {
const isSettings = pane.kind === 'settings';
const isTerminal = pane.kind === 'terminal';
const isArtifact = pane.kind === 'markdown_artifact' || pane.kind === 'html_artifact';
// v1.9: when maximized, hide every pane except the settings one.
// display:none keeps the React tree mounted so streams / drafts
// survive the toggle without re-mount cost.
@@ -195,7 +198,7 @@ export function Workspace({
}
// Terminal panes own their tab strip (no chats, no ChatTabBar) and
// are not drag-reorderable for now — keeps the layout grid simple.
const isChromeless = isSettings || isTerminal;
const isChromeless = isSettings || isTerminal || isArtifact;
return (
<div
key={pane.id}
@@ -318,6 +321,18 @@ export function Workspace({
label={terminalLabels.get(pane.id) ?? 'Terminal'}
active={idx === activePaneIdx}
/>
) : pane.kind === 'markdown_artifact' && pane.markdown_artifact_state ? (
<MarkdownArtifactPane
chatId={pane.markdown_artifact_state.chat_id}
state={pane.markdown_artifact_state}
onClose={() => removePane(idx)}
/>
) : pane.kind === 'html_artifact' && pane.html_artifact_state ? (
<HtmlArtifactPane
chatId={pane.html_artifact_state.chat_id}
state={pane.html_artifact_state}
onClose={() => removePane(idx)}
/>
) : pane.kind === 'chat' && pane.chatId ? (
<ChatPane
sessionId={sessionId}

View File

@@ -2,7 +2,14 @@
// across hooks (e.g. AI rename arriving via WS in the session view needs to
// also refresh the sidebar's session list).
import type { Chat, ErrorReason, Project, Session } from '@/api/types';
import type {
Chat,
ErrorReason,
HtmlArtifactState,
MarkdownArtifactState,
Project,
Session,
} from '@/api/types';
import type { Attachment } from '@/lib/attachments';
export interface SessionRenamedEvent {
@@ -68,6 +75,19 @@ export interface OpenChatInActivePaneEvent {
chat_id: string;
}
// v1.14.x-html-artifact-panes: ActionRow's "Open in pane" button emits one of
// these; useWorkspacePanes subscribes and inserts the corresponding artifact
// pane (or focuses an existing one keyed by message_id).
export interface OpenMarkdownArtifactPaneEvent {
type: 'open_markdown_artifact_pane';
state: MarkdownArtifactState;
}
export interface OpenHtmlArtifactPaneEvent {
type: 'open_html_artifact_pane';
state: HtmlArtifactState;
}
// Client-side event fired by the sidebar Settings button when a session is
// currently mounted. Session.tsx subscribes and calls
// panesHook.toggleSettingsPane() (open on first click, close on second).
@@ -154,6 +174,8 @@ export type SessionEvent =
| OpenFileInBrowserEvent
| AttachChatFileEvent
| OpenChatInActivePaneEvent
| OpenMarkdownArtifactPaneEvent
| OpenHtmlArtifactPaneEvent
| OpenSettingsPaneEvent
| SessionArchivedEvent
| ChatCreatedEvent

View File

@@ -154,6 +154,11 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
case 'open_chat_in_active_pane':
// Consumed by Workspace; sidebar has no business with pane state.
return prev;
case 'open_markdown_artifact_pane':
case 'open_html_artifact_pane':
// v1.14.x-html-artifact-panes: consumed by useWorkspacePanes; sidebar
// has no business with pane state.
return prev;
case 'open_settings_pane':
// Consumed by Session.tsx (calls toggleSettingsPane on its panesHook).
// Sidebar data is untouched.

View File

@@ -2,7 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import type { DragEvent } from 'react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { WorkspacePane } from '@/api/types';
import type {
HtmlArtifactState,
MarkdownArtifactState,
WorkspacePane,
} from '@/api/types';
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
import { sessionEvents } from '@/hooks/sessionEvents';
@@ -43,6 +47,28 @@ function settingsPane(): WorkspacePane {
return { id: generateId(), kind: 'settings', chatIds: [], activeChatIdx: -1 };
}
// v1.14.x-html-artifact-panes: artifact pane factories. Payload travels with
// the pane row so the sessions.workspace_panes jsonb survives reload.
function markdownArtifactPane(state: MarkdownArtifactState): WorkspacePane {
return {
id: generateId(),
kind: 'markdown_artifact',
chatIds: [],
activeChatIdx: -1,
markdown_artifact_state: state,
};
}
function htmlArtifactPane(state: HtmlArtifactState): WorkspacePane {
return {
id: generateId(),
kind: 'html_artifact',
chatIds: [],
activeChatIdx: -1,
html_artifact_state: state,
};
}
// v1.9: settings panes are ephemeral. Filter them out before persisting so a
// page reload always returns to a clean workspace; the user re-opens via the
// sidebar Settings button when needed.
@@ -169,6 +195,50 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
});
}, [sessionId]);
// v1.14.x-html-artifact-panes: ActionRow's "Open in pane" emits one of
// these per click. If a pane already exists for the same message_id, focus
// it instead of stacking a duplicate. Otherwise append (capped at MAX_PANES;
// settings panes don't count, matching addSplitPane's rule).
useEffect(() => {
return sessionEvents.subscribe((ev) => {
if (
ev.type !== 'open_markdown_artifact_pane' &&
ev.type !== 'open_html_artifact_pane'
) {
return;
}
setPanes((prev) => {
const targetKind: WorkspacePane['kind'] =
ev.type === 'open_html_artifact_pane' ? 'html_artifact' : 'markdown_artifact';
const messageId = ev.state.message_id;
const existingIdx = prev.findIndex((p) =>
p.kind === 'markdown_artifact'
? p.markdown_artifact_state?.message_id === messageId
: p.kind === 'html_artifact'
? p.html_artifact_state?.message_id === messageId
: false,
);
if (existingIdx >= 0) {
setActivePaneIdx(existingIdx);
return prev;
}
if (nonSettingsCount(prev) >= MAX_PANES) {
toast.error(`Maximum ${MAX_PANES} panes`);
return prev;
}
const newPane =
ev.type === 'open_html_artifact_pane'
? htmlArtifactPane(ev.state)
: markdownArtifactPane(ev.state);
// Defensive: assert kind matches for the discriminated union.
if (newPane.kind !== targetKind) return prev;
const next = [...prev, newPane];
setActivePaneIdx(next.length - 1);
return next;
});
});
}, []);
// v1.12.1: debounced PATCH on every change. Settings panes are stripped
// before saving (ephemeral per v1.9).
useEffect(() => {

View File

@@ -263,45 +263,52 @@ After v1.13.2 ships, tag the umbrella `v1.13` on the same commit (or on -C — S
-----
## v1.14.x-html — HTML artifacts in BooChat (NEW, 2026-05-22)
## v1.14.x-html — pane-based artifact viewer with Markdown + HTML (REVISED, 2026-05-23)
**Goal:** integrate Thariq Shihipar's "HTML > Markdown for agent output at length" pattern (`claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html`, May 20 2026) into BooChat. Bias the model toward HTML for outputs >100 lines: information density, visual clarity, interactive controls (sliders/knobs/SVG diagrams/side-by-side comparisons), shareability. BooChat already renders into a webview, so the surface fit is unusually good.
**Goal:** every assistant message gets an "Open in pane" affordance that renders it as an artifact — Markdown by default (the model's normal output), HTML only when the user explicitly asks for it (e.g. "render this as HTML", "make me a dashboard", "build an interactive diagram"). Both artifact types open in BooChat's existing workspace splitter. Markdown panes have **Copy** (raw source) + **Download** (`.md`); HTML panes have **Download** (`.html`) only. No inline iframe preview — artifacts are pane-only.
Inspired by Thariq Shihipar's "HTML > Markdown at length" pattern (`claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html`, May 20 2026), but scoped down from that post's "auto-bias to HTML for >100 lines" recommendation: Markdown stays the default everywhere, HTML is an on-request rendering target for cases where interactive controls / diagrams / side-by-side layouts pay off.
**Scope:**
1. **Model-side prompting** (no code change yet, just AGENTS.md guidance):
- Add HTML-bias rule to global `AGENTS.md`: "For outputs >100 lines, default to a self-contained `<!DOCTYPE html>...</html>` artifact unless the user explicitly asks for Markdown. For outputs <100 lines or for short conversational replies, stay in Markdown."
- Reasoning shown in the rule: HTML carries diagrams, tabs, illustrations, code-with-syntax-highlighting, interactive controls, mobile-responsive layouts. Markdown is restrictive at any length.
- Cite Thariq's blog post in the rule comment so future audit passes know where it came from.
1. **Detection at the BooChat backend.** In `apps/chat/services/inference/stream-phase.ts` post-processing: detect any assistant text part starting with `<!DOCTYPE html>` (case-insensitive, whitespace-trimmed) — or wrapped in a fenced ` ```html` block — and tag it as an HTML artifact. Emit a new part kind `html_artifact` into `message_parts` (CHECK constraint update). Payload: `{html_content, char_count, title}`. Title pulled from `<title>` tag or first `<h1>` if available.
1. **Three render targets (Sam's pick: "3 with a download"):**
- **Inline preview** in the chat stream: small sandboxed iframe (~400px tall), renders the artifact next to where it was streamed. Default size, click-to-expand.
- **Open in pane**: button on the inline preview opens the artifact in a full-height pane in BooChat's existing workspace splitter, alongside the file viewer and BooTerm. Pane is dismissible. Pane state persisted via `sessions.workspace_panes jsonb` (the v1.12.1 schema already supports this).
- **Download**: button writes the artifact to `/opt/<project>/.boocode/artifacts/<slug>-<unix-timestamp>.html` (path-guarded same as native write tools), surfaces an OS download link via the existing file-serving path. Filename slug derived from artifact title.
1. **Security stance — locked 2026-05-22:** the iframe is sandboxed with `sandbox="allow-scripts allow-clipboard-write allow-downloads"`. **Crucially, omit `allow-same-origin`** so the artifact has its own opaque origin and 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 header on the iframe response: `default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'`. The `connect-src 'none'` is the key clause — artifacts can't `fetch()`, can't open WebSockets, can't ping a tracking pixel, can't exfiltrate. JS runs (so Thariq's interactive knobs/sliders/copy-as-prompt buttons work) but nothing else network-touching does. **None of Thariq's blog examples need the relaxed permissions** — they're all client-side.
1. **Frontend rendering** (`apps/web/src/components/HtmlArtifactPart.tsx`):
- Inline preview: `<iframe srcdoc={html_content} sandbox="allow-scripts allow-clipboard-write allow-downloads" className="..." />` with the strict-sandbox attributes above.
- "Open in pane" button: dispatches workspace-pane action with `{type: 'html_artifact', message_part_id, html_content}`.
- "Download" button: POST to new endpoint `/api/chats/:id/artifacts/:part_id/download` which writes to disk (path-guarded) and returns the absolute path or pre-signed URL for the existing static-file serving route.
1. **No artifact persistence beyond the chat.** Artifacts live in `message_parts.payload->>'html_content'` with the chat. Downloads go to `/opt/<project>/.boocode/artifacts/` and are user-managed from there. No separate artifacts table.
1. **Token-budget guard.** Single artifact can be at most 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."
1. **No `web-artifacts-builder` skill vendor.** That skill (`anthropics/skills/web-artifacts-builder`) is built for Claude.ai's runtime with Vite + Parcel + tspaths + html-inline toolchain. BooChat has no shell execution surface. The pattern transplants; the toolchain doesn't. Treat the skill's "avoid AI slop" design principles (no excessive centered layouts, no purple gradients, no uniform rounded corners, no Inter font) as conventions inlined in the HTML-bias AGENTS.md rule. The init/bundle scripts are out of scope.
1. **Model-side prompting** (no code change, just AGENTS.md guidance):
- Add HTML-on-request rule to global `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')."
- Inline the `web-artifacts-builder` "avoid AI slop" design principles for when HTML is requested: no excessive centered layouts, no purple gradients, no uniform rounded corners, no Inter font, no generic AI aesthetics.
- Cite Thariq's blog post in the rule comment so future audit passes know where the design conventions came from.
1. **Detection at the BooChat backend.** In `apps/chat/services/inference/stream-phase.ts` post-processing: detect any assistant text part starting with `<!DOCTYPE html>` (case-insensitive, whitespace-trimmed) — or wrapped in a fenced ` ```html` block — and tag it as an HTML artifact. Emit a new part kind `html_artifact` into `message_parts` (CHECK constraint update). Payload: `{html_content, char_count, title}`. Title pulled from `<title>` tag or first `<h1>` if available. Detection is opportunistic — when the model produces HTML (because the user asked), the tag fires; otherwise the message stays plain-Markdown and no `html_artifact` part is written.
1. **Pane-only render surface.** Every assistant message in the chat stream gets an "Open in pane" affordance (icon button in the message footer, alongside the existing copy/regenerate controls). Clicking it opens the message as an artifact pane in BooChat's existing workspace splitter, alongside the file viewer and BooTerm. Pane is dismissible. Pane state persisted via `sessions.workspace_panes jsonb` (the v1.12.1 schema already supports this).
- **Markdown pane** — renders via the same Markdown component used inline in `MessageBubble` (so syntax highlighting, fenced code blocks, tables, etc. all work). Header carries **Copy** (writes raw Markdown source to clipboard via `navigator.clipboard.writeText`) and **Download** (`.md`) buttons.
- **HTML pane** — renders the artifact in a sandboxed iframe at full pane height. Header carries **Download** (`.html`) only. **No Copy button** — HTML source isn't useful clipboard content; if the user wants the source they can Download and inspect.
1. **Download path & filename slug.** Both formats write to `/opt/<project>/.boocode/artifacts/<slug>-<unix-timestamp>.<ext>` (path-guarded same as native write tools), and surface an OS download link via the existing file-serving path.
- Markdown slug: derived from the message's first heading (`# ...`) if present, else the first 6 words of the message body, lowercased + hyphenated.
- HTML slug: derived from the artifact's `<title>` tag if present, else first `<h1>`, else first 6 words of the inner text. Same lowercase-hyphen treatment.
1. **Security stance for HTML pane — locked 2026-05-22:** the iframe is sandboxed with `sandbox="allow-scripts allow-clipboard-write allow-downloads"`. **Crucially, omit `allow-same-origin`** so the artifact has its own opaque origin and 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 header on the iframe response: `default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'`. The `connect-src 'none'` is the key clause — artifacts can't `fetch()`, can't open WebSockets, can't ping a tracking pixel, can't exfiltrate. JS runs (so interactive knobs/sliders/copy-as-prompt buttons work) but nothing else network-touching does.
1. **Frontend components:**
- `apps/web/src/components/MarkdownArtifactPane.tsx` — pane shell + header (Copy + Download) + Markdown render reusing the existing component.
- `apps/web/src/components/HtmlArtifactPane.tsx` — pane shell + header (Download only) + `<iframe srcdoc={html_content} sandbox="allow-scripts allow-clipboard-write allow-downloads" />`.
- `MessageBubble.tsx` — add "Open in pane" affordance to every assistant message footer. Dispatches workspace-pane action `{type: 'markdown_artifact' | 'html_artifact', message_id, html_content?}`. When the message has an `html_artifact` part, the affordance opens as an HTML pane; otherwise it opens as a Markdown pane.
- Download button → POST to new endpoint `/api/chats/:id/messages/:msg_id/artifacts/download?fmt=md|html` which writes to disk (path-guarded) and returns the absolute path or pre-signed URL for the existing static-file serving route.
1. **No artifact persistence beyond the chat.** Artifacts live in `message_parts.payload->>'html_content'` (for HTML) or are derived on-demand from the assistant message's content (for Markdown). Downloads go to `/opt/<project>/.boocode/artifacts/` and are user-managed from there. No separate artifacts table.
1. **Token-budget guard.** Single HTML artifact can be at most 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.
1. **No `web-artifacts-builder` skill vendor.** That skill (`anthropics/skills/web-artifacts-builder`) is built for Claude.ai's runtime with Vite + Parcel + tspaths + html-inline toolchain. BooChat has no shell execution surface. The pattern transplants; the toolchain doesn't. Treat the skill's "avoid AI slop" design principles as conventions inlined in the HTML-on-request AGENTS.md rule. The init/bundle scripts are out of scope.
**Lift sources:**
- `claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html` (Thariq Shihipar, May 20 2026) — the pattern, the use-case taxonomy (specs/code-review/design/reports/custom editors), the design philosophy.
- `claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html` (Thariq Shihipar, May 20 2026) — design conventions and use-case taxonomy (specs/code-review/design/reports/custom editors). The "auto-bias for >100 lines" recommendation is deliberately NOT lifted.
- HTML iframe sandbox spec (web platform standard, no license issues).
- `anthropics/skills/web-artifacts-builder` — design-principle reference only ("avoid AI slop" rules). **Do not vendor the toolchain.**
**Dependencies:** v1.13 merged (`message_parts` table is where artifacts live). Independent of v1.14 (outer loop) and v1.14.x-mcp (MCP PoC). Can ship in any order relative to those.
**Dependencies:** v1.13 merged (`message_parts` table is where HTML artifacts live). Independent of v1.14 (outer loop) and v1.14.x-mcp (MCP PoC). Can ship in any order relative to those.
**Estimated:** ~400 LoC. Roughly half backend (detection + part-kind extension + download endpoint + path-guard integration), half frontend (HtmlArtifactPart component + pane integration + download button wiring).
**Estimated:** ~400 LoC. Roughly half backend (HTML detection + part-kind extension + download endpoint + path-guard integration + Markdown slug derivation) and half frontend (two artifact-pane components + MessageBubble affordance + pane integration + download wiring).
**Schema addition:**
- `message_parts.kind` CHECK constraint adds `'html_artifact'` to the allowed set.
**Skip-condition:** none — independent batch, ships clean any time after v1.13. Highest user-visible payoff of any v1.13.x/v1.14.x batch (transforms what the model can produce, not just how the backend handles it).
**Skip-condition:** none — independent batch, ships clean any time after v1.13. Pane-based artifact view is a structural UX improvement (full-height read surface for long replies, durable download path) on top of the HTML-on-request rendering capability.
**Shipped as `v1.13.19-html-artifact-panes` on 2026-05-23.** Two scope-revisions during impl: (a) the HTML-on-request rule landed in `BOOCHAT.md` (always-true rules layer), not `data/AGENTS.md` (per-agent registry) — per BOOCHAT.md's own convention block. (b) Pane state stayed reference-only — `{chat_id, message_id, title}` — content fetched on mount via the existing chat-messages endpoint (Markdown) and a new `GET /api/chats/:id/messages/:msg_id/html_artifact` (HTML). Storing content in pane state would have ridden 1MB blobs through the `session_workspace_updated` WS frame and bloated the jsonb column on multi-pane sessions. Defense-in-depth additions beyond the original proposal: `X-Content-Type-Options: nosniff` + `Content-Security-Policy: sandbox` on the GET serve route, and `assertArtifactsDirSafe` realpaths the artifacts dir after `mkdir` to close a symlink-escape gap that would otherwise let a planted symlink under `.boocode/artifacts/` route writes outside the project root. Smoke not run pre-tag; first deploy is the smoke.
-----

View 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).

View 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 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