/* 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/. */ /** * ExecutableCodeBlock — renders a code block from an LLM response * with a "Run" button that executes it in the QuickJS sandbox. * Results (logs, return value, errors) appear in a console-like panel. * Failed executions show a "Fix this" button that feeds the error * back to the LLM for automatic repair. * * Auto-execute: when the chat sets status to 'running' (via auto-execute toggle), * a useEffect triggers actual sandbox execution automatically. */ import { memo, useCallback, useState, useEffect, useRef } from 'react'; import { Play, Copy, CheckCircle2, AlertCircle, Loader2, FileCode2, RefreshCw, Terminal, ChevronDown, ChevronRight, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; import { useSandbox } from '@/hooks/useSandbox'; import { useViewerStore } from '@/store'; import type { CodeBlock, CodeExecResult } from '@/lib/llm/types'; interface ExecutableCodeBlockProps { block: CodeBlock; messageId: string; result?: CodeExecResult; /** Callback to trigger a "fix this" error feedback loop */ onFixError?: (code: string, error: string) => void; } /** Format a log arg for display */ function formatArg(a: unknown): string { if (typeof a === 'object' && a !== null) { try { return JSON.stringify(a, null, 2); } catch { return String(a); } } return String(a); } /** Level prefix for console lines */ function levelPrefix(level: string): string { switch (level) { case 'error': return '✕'; case 'warn': return '⚠'; case 'info': return 'ℹ'; default: return '›'; } } function captureCompressedCanvasImage(canvas: HTMLCanvasElement): string { const maxSide = 1400; const srcW = canvas.width || canvas.clientWidth || 0; const srcH = canvas.height || canvas.clientHeight || 0; if (srcW <= 0 || srcH <= 0) { return canvas.toDataURL('image/jpeg', 0.72); } const scale = Math.min(1, maxSide / Math.max(srcW, srcH)); const outW = Math.max(1, Math.round(srcW * scale)); const outH = Math.max(1, Math.round(srcH * scale)); const out = document.createElement('canvas'); out.width = outW; out.height = outH; const ctx = out.getContext('2d'); if (!ctx) { return canvas.toDataURL('image/jpeg', 0.72); } ctx.drawImage(canvas, 0, 0, outW, outH); return out.toDataURL('image/jpeg', 0.72); } export const ExecutableCodeBlock = memo(function ExecutableCodeBlock({ block, messageId, result, onFixError, }: ExecutableCodeBlockProps) { const { execute } = useSandbox(); const setCodeExecResult = useViewerStore((s) => s.setCodeExecResult); const setScriptError = useViewerStore((s) => s.setScriptError); const [copied, setCopied] = useState(false); const [consoleOpen, setConsoleOpen] = useState(true); const consoleEndRef = useRef(null); const autoExecTriggered = useRef(false); const copiedResetTimerRef = useRef(null); const handleRun = useCallback(async () => { setCodeExecResult(messageId, block.index, { status: 'running' }); try { const scriptResult = await execute(block.code); if (scriptResult) { setCodeExecResult(messageId, block.index, { status: 'success', logs: scriptResult.logs, value: scriptResult.value, durationMs: scriptResult.durationMs, }); // Auto-capture viewport screenshot if script likely created/modified geometry if (block.code.includes('loadIfc') || block.code.includes('bim.create') || block.code.includes('colorize')) { // Small delay to let the renderer finish presenting the frame setTimeout(() => { try { const canvas = document.querySelector('canvas'); if (canvas) { const dataUrl = captureCompressedCanvasImage(canvas as HTMLCanvasElement); useViewerStore.getState().setChatViewportScreenshot(dataUrl); } } catch { /* screenshot capture failed — non-critical */ } }, 500); } } else { // useSandbox sets scriptLastError synchronously before returning null — // read it immediately after the await to get the actual error message. const { scriptLastError, scriptLastResult } = useViewerStore.getState(); setCodeExecResult(messageId, block.index, { status: 'error', error: scriptLastError ?? 'Script execution failed', logs: scriptLastResult?.logs, durationMs: scriptLastResult?.durationMs, }); } } catch (err) { setCodeExecResult(messageId, block.index, { status: 'error', error: err instanceof Error ? err.message : String(err), }); } }, [execute, block.code, block.index, messageId, setCodeExecResult]); // Auto-execute: when the chat auto-execute toggle triggers a 'running' status // before this component has executed, trigger actual execution useEffect(() => { if (result?.status === 'running' && !autoExecTriggered.current) { autoExecTriggered.current = true; void handleRun(); } // Reset the flag when result goes back to idle / new result if (result?.status !== 'running') { autoExecTriggered.current = false; } }, [result?.status, handleRun]); useEffect(() => () => { if (copiedResetTimerRef.current !== null) { window.clearTimeout(copiedResetTimerRef.current); } }, []); // Auto-scroll console to bottom when new logs appear useEffect(() => { consoleEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, [result?.logs?.length, result?.status]); const handleCopy = useCallback(async () => { try { await navigator.clipboard.writeText(block.code); setCopied(true); if (copiedResetTimerRef.current !== null) { window.clearTimeout(copiedResetTimerRef.current); } copiedResetTimerRef.current = window.setTimeout(() => { setCopied(false); copiedResetTimerRef.current = null; }, 2000); } catch (error) { setScriptError( error instanceof Error ? error.message : 'Could not copy code block to clipboard.', [], ); } }, [block.code, setScriptError]); const handleApplyToEditor = useCallback(() => { const state = useViewerStore.getState(); const applyResult = state.applyScriptEditOps([{ opId: crypto.randomUUID(), type: 'replaceSelection', baseRevision: state.scriptEditorRevision, text: block.code, }], { intent: 'create', }); if (!applyResult.ok) { setScriptError(applyResult.error ?? 'Could not apply code block to the current selection.', applyResult.diagnostic ? [applyResult.diagnostic] : []); return; } setScriptError(null); state.setScriptPanelVisible(true); }, [block.code, setScriptError]); const handleReplaceAllInEditor = useCallback(() => { const state = useViewerStore.getState(); const applyResult = state.replaceScriptContentFallback(block.code, { intent: 'explicit_rewrite', source: 'manual_replace_all', }); if (!applyResult.ok) { setScriptError(applyResult.error ?? 'Could not replace the script with this code block.', applyResult.diagnostic ? [applyResult.diagnostic] : []); return; } setScriptError(null); state.setScriptPanelVisible(true); }, [block.code, setScriptError]); const handleFixError = useCallback(() => { if (result?.status === 'error' && result.error && onFixError) { onFixError(block.code, result.error); } }, [block.code, result, onFixError]); const isRunning = result?.status === 'running'; const hasOutput = result && ( result.status !== 'running' || (result.logs && result.logs.length > 0) ); const hasLogs = result?.logs && result.logs.length > 0; return (
{/* Code header with action buttons */}
{block.language || 'js'}
Copy code Apply to selection Replace entire script Execute in sandbox
{/* Code content */}
        {block.code}
      
{/* Console output panel */} {(isRunning || hasOutput) && (
{/* Console header */} {/* Console body */} {consoleOpen && (
{/* Running indicator */} {isRunning && (!hasLogs) && (
Executing script...
)} {/* Log entries */} {hasLogs && result.logs!.map((log, i) => (
{levelPrefix(log.level)} {log.args.map(formatArg).join(' ')}
))} {/* Error message */} {result?.status === 'error' && result.error && (
{result.error}
)} {/* Return value */} {result?.status === 'success' && result.value !== undefined && result.value !== null && (
{typeof result.value === 'object' ? JSON.stringify(result.value, null, 2) : String(result.value)}
)} {/* Success footer */} {result?.status === 'success' && (
Done{result.durationMs !== undefined ? ` in ${result.durationMs}ms` : ''}
)}
)} {/* Error action buttons */} {result?.status === 'error' && (
{onFixError && ( )}
)}
)}
); });