/* 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 (
fetch, no browser globals.
Use bim.* APIs for viewer / data / export side-effects.