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