// v1.14.x-html-artifact-panes: dedicated full-height Markdown viewer used // when a user clicks "Open in pane" on an assistant message that has NO // html_artifact part. Header carries Copy (raw source) + Download (server- // materialised .md under /.boocode/artifacts/) + close. // // Pane state is a reference only (chat_id + message_id + title); the markdown // body is fetched on mount from GET /api/chats/:chat_id/messages by locating // the matching message_id. This keeps sessions.workspace_panes jsonb small // and the assistant message row remains the single source of truth. import { useEffect, useState } from 'react'; import { Check, Copy, Download, X } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api/client'; import type { MarkdownArtifactState } from '@/api/types'; import { MarkdownRenderer } from './MarkdownRenderer'; interface Props { chatId: string; state: MarkdownArtifactState; onClose: () => void; } export function MarkdownArtifactPane({ chatId, state, onClose }: Props) { const [justCopied, setJustCopied] = useState(false); const [downloading, setDownloading] = useState(false); const [content, setContent] = useState(null); const [loadError, setLoadError] = useState(null); useEffect(() => { let cancelled = false; setContent(null); setLoadError(null); void (async () => { try { // No single-message GET endpoint exists; the chat-messages list is // already cached server-side and the lookup is O(n) over a small // window. Cheaper than adding a new route for one call site. const messages = await api.chats.messages(chatId); if (cancelled) return; const msg = messages.find((m) => m.id === state.message_id); if (!msg) { setLoadError('Message not found'); return; } setContent(msg.content ?? ''); } catch (err) { if (cancelled) return; setLoadError(err instanceof Error ? err.message : 'failed to load message'); } })(); return () => { cancelled = true; }; }, [chatId, state.message_id]); async function copy() { if (content === null) return; try { await navigator.clipboard.writeText(content); setJustCopied(true); setTimeout(() => setJustCopied(false), 1200); } catch (err) { toast.error(err instanceof Error ? err.message : 'copy failed'); } } async function download() { if (downloading) return; setDownloading(true); try { const { url, path } = await api.messages.downloadArtifact( chatId, state.message_id, 'md', ); // Trigger browser download from the returned URL. The endpoint stamps // Content-Disposition: attachment so the click lands as a save. const a = document.createElement('a'); a.href = url; a.rel = 'noopener'; a.click(); toast.success(`Saved to ${path}`); } catch (err) { toast.error(err instanceof Error ? err.message : 'download failed'); } finally { setDownloading(false); } } return (
{state.title || 'Markdown artifact'}
{loadError ? (
Failed to load: {loadError}
) : content === null ? (
Loading…
) : ( )}
); }