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>
149 lines
5.1 KiB
TypeScript
149 lines
5.1 KiB
TypeScript
// 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>
|
|
);
|
|
}
|