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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user