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