/* 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/. */ /** * ScriptPanel — Code editor + output console + optional AI chat side panel. * * Uses CodeMirror 6 for the code editor with bim.* autocomplete. * Connects to the QuickJS sandbox via useSandbox() and displays results * in a log console. AI chat is integrated as a collapsible side panel. */ import { useCallback, useEffect, useMemo, useRef, useState, memo } from 'react'; import { Play, Save, Plus, Trash2, X, ChevronDown, FileCode2, RotateCcw, AlertCircle, CheckCircle2, Info, AlertTriangle, Bot, PanelRightClose, PanelRightOpen, Undo2, Redo2, Wrench, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Dialog, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription, } from '@/components/ui/dialog'; import { ScrollArea } from '@/components/ui/scroll-area'; import { cn, formatDuration } from '@/lib/utils'; import { useViewerStore } from '@/store'; import { posthog } from '@/lib/analytics'; import { useSandbox } from '@/hooks/useSandbox'; import { SCRIPT_TEMPLATES } from '@/lib/scripts/templates'; import { CodeEditor } from './CodeEditor'; import { ChatPanel } from './ChatPanel'; import { PromoteToolDialog } from '@/components/extensions/PromoteToolDialog'; import { useOptionalExtensionHost } from '@/sdk/ExtensionHostProvider'; import type { LogEntry } from '@/store/slices/scriptSlice'; interface ScriptPanelProps { onClose?: () => void; } /** Consolidated script state selector — single subscription instead of 14 */ function useScriptState() { const editorContent = useViewerStore((s) => s.scriptEditorContent); const setEditorContent = useViewerStore((s) => s.setScriptEditorContent); const executionState = useViewerStore((s) => s.scriptExecutionState); const lastResult = useViewerStore((s) => s.scriptLastResult); const lastError = useViewerStore((s) => s.scriptLastError); const savedScripts = useViewerStore((s) => s.savedScripts); const activeScriptId = useViewerStore((s) => s.activeScriptId); const editorDirty = useViewerStore((s) => s.scriptEditorDirty); const createScript = useViewerStore((s) => s.createScript); const saveActiveScript = useViewerStore((s) => s.saveActiveScript); const deleteScript = useViewerStore((s) => s.deleteScript); const setActiveScriptId = useViewerStore((s) => s.setActiveScriptId); const deleteConfirmId = useViewerStore((s) => s.scriptDeleteConfirmId); const setDeleteConfirmId = useViewerStore((s) => s.setScriptDeleteConfirmId); const setScriptCursorContext = useViewerStore((s) => s.setScriptCursorContext); const registerScriptEditorApplyAdapter = useViewerStore((s) => s.registerScriptEditorApplyAdapter); const scriptCanUndo = useViewerStore((s) => s.scriptCanUndo); const scriptCanRedo = useViewerStore((s) => s.scriptCanRedo); const setScriptHistoryState = useViewerStore((s) => s.setScriptHistoryState); const undoScriptEditor = useViewerStore((s) => s.undoScriptEditor); const redoScriptEditor = useViewerStore((s) => s.redoScriptEditor); const queueChatRepairRequest = useViewerStore((s) => s.queueChatRepairRequest); const chatToolReady = useViewerStore((s) => s.chatToolReady); const setChatToolReady = useViewerStore((s) => s.setChatToolReady); return { editorContent, setEditorContent, executionState, lastResult, lastError, savedScripts, activeScriptId, editorDirty, createScript, saveActiveScript, deleteScript, setActiveScriptId, deleteConfirmId, setDeleteConfirmId, setScriptCursorContext, registerScriptEditorApplyAdapter, scriptCanUndo, scriptCanRedo, setScriptHistoryState, undoScriptEditor, redoScriptEditor, queueChatRepairRequest, chatToolReady, setChatToolReady, }; } export function ScriptPanel({ onClose }: ScriptPanelProps) { const { editorContent, setEditorContent, executionState, lastResult, lastError, savedScripts, activeScriptId, editorDirty, createScript, saveActiveScript, deleteScript, setActiveScriptId, deleteConfirmId, setDeleteConfirmId, setScriptCursorContext, registerScriptEditorApplyAdapter, scriptCanUndo, scriptCanRedo, setScriptHistoryState, undoScriptEditor, redoScriptEditor, queueChatRepairRequest, chatToolReady, setChatToolReady, } = useScriptState(); const { execute, reset } = useSandbox(); const extensionHost = useOptionalExtensionHost(); const [outputCollapsed, setOutputCollapsed] = useState(false); const chatPanelVisible = useViewerStore((s) => s.chatPanelVisible); const setChatPanelVisible = useViewerStore((s) => s.setChatPanelVisible); // Chat panel width (px) — resizable via drag handle const [chatWidth, setChatWidth] = useState(380); const chatDragRef = useRef<{ startX: number; startWidth: number } | null>(null); const cleanupChatDragRef = useRef<(() => void) | null>(null); // Open chat by default when script panel mounts useEffect(() => { try { if (localStorage.getItem('ifc-lite-chat-panel-visible') === null) { setChatPanelVisible(true); } } catch { setChatPanelVisible(true); } return () => { cleanupChatDragRef.current?.(); }; }, [setChatPanelVisible]); const handleChatResizeStart = useCallback((e: React.MouseEvent) => { e.preventDefault(); chatDragRef.current = { startX: e.clientX, startWidth: chatWidth }; const onMouseMove = (moveEvent: MouseEvent) => { if (!chatDragRef.current) return; const delta = chatDragRef.current.startX - moveEvent.clientX; const newWidth = Math.min(700, Math.max(240, chatDragRef.current.startWidth + delta)); setChatWidth(newWidth); }; const cleanup = () => { chatDragRef.current = null; document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; cleanupChatDragRef.current = null; }; const onMouseUp = () => { cleanup(); }; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; cleanupChatDragRef.current = cleanup; }, [chatWidth]); const activeScript = useMemo( () => savedScripts.find((s) => s.id === activeScriptId), [savedScripts, activeScriptId], ); const deleteConfirmScript = useMemo( () => (deleteConfirmId ? savedScripts.find((s) => s.id === deleteConfirmId) : null), [savedScripts, deleteConfirmId], ); const handleRun = useCallback(async () => { if (executionState === 'running') return; const startedAt = performance.now(); await execute(editorContent); const durationMs = Math.round(performance.now() - startedAt); extensionHost?.emitAction('script.execute', { templateId: activeScriptId ?? undefined, durationMs, }); posthog.capture('script_run', { from_template: activeScriptId != null, template_id: activeScriptId ?? undefined, duration_ms: durationMs, success: useViewerStore.getState().scriptLastError == null, }); }, [execute, editorContent, executionState, extensionHost, activeScriptId]); const handleSave = useCallback(() => { if (activeScriptId) { saveActiveScript(); } else { createScript('Untitled Script'); } }, [activeScriptId, saveActiveScript, createScript]); const handleNew = useCallback((name: string, code?: string) => { createScript(name, code); }, [createScript]); const [promoteOpen, setPromoteOpen] = useState(false); const canPromote = !!extensionHost && editorContent.trim().length > 0; const handleDeleteConfirm = useCallback(() => { if (deleteConfirmId) { deleteScript(deleteConfirmId); } }, [deleteConfirmId, deleteScript]); const handleFixWithLlm = useCallback(() => { if (!lastError) return; setChatPanelVisible(true); const state = useViewerStore.getState(); queueChatRepairRequest({ error: lastError, diagnostics: state.scriptLastDiagnostics, reason: lastError.startsWith('Preflight validation failed:') ? 'preflight' : 'runtime', }); }, [lastError, queueChatRepairRequest, setChatPanelVisible]); const toggleChat = useCallback(() => { setChatPanelVisible(!chatPanelVisible); }, [chatPanelVisible, setChatPanelVisible]); return (
{/* Left side: Script editor + output */}
{/* Header */}
{activeScript ? activeScript.name : 'Script Editor'} {editorDirty && *}
{/* Script selector dropdown */} {savedScripts.length > 0 && ( {savedScripts.map((s) => ( setActiveScriptId(s.id)} className={cn(s.id === activeScriptId && 'bg-accent')} > {s.name} ))} {activeScriptId && ( setDeleteConfirmId(activeScriptId)} className="text-destructive" > Delete )} )} {/* AI Chat toggle */} {chatPanelVisible ? 'Hide AI Chat' : 'Show AI Chat'} {onClose && ( )}
{/* Post-authoring "install as tool" banner — surfaces right where the AI-written code lands so the user never has to hunt for the Promote button. Highlighted (accent fill + ring) so the install step reads as the obvious next move, not a faint afterthought. */} {chatToolReady?.kind === 'script' && (
This script is ready
Install it as a one-click button in your toolbar.
)} {/* Toolbar */}
Run script (Ctrl+Enter) Save (Ctrl+S) {/* Save-as-tool — the explicit, always-visible bridge from a one-shot script to a persistent toolbar button. A labelled outline button (not a buried icon) so the "keep this" step is discoverable without nagging. */} Turn this script into a permanent one-click button in your toolbar Undo (Ctrl+Z) Redo (Ctrl+Shift+Z) {/* New script dropdown with templates */} New script handleNew('Untitled Script')}> Blank Script {SCRIPT_TEMPLATES.map((t) => ( handleNew(t.name, t.code)}> {t.name} ))} Reset sandbox {/* Status indicator */}
{executionState === 'running' && ( Running... )} {executionState === 'success' && lastResult && ( {formatDuration(lastResult.durationMs)} )} {executionState === 'error' && ( Error )}
{/* Code Editor */}
{/* Output Console */}
{/* Output header */} {!outputCollapsed && (
{/* Error message */} {lastError && (
{lastError} {/* Sandbox-globals hint — when the error names a browser-context API the sandbox doesn't expose, surface a one-line cue so the user understands why the rewrite is needed before clicking Fix. */} {/(document|window|navigator|location|fetch|XMLHttpRequest|localStorage|indexedDB|setTimeout|setInterval) is not defined/.test(lastError) && (
Scripts run in a QuickJS sandbox — no DOM, no fetch, no browser globals. Use bim.* APIs for viewer / data / export side-effects.
)}
)} {/* Log entries */} {lastResult?.logs.map((log, i) => ( ))} {/* Return value */} {lastResult && lastResult.value !== undefined && lastResult.value !== null && (
Return: {typeof lastResult.value === 'object' ? JSON.stringify(lastResult.value, null, 2) : String(lastResult.value)}
)} {/* Empty state */} {!lastError && !lastResult && (
Press Run or Ctrl+Enter to execute
)}
)}
{/* Right side: AI Chat panel (collapsible, resizable) */} {chatPanelVisible && ( <>
setChatPanelVisible(false)} />
)} {/* Delete confirmation dialog */} { if (!open) setDeleteConfirmId(null); }}> Delete Script Are you sure you want to delete “{deleteConfirmScript?.name ?? 'this script'}”? This action cannot be undone. {promoteOpen && extensionHost && ( s.id === activeScriptId)?.name ?? 'My tool' } onClose={() => setPromoteOpen(false)} /> )}
); } /** Format a log entry's args into a display string */ function formatLogArgs(args: unknown[]): string { return args.map((a) => { if (typeof a === 'object' && a !== null) { try { return JSON.stringify(a, null, 2); } catch { return String(a); } } return String(a); }).join(' '); } /** Render a single log entry with appropriate icon and color — memoized */ const MemoizedLogLine = memo(function LogLine({ log }: { log: LogEntry }) { const formatted = useMemo(() => formatLogArgs(log.args), [log.args]); switch (log.level) { case 'error': return (
{formatted}
); case 'warn': return (
{formatted}
); case 'info': return (
{formatted}
); default: return (
{formatted}
); } });