diff --git a/app/api/sessions/[id]/artifacts/route.test.mjs b/app/api/sessions/[id]/artifacts/route.test.mjs
new file mode 100644
index 0000000000..db7999778f
--- /dev/null
+++ b/app/api/sessions/[id]/artifacts/route.test.mjs
@@ -0,0 +1,55 @@
+import assert from "node:assert/strict";
+import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import test from "node:test";
+import { createJiti } from "jiti";
+
+const jiti = createJiti(import.meta.url, { tsconfigPaths: true });
+const { GET } = await jiti.import("./route.ts");
+const { cacheSessionPath, invalidateSessionPathCache, invalidateSessionManagerCache } = await jiti.import("@/lib/session-reader.ts");
+const fence = (value) => `\`\`\`pi-html id="demo"\n\n\`\`\``;
+
+test("artifact index covers compacted/paged history and reads only the selected ancestor branch", async (t) => {
+ const dir = mkdtempSync(join(tmpdir(), 'pi-web-artifacts-'));
+ const file = join(dir, 'session.jsonl');
+ const id = 'artifact-route-fixture';
+ const timestamp = new Date().toISOString();
+ const assistant = (id, parentId, text) => ({ type: 'message', id, parentId, timestamp, message: { role: 'assistant', content: [{ type: 'text', text }], provider: 'test', model: 'test', timestamp: Date.now() } });
+ const entries = [{ type: 'session', version: 3, id, timestamp, cwd: dir }, assistant('first', null, fence('first'))];
+ for (let index = 0; index < 105; index++) entries.push({ type: 'message', id: 'filler-' + index, parentId: entries.at(-1).id, timestamp, message: { role: 'user', content: 'ordinary text' } });
+ entries.push(assistant('second', entries.at(-1).id, fence('second')));
+ entries.push({ type: 'compaction', id: 'compact', parentId: 'second', timestamp, summary: 'summary', firstKeptEntryId: 'second', tokensBefore: 10000 });
+ entries.push(assistant('sibling', 'first', fence('other branch')));
+ const original = entries.map((entry) => JSON.stringify(entry)).join('\n') + '\n';
+ writeFileSync(file, original);
+ cacheSessionPath(id, file);
+ t.after(() => { invalidateSessionPathCache(id); invalidateSessionManagerCache(file); rmSync(dir, { recursive: true, force: true }); });
+ const get = (query) => GET(new Request(`http://localhost/api/sessions/${id}/artifacts?${query}`), { params: Promise.resolve({ id }) });
+ const response = await get('leafId=compact');
+ assert.equal(response.status, 200);
+ assert.equal(response.headers.get('Cache-Control'), 'no-store');
+ const history = (await response.json()).versions;
+ assert.deepEqual(history.map((item) => item.entryId), ['first', 'second']);
+ assert.equal(history[0].code, undefined, 'Index returns metadata, not all historical HTML');
+ const old = await get('leafId=compact&key=first%3A0%3A0');
+ assert.equal((await old.json()).version.code, '');
+ assert.equal((await get('leafId=compact&key=sibling%3A0%3A0')).status, 404);
+ assert.equal((await get('leafId=missing')).status, 404);
+ assert.deepEqual((await (await get('leafId=null')).json()).versions, []);
+ assert.deepEqual((await (await get('')).json()).versions.map((item) => item.entryId), ['first', 'sibling']);
+ assert.equal(readFileSync(file, 'utf8'), original, 'Browsing never rewrites or appends artifact state');
+});
+
+test("artifact reads prefer the open wrapper's in-memory SessionManager", async (t) => {
+ const id = 'in-memory-artifact-fixture';
+ const previous = globalThis.__piSessions;
+ const version = { type: 'message', id: 'live-entry', message: { role: 'assistant', content: [{ type: 'text', text: fence('live') }] } };
+ globalThis.__piSessions = new Map([[id, { isAlive: () => true, inner: { sessionManager: {
+ getLeafId: () => version.id, getEntry: (key) => key === version.id ? version : undefined, getBranch: () => [version],
+ } } }]]);
+ t.after(() => { globalThis.__piSessions = previous; });
+ const response = await GET(new Request('http://localhost/api/sessions/live/artifacts'), { params: Promise.resolve({ id }) });
+ assert.equal(response.status, 200);
+ assert.equal((await response.json()).versions[0].key, 'live-entry:0:0');
+});
diff --git a/app/api/sessions/[id]/artifacts/route.ts b/app/api/sessions/[id]/artifacts/route.ts
new file mode 100644
index 0000000000..1dbecb0dc7
--- /dev/null
+++ b/app/api/sessions/[id]/artifacts/route.ts
@@ -0,0 +1,31 @@
+import { NextResponse } from "next/server";
+import { resolveSessionPath, openSessionManager } from "@/lib/session-reader";
+import { getRpcSession } from "@/lib/rpc-manager";
+import { branchArtifactVersions } from "@/lib/interactive-artifacts";
+
+export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ const query = new URL(req.url).searchParams;
+ try {
+ const rpc = getRpcSession(id);
+ const live = rpc?.isAlive() ? rpc : undefined;
+ const path = live ? null : await resolveSessionPath(id);
+ if (!live && !path) return NextResponse.json({ error: "Session not found" }, { status: 404 });
+ const sm = live?.inner.sessionManager ?? openSessionManager(path!);
+ const requestedLeaf = query.get("leafId");
+ const leaf = requestedLeaf === "null" ? null : requestedLeaf ?? sm.getLeafId();
+ if (leaf && !sm.getEntry(leaf)) return NextResponse.json({ error: "Branch not found" }, { status: 404 });
+ const versions = branchArtifactVersions(leaf ? sm.getBranch(leaf) : []);
+ const key = query.get("key");
+ if (key !== null) {
+ const version = versions.find((item) => item.key === key);
+ return version ? NextResponse.json({ version }, { headers: { "Cache-Control": "no-store" } })
+ : NextResponse.json({ error: "Artifact version not found on this branch" }, { status: 404 });
+ }
+ return NextResponse.json({ leafId: leaf, versions: versions.map(({ id, key, entryId, blockIndex, ordinal }) => ({ id, key, entryId, blockIndex, ordinal })) }, {
+ headers: { "Cache-Control": "no-store" },
+ });
+ } catch (error) {
+ return NextResponse.json({ error: String(error) }, { status: 500 });
+ }
+}
diff --git a/app/globals.css b/app/globals.css
index e717dc2928..5453580c34 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1114,6 +1114,212 @@ pre, code {
color: var(--text);
}
+.interactive-preview {
+ position: relative;
+ inset: auto;
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+ width: 100%;
+ max-width: 100%;
+ max-height: none;
+ min-width: 0;
+ margin: 6px 0;
+ padding: 0;
+ border: 1px solid var(--border);
+ border-radius: 7px;
+ overflow: hidden;
+ background: var(--bg);
+ color: var(--text);
+}
+.interactive-preview .markdown-code-header {
+ flex: none;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+.interactive-preview-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+}
+.interactive-preview .markdown-code-actions {
+ gap: 3px;
+ margin-left: auto;
+}
+.interactive-artifact-id {
+ flex: 1 1 auto;
+ min-width: 0;
+ max-width: 120px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font: 12px var(--font-mono);
+}
+.has-artifact-version .interactive-preview-label {
+ flex: 1 1 260px;
+}
+.interactive-preview-label .markdown-code-lang,
+.interactive-preview-label select {
+ flex: none;
+ white-space: nowrap;
+}
+.interactive-preview-label select {
+ max-width: 100px;
+ min-width: 0;
+ padding: 3px;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: var(--bg-panel);
+ color: var(--text);
+ font-size: 12px;
+}
+.interactive-artifact-history {
+ margin: 6px 0;
+}
+.interactive-artifact-history > summary {
+ cursor: pointer;
+ padding: 8px 0;
+ font: 12px/1.5 var(--font-mono);
+ color: var(--text-muted);
+ overflow-wrap: anywhere;
+}
+.interactive-artifact-notice,
+.interactive-artifact-status {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex: none;
+ padding: 6px 12px;
+ font-size: 12px;
+ color: var(--text-muted);
+ background: var(--bg-panel);
+}
+.interactive-artifact-loading {
+ border: 1px solid var(--border);
+ border-radius: 7px;
+ overflow: hidden;
+}
+.interactive-preview-action {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: none;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: 0;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--text-muted);
+ cursor: pointer;
+}
+.interactive-preview-action:hover:not(:disabled) {
+ color: var(--text);
+ background: var(--bg-hover);
+}
+.interactive-preview-action:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: -2px;
+}
+.interactive-preview-action:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+.interactive-preview-frame {
+ flex: none;
+ height: 320px;
+ background: var(--bg);
+}
+.interactive-preview-frame iframe {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border: 0;
+ color-scheme: light dark;
+}
+.interactive-preview-source {
+ min-height: 0;
+ max-height: min(480px, 60dvh);
+ overflow: auto;
+}
+.interactive-preview-source > .markdown-code-block {
+ margin: 0;
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
+}
+.interactive-preview [hidden] {
+ display: none;
+}
+.interactive-preview-error {
+ flex: none;
+ padding: 8px 12px;
+ font-size: 12px;
+ color: var(--text-muted);
+ background: var(--bg-panel);
+ border-bottom: 1px solid var(--border);
+}
+.interactive-preview-error-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+.interactive-preview-error summary {
+ cursor: pointer;
+ width: fit-content;
+ padding: 4px 0;
+}
+.interactive-preview-error pre {
+ max-height: 160px;
+ overflow: auto;
+ margin: 4px 0 0;
+ padding: 8px;
+ font: 12px/1.5 var(--font-mono);
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ color: var(--text);
+ background: var(--bg);
+}
+.interactive-preview.is-expanded {
+ position: fixed;
+ inset: 0;
+ width: calc(100% - 32px);
+ max-width: 1000px;
+ height: calc(100dvh - 32px);
+ max-height: none;
+ margin: auto;
+}
+.interactive-preview.is-expanded .interactive-preview-frame {
+ flex: 1;
+ height: auto;
+ min-height: 0;
+ max-height: none;
+}
+.interactive-preview.is-expanded .interactive-preview-source {
+ flex: 1;
+ max-height: none;
+}
+.interactive-preview::backdrop {
+ background: rgb(0 0 0 / 55%);
+}
+@media (pointer: coarse) {
+ .interactive-preview-action {
+ width: 32px;
+ height: 36px;
+ }
+}
+@media (max-width: 600px) {
+ .interactive-preview.is-expanded {
+ width: 100%;
+ height: 100dvh;
+ border-radius: 0;
+ padding-top: env(safe-area-inset-top);
+ padding-bottom: env(safe-area-inset-bottom);
+ }
+}
+
.mermaid-block {
padding: 12px;
overflow-x: auto;
diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx
index 84864096e8..42ba33230e 100644
--- a/components/ChatWindow.tsx
+++ b/components/ChatWindow.tsx
@@ -15,6 +15,7 @@ import { buildQuotedSelection } from "@/lib/quoted-selection";
import { dropMentionText, splitDroppedItems, uploadFiles, type DroppedItem } from "@/lib/file-upload-client";
import { MessageView } from "./MessageView";
import { MarkdownBody } from "./MarkdownBody";
+import { InteractiveArtifactsProvider, InteractiveArtifactEntry } from "./InteractiveArtifacts";
import { ChatInput, type ChatInputHandle } from "./ChatInput";
import { ChatMinimap, useMessageRefs } from "./ChatMinimap";
import { ExtensionStatusBar } from "./ExtensionStatusBar";
@@ -1079,6 +1080,7 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi
style={{ visibility: pendingScrollRestore ? "hidden" : undefined }}
>
+
{(() => {
let lastUserIdx = -1;
@@ -1128,6 +1130,7 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi
}
if (options.showTimestamp !== undefined) showTimestamp = options.showTimestamp;
const view = (
+
+
);
if (!isVisible || currentRefIdx === undefined) return view;
return (
@@ -1318,6 +1322,7 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi
+
{isMobile || pendingScrollRestore ? null : (
diff --git a/components/InteractiveArtifacts.tsx b/components/InteractiveArtifacts.tsx
new file mode 100644
index 0000000000..89e5c4ee6a
--- /dev/null
+++ b/components/InteractiveArtifacts.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
+import type { AgentMessage, AssistantContentBlock } from "@/lib/types";
+import { mergeArtifactVersions, messageArtifactVersions, type ArtifactVersion } from "@/lib/interactive-artifacts";
+import { useI18n } from "@/hooks/useI18n";
+import { InteractivePreview } from "./InteractivePreview";
+import { RefreshIcon } from "./SidebarIcons";
+
+const EntryContext = createContext(undefined);
+export const InteractiveArtifactEntry = EntryContext.Provider;
+
+const ArtifactsContext = createContext<{
+ groups: Map;
+ messages: Map;
+ url?: string;
+ error: boolean;
+ loading: boolean;
+ retry: () => void;
+}>({ groups: new Map(), messages: new Map(), error: false, loading: false, retry() {} });
+
+export function useInteractiveArtifactSource(block: AssistantContentBlock, fallbackIndex: number) {
+ const entry = useContext(EntryContext);
+ const context = useContext(ArtifactsContext);
+ const message = entry ? context.messages.get(entry) : undefined;
+ // Process/final-answer projection can change displayed block indices.
+ const originalIndex = message?.role === "assistant" ? message.content.indexOf(block) : -1;
+ return entry ? `${entry}:${originalIndex >= 0 ? originalIndex : fallbackIndex}` : undefined;
+}
+
+export function useOriginalAssistantBlocks() {
+ const entry = useContext(EntryContext);
+ const context = useContext(ArtifactsContext);
+ const message = entry ? context.messages.get(entry) : undefined;
+ return message?.role === "assistant" ? message.content : undefined;
+}
+
+export function InteractiveArtifactsProvider({ sessionId, leafId, messages, entryIds, children }: {
+ sessionId?: string; leafId: string | null; messages: AgentMessage[]; entryIds: string[]; children: ReactNode;
+}) {
+ const scope = `${sessionId ?? ""}:${leafId ?? "null"}`;
+ const local = useMemo(() => messages.flatMap((message, index) => message.role === "assistant"
+ ? messageArtifactVersions(entryIds[index] ?? `pending-${index}`, message.content) : []), [messages, entryIds]);
+ const hasArtifacts = local.length > 0;
+ const sourceMessages = useMemo(() => new Map(messages.map((message, index) => [entryIds[index] ?? `pending-${index}`, message])), [messages, entryIds]);
+ const [snapshot, setSnapshot] = useState<{ scope: string; versions: ArtifactVersion[]; error: boolean }>({ scope: "", versions: [], error: false });
+ const [retry, setRetry] = useState(0);
+ const url = sessionId ? `/api/sessions/${encodeURIComponent(sessionId)}/artifacts?leafId=${encodeURIComponent(leafId ?? "null")}` : undefined;
+ useEffect(() => {
+ if (!url || !hasArtifacts) return;
+ const controller = new AbortController();
+ fetch(url, { signal: controller.signal }).then(async (response) => {
+ if (!response.ok) throw new Error("Artifact history unavailable");
+ const data = await response.json() as { versions: ArtifactVersion[] };
+ if (!Array.isArray(data.versions)) throw new Error("Invalid artifact history");
+ if (!controller.signal.aborted) setSnapshot({ scope, versions: data.versions, error: false });
+ }).catch(() => {
+ if (!controller.signal.aborted) setSnapshot({ scope, versions: [], error: true });
+ });
+ return () => controller.abort();
+ }, [url, scope, hasArtifacts, retry]);
+ const groups = useMemo(() => mergeArtifactVersions(snapshot.scope === scope ? snapshot.versions : [], local), [snapshot, scope, local]);
+ const value = useMemo(() => ({ groups, messages: sourceMessages, url, error: snapshot.scope === scope && snapshot.error,
+ loading: Boolean(url && hasArtifacts && snapshot.scope !== scope), retry: () => setRetry((value) => value + 1),
+ }), [groups, sourceMessages, url, snapshot, scope, hasArtifacts]);
+ return {children};
+}
+
+export function InteractiveArtifact({ id, sourceKey, code, complete, isStreaming }: {
+ id?: string; sourceKey?: string; code: string; complete: boolean; isStreaming?: boolean;
+}) {
+ const { t } = useI18n();
+ const context = useContext(ArtifactsContext);
+ const versions = id ? context.groups.get(id) ?? [] : [];
+ const owner = versions.find((version) => version.key === sourceKey);
+ const latest = versions[versions.length - 1];
+ const [open, setOpen] = useState(false);
+ const [selection, setSelection] = useState<{ owner: string; key: string } | null>(null);
+ const [loaded, setLoaded] = useState<{ url: string; version: ArtifactVersion } | null>(null);
+ const [loadError, setLoadError] = useState(false);
+ const [loadRetry, setLoadRetry] = useState(0);
+ const selected = versions.find((version) => version.key === (selection && selection.owner === sourceKey ? selection.key : sourceKey)) ?? owner;
+ const requestUrl = selected && context.url ? `${context.url}&key=${encodeURIComponent(selected.key)}` : undefined;
+ const selectedCode = selected?.code ?? (selected?.key === owner?.key && owner ? code : loaded && loaded.url === requestUrl ? loaded.version.code : undefined);
+ useEffect(() => {
+ if (!requestUrl || selectedCode !== undefined || (!open && owner?.key !== latest?.key)) return;
+ const controller = new AbortController();
+ fetch(requestUrl, { signal: controller.signal }).then(async (response) => {
+ if (!response.ok) throw new Error("Artifact version unavailable");
+ const data = await response.json() as { version: ArtifactVersion };
+ if (data.version?.key !== selected?.key || typeof data.version.code !== "string") throw new Error("Invalid artifact version");
+ if (!controller.signal.aborted) { setLoaded({ url: requestUrl, version: data.version }); setLoadError(false); }
+ }).catch(() => { if (!controller.signal.aborted) setLoadError(true); });
+ return () => controller.abort();
+ }, [requestUrl, selectedCode, open, owner?.key, latest?.key, selected?.key, loadRetry]);
+
+ if (!id || isStreaming || !complete || !owner) return ;
+ const header = <>
+ {id}
+
+ >;
+ const preview = <>
+ {context.error &&
+ {t("chat.artifactHistoryError")}
+
+
}
+ {selectedCode !== undefined ? 1 ? t("chat.artifactVersionReset") : undefined} />
+ :
+
{header}
+
+ {loadError ? t("chat.artifactLoadError") : t("i18n.loading")}
+ {loadError && }
+
+
}
+ >;
+ if (owner.key === latest?.key) return preview;
+ return setOpen(event.currentTarget.open)}>
+ {id} · {t("chat.artifactVersionNumber", { version: versions.indexOf(owner) + 1 })}
+ {open && preview}
+ ;
+}
diff --git a/components/InteractivePreview.tsx b/components/InteractivePreview.tsx
new file mode 100644
index 0000000000..64dd8f915a
--- /dev/null
+++ b/components/InteractivePreview.tsx
@@ -0,0 +1,205 @@
+"use client";
+
+import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
+import { copyText } from "@/lib/clipboard";
+import { interactivePreviewHostDocument, interactivePreviewError, interactivePreviewHeight, type InteractivePreviewError } from "@/lib/interactive-preview";
+import { useI18n } from "@/hooks/useI18n";
+import { CloseIcon, EyeIcon, RefreshIcon, SpinnerIcon } from "./SidebarIcons";
+import { CodeBlock } from "./MermaidBlock";
+
+export function InteractivePreview({ code, isStreaming, complete, headerSlot, notice }: {
+ code: string;
+ isStreaming?: boolean;
+ complete: boolean;
+ headerSlot?: ReactNode;
+ notice?: string;
+}) {
+ const { t } = useI18n();
+ const [showSource, setShowSource] = useState(false);
+ const [expanded, setExpanded] = useState(false);
+ const [run, setRun] = useState(0);
+ const [errors, setErrors] = useState([]);
+ const [copied, setCopied] = useState<"source" | "errors" | null>(null);
+ const [frameHeight, setFrameHeight] = useState(320);
+ const dialogRef = useRef(null);
+ const iframeRef = useRef(null);
+ const expandRef = useRef(null);
+ const copyTimer = useRef | undefined>(undefined);
+ const ready = complete && !isStreaming;
+ const sourceVisible = showSource;
+ const documentSource = useMemo(() => interactivePreviewHostDocument(code), [code]);
+
+ useEffect(() => {
+ const dialog = dialogRef.current;
+ if (!dialog || !expanded) return;
+ const expandButton = expandRef.current;
+ const previousOverflow = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ dialog.close();
+ dialog.showModal();
+ expandButton?.focus({ preventScroll: true });
+ return () => {
+ document.body.style.overflow = previousOverflow;
+ dialog.close();
+ if (dialog.isConnected) dialog.show();
+ expandButton?.focus({ preventScroll: true });
+ };
+ }, [expanded]);
+
+ // Subscribe during commit so initial iframe failures cannot outrun the listener.
+ useLayoutEffect(() => {
+ const onMessage = (event: MessageEvent) => {
+ if (!iframeRef.current || event.source !== iframeRef.current.contentWindow) return;
+ if (event.data?.type === "pi-html:error") {
+ const error = interactivePreviewError(event.data);
+ if (error) setErrors((previous) => previous.length >= 5 || previous.some((item) =>
+ item.kind === error.kind && item.message === error.message && item.line === error.line && item.column === error.column)
+ ? previous : [...previous, error]);
+ }
+ if (event.data?.type === "pi-html:escape") setExpanded(false);
+ if (event.data?.type === "pi-html:resize" && !expanded) {
+ const height = interactivePreviewHeight(event.data.height);
+ if (height !== null) setFrameHeight(height);
+ }
+ };
+ window.addEventListener("message", onMessage);
+ return () => window.removeEventListener("message", onMessage);
+ }, [expanded]);
+
+ useEffect(() => {
+ if (!expanded && !showSource) {
+ iframeRef.current?.contentWindow?.postMessage({ type: "pi-html:measure" }, "*");
+ }
+ }, [expanded, showSource]);
+
+ useEffect(() => () => clearTimeout(copyTimer.current), []);
+
+ const download = () => {
+ const url = URL.createObjectURL(new Blob([code], { type: "text/html;charset=utf-8" }));
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = "pi-interactive.html";
+ link.click();
+ setTimeout(() => URL.revokeObjectURL(url), 0);
+ };
+
+ const errorText = errors.map((error) => {
+ const kind = t(error.kind === "promise" ? "chat.previewPromiseError" : "chat.previewRuntimeError");
+ const location = error.line === undefined ? "" : `\n${t("chat.previewErrorLocation", {
+ line: error.line, column: error.column ?? "?",
+ })}`;
+ return `${kind}: ${error.message || t("chat.previewUnknownError")}${location}`;
+ }).join("\n\n");
+
+ const copy = async (value: string, target: "source" | "errors") => {
+ try {
+ await copyText(value);
+ setCopied(target);
+ clearTimeout(copyTimer.current);
+ copyTimer.current = setTimeout(() => setCopied(null), 1500);
+ } catch {
+ setCopied(null);
+ }
+ };
+
+ return (
+
+ );
+}
+
+function PreviewIcon({ kind }: { kind: "code" | "copy" | "check" | "download" | "expand" }) {
+ const paths = {
+ code: "m8 7-5 5 5 5m8-10 5 5-5 5m-3-14-2 18",
+ copy: "M9 9h12v12H9zM15 5V3H3v12h2",
+ check: "m5 12 4 4L19 6",
+ download: "M12 3v12m-5-5 5 5 5-5M5 17v4h14v-4",
+ expand: "M8 3H3v5m13-5h5v5M3 16v5h5m13-5v5h-5",
+ };
+ return ;
+}
diff --git a/components/MarkdownBody.test.mjs b/components/MarkdownBody.test.mjs
index d325ad8c8f..43bbe3e4ea 100644
--- a/components/MarkdownBody.test.mjs
+++ b/components/MarkdownBody.test.mjs
@@ -27,6 +27,37 @@ function renderMarkdown(markdown, props = {}) {
);
}
+test("only assistant-enabled, explicitly closed pi-html fences create sandboxed previews", () => {
+ const document = '';
+ const markdown = `\`\`\`pi-html\n${document}\n\`\`\``;
+ const html = renderMarkdown(markdown, { allowInteractive: true });
+ assert.match(html, /