/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * /mcp/playground — interactive surface for the @ifc-lite/mcp tool catalogue. * * Layout: a 3-column resizable workspace * • Left — sample picker + parsed model summary (entity counts, types, * materials, units), file drop zone for ad-hoc uploads. * • Centre — agent transcript with inline tool-call rendering. * • Right (collapsible later) — selected tool spotlight from the catalogue. * * The model parses entirely in-browser via @ifc-lite/parser; the agent runs * on Anthropic via BYOK; tool calls dispatch through `playground-dispatcher` * against the local `BimContext`. No IFC ever leaves the browser. */ import { type CSSProperties, type ReactNode, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { ArrowLeft, Box, ChevronDown, ChevronRight, Download, Loader2, Upload, FileText, AlertTriangle, Trash2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useDocumentMeta, useFonts } from './use-mcp-page'; import { parsePlaygroundModel, supportedToolNames, type DispatchContext, type LoadedPlaygroundModel, } from './playground-dispatcher'; import { PlaygroundChat } from './PlaygroundChat'; import { PlaygroundViewer, type ViewerController } from './PlaygroundViewer'; import { playgroundFiles, usePlaygroundFiles, formatBytes as formatFileBytes } from './playground-files'; const NIGHT = '#0a0a0c'; const PANEL = '#101014'; const RULE = 'rgba(237, 228, 211, 0.08)'; const PAPER = '#ede4d3'; const PAPER_DIM = 'rgba(237, 228, 211, 0.55)'; const ACCENT = '#d6ff3f'; const stage: CSSProperties = { background: NIGHT, color: PAPER, fontFamily: '"Bricolage Grotesque", system-ui, sans-serif', }; const display: CSSProperties = { fontFamily: '"Instrument Serif", serif', fontStyle: 'normal', }; const mono: CSSProperties = { fontFamily: '"JetBrains Mono", ui-monospace, monospace', }; interface SampleEntry { id: string; label: string; blurb: string; url: string; approxBytes: number; } const SAMPLES: SampleEntry[] = [ { id: 'hello-wall', label: 'Hello Wall', blurb: 'IFC5 minimal · 1 wall, 1 storey', url: '/samples/hello-wall.ifc', approxBytes: 78_000 }, { id: 'building-architecture', label: 'Building / Architecture', blurb: 'buildingSMART sample · 444 entities, IFC4', url: '/samples/building-architecture.ifc', approxBytes: 220_000 }, { id: 'infra-bridge', label: 'Infra Bridge', blurb: 'Infrastructure · IFC4.3 bridge sample', url: '/samples/infra-bridge.ifc', approxBytes: 1_800_000 }, ]; export function McpPlayground(): ReactNode { useFonts( 'https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Bricolage+Grotesque:opsz,wght@12..96,300;12..96,400;12..96,500;12..96,600;12..96,700&family=JetBrains+Mono:wght@400;500;600&display=swap', ); useDocumentMeta('@ifc-lite/mcp · playground', NIGHT); const [model, setModel] = useState(null); const [loadingId, setLoadingId] = useState(null); const [error, setError] = useState(null); const [viewerOpen, setViewerOpen] = useState(false); const viewerRef = useRef(null); // Stable context getter — keeps the chat panel from re-running its // dispatch closure every render while still letting the viewer ref // attach late (the viewer component isn't mounted until the user // expands the panel). const getDispatchContext = useCallback<() => DispatchContext>( () => ({ viewer: viewerRef.current ?? null, openViewerPanel: () => setViewerOpen(true), }), [], ); const loadFromUrl = useCallback(async (entry: SampleEntry) => { setLoadingId(entry.id); setError(null); try { const res = await fetch(entry.url); if (!res.ok) throw new Error(`Failed to fetch ${entry.label}: HTTP ${res.status}`); const buf = await res.arrayBuffer(); const m = await parsePlaygroundModel(buf, `${entry.id}.ifc`); setModel(m); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setLoadingId(null); } }, []); const loadFromFile = useCallback(async (file: File) => { setLoadingId('upload'); setError(null); try { const buf = await file.arrayBuffer(); const m = await parsePlaygroundModel(buf, file.name); setModel(m); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setLoadingId(null); } }, []); return (
setModel(null)} hasModel={!!model} />
{/* Sidebar */} {/* Right column: collapsible 3D viewer above the chat. The viewer component is unmounted while collapsed so we don’t hold a WebGL context for nothing. The dispatcher's `openViewerPanel()` flips `viewerOpen` so the agent can request the panel programmatically. */}
setViewerOpen((o) => !o)} controllerRef={viewerRef} />
); } // ── top bar ──────────────────────────────────────────────────────────────── function TopBar({ onClose, hasModel }: { onClose: () => void; hasModel: boolean }): ReactNode { return (
back to /mcp
viewer /mcp/playground {hasModel && ( )}
); } // ── samples ──────────────────────────────────────────────────────────────── function SampleList({ samples, loadingId, activeId, onPick, }: { samples: SampleEntry[]; loadingId: string | null; activeId: string | null; onPick: (s: SampleEntry) => void; }): ReactNode { return (
sample models
    {samples.map((s) => { const isActive = activeId === s.id; const isLoading = loadingId === s.id; return (
  • ); })}
); } function DropZone({ onFile, disabled, }: { onFile: (file: File) => void; disabled: boolean; }): ReactNode { const [hover, setHover] = useState(false); const inputRef = useRef(null); return ( ); } // ── model summary ───────────────────────────────────────────────────────── function ModelSummary({ model }: { model: LoadedPlaygroundModel }): ReactNode { const top = useMemo(() => { const counts: Array<{ type: string; count: number }> = []; for (const [type, ids] of model.store.entityIndex.byType) counts.push({ type, count: ids.length }); counts.sort((a, b) => b.count - a.count); return counts.slice(0, 8); }, [model]); return (
{model.name}
schema
{model.store.schemaVersion}
entities
{model.store.entityCount.toLocaleString()}
file
{formatBytes(model.fileSize)}
top entity types
    {top.map((row) => (
  • {row.type} {row.count}
  • ))}
); } // ── footer ───────────────────────────────────────────────────────────────── function FooterLinks(): ReactNode { return ( ); } // ── helpers ─────────────────────────────────────────────────────────────── function formatBytes(bytes: number): string { if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; if (bytes >= 1024) return (bytes / 1024).toFixed(0) + ' KB'; return bytes + ' B'; } function modelIdFor(model: LoadedPlaygroundModel): string { // The dispatcher derives ids from the filename; we encode SAMPLES with the // same prefix so we can spot the active sample in the picker. return model.id; } // ── downloads panel ─────────────────────────────────────────────────────── // // Tools that "write a file" (bcf_export, model_save, export_*) push their // artifact into `playgroundFiles` instead of triggering a browser download. // This panel renders one row per file with an explicit Download button — // the actual click only happens when the USER presses it, // never auto-triggered. function DownloadsPanel(): ReactNode { const files = usePlaygroundFiles(); if (files.length === 0) return null; return (
downloads · {files.length}
    {files.map((f) => (
  • {f.filename} {formatFileBytes(f.size)}
    {f.description && ( {f.description} )}
    from {f.source}
  • ))}
); } // ── inline viewer panel ─────────────────────────────────────────────────── function ViewerPanel({ model, open, onToggle, controllerRef, }: { model: LoadedPlaygroundModel | null; open: boolean; onToggle: () => void; controllerRef: React.MutableRefObject; }): ReactNode { return (
{open && (
)}
); }