v1.13.19-html-artifact-panes: pane-based artifact viewer with on-request HTML

Every assistant message gets an "Open in pane" affordance that opens the
message in the workspace splitter — Markdown pane (Copy + Download .md) by
default; HTML pane (Download .html only) when the model emits a self-contained
<!DOCTYPE html> or fenced ```html artifact. BOOCHAT.md rule keeps Markdown
default at every length; HTML opt-in on explicit user request.

Backend: services/artifacts.ts (slug derivation + write helpers with
symlink-escape guard via realpath-after-mkdir), routes/artifacts.ts (POST
download + GET stream with nosniff + CSP sandbox defense-in-depth), HTML
detection in finalizeCompletion writing a new message_parts.kind='html_artifact'
row (schema CHECK extended via v1.13.13 pattern), graceful 1MB cap via the
pure decideHtmlArtifactWrite helper. PartKind union extended.

Frontend: MarkdownRenderer.tsx extracted from MessageBubble's inline
MarkdownBody for reuse; MarkdownArtifactPane.tsx + HtmlArtifactPane.tsx with
loading/error states; pane state is reference-only ({chat_id, message_id,
title}) — content fetched on mount to keep workspace_panes jsonb small and
avoid 1MB blobs riding session_workspace_updated frames. iframe sandbox
locked to allow-scripts allow-clipboard-write allow-downloads with no
allow-same-origin, srcDoc not src. openInPane discriminates 404 (expected
fallback) from real errors (toast + bail). PanelRightOpen icon button with
mobile 44px tap-target.

31 new server unit tests including a real-symlink filesystem case; 332/332
server tests passing, tsc clean both sides, pnpm -C apps/web build green.
Smoke deferred to first deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 12:43:13 +00:00
parent 1a889dcde3
commit ad45b28250
24 changed files with 1864 additions and 195 deletions

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>
);
}