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:
148
apps/web/src/components/MarkdownRenderer.tsx
Normal file
148
apps/web/src/components/MarkdownRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user