@ifc-lite/viewer 1.14.2 → 1.14.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/assets/{Arrow.dom-CSgnLhN4.js → Arrow.dom-_vGzMMKs.js} +1 -1
  3. package/dist/assets/basketViewActivator-BZcoCL3V.js +1 -0
  4. package/dist/assets/{browser-qSKWrKQW.js → browser-Czmf34bo.js} +1 -1
  5. package/dist/assets/ifc-lite_bg-DyBKoGgk.wasm +0 -0
  6. package/dist/assets/index-CMQ_Dgkr.css +1 -0
  7. package/dist/assets/index-D7nEDctQ.js +229 -0
  8. package/dist/assets/{index-4Y4XaV8N.js → index-DX-Qf5fA.js} +72669 -61673
  9. package/dist/assets/{native-bridge-CSFDsEkg.js → native-bridge-DAOWftxE.js} +1 -1
  10. package/dist/assets/{wasm-bridge-Zf90ysEm.js → wasm-bridge-D7jYpn8a.js} +1 -1
  11. package/dist/index.html +2 -2
  12. package/package.json +21 -20
  13. package/src/App.tsx +17 -1
  14. package/src/components/viewer/BasketPresentationDock.tsx +8 -4
  15. package/src/components/viewer/ChatPanel.tsx +1402 -0
  16. package/src/components/viewer/CodeEditor.tsx +70 -4
  17. package/src/components/viewer/CommandPalette.tsx +1 -0
  18. package/src/components/viewer/HierarchyPanel.tsx +28 -13
  19. package/src/components/viewer/MainToolbar.tsx +113 -95
  20. package/src/components/viewer/ScriptPanel.tsx +351 -184
  21. package/src/components/viewer/UpgradePage.tsx +69 -0
  22. package/src/components/viewer/Viewport.tsx +23 -0
  23. package/src/components/viewer/chat/ChatMessage.tsx +144 -0
  24. package/src/components/viewer/chat/ExecutableCodeBlock.tsx +416 -0
  25. package/src/components/viewer/chat/ModelSelector.tsx +102 -0
  26. package/src/components/viewer/chat/renderTextContent.test.ts +23 -0
  27. package/src/components/viewer/chat/renderTextContent.ts +19 -0
  28. package/src/components/viewer/hierarchy/HierarchyNode.tsx +10 -3
  29. package/src/components/viewer/hierarchy/treeDataBuilder.test.ts +126 -0
  30. package/src/components/viewer/hierarchy/treeDataBuilder.ts +139 -38
  31. package/src/components/viewer/hierarchy/types.ts +6 -1
  32. package/src/components/viewer/hierarchy/useHierarchyTree.ts +27 -12
  33. package/src/hooks/useIfcCache.ts +1 -2
  34. package/src/hooks/useSandbox.ts +122 -6
  35. package/src/index.css +10 -0
  36. package/src/lib/attachments.ts +46 -0
  37. package/src/lib/llm/ClerkChatSync.tsx +74 -0
  38. package/src/lib/llm/clerk-auth.ts +62 -0
  39. package/src/lib/llm/code-extractor.ts +50 -0
  40. package/src/lib/llm/context-builder.test.ts +18 -0
  41. package/src/lib/llm/context-builder.ts +305 -0
  42. package/src/lib/llm/free-models.test.ts +118 -0
  43. package/src/lib/llm/message-capabilities.test.ts +131 -0
  44. package/src/lib/llm/message-capabilities.ts +94 -0
  45. package/src/lib/llm/models.ts +197 -0
  46. package/src/lib/llm/repair-loop.test.ts +91 -0
  47. package/src/lib/llm/repair-loop.ts +76 -0
  48. package/src/lib/llm/script-diagnostics.ts +445 -0
  49. package/src/lib/llm/script-edit-ops.test.ts +399 -0
  50. package/src/lib/llm/script-edit-ops.ts +954 -0
  51. package/src/lib/llm/script-preflight.test.ts +513 -0
  52. package/src/lib/llm/script-preflight.ts +990 -0
  53. package/src/lib/llm/script-preservation.test.ts +128 -0
  54. package/src/lib/llm/script-preservation.ts +152 -0
  55. package/src/lib/llm/stream-client.test.ts +97 -0
  56. package/src/lib/llm/stream-client.ts +410 -0
  57. package/src/lib/llm/system-prompt.test.ts +181 -0
  58. package/src/lib/llm/system-prompt.ts +665 -0
  59. package/src/lib/llm/types.ts +150 -0
  60. package/src/lib/scripts/templates/bim-globals.d.ts +226 -7
  61. package/src/lib/scripts/templates/create-building.ts +12 -12
  62. package/src/main.tsx +10 -1
  63. package/src/sdk/adapters/export-adapter.test.ts +24 -0
  64. package/src/sdk/adapters/export-adapter.ts +40 -16
  65. package/src/sdk/adapters/files-adapter.ts +39 -0
  66. package/src/sdk/adapters/model-compat.ts +1 -1
  67. package/src/sdk/adapters/mutate-adapter.ts +20 -6
  68. package/src/sdk/adapters/mutation-view.ts +112 -0
  69. package/src/sdk/adapters/query-adapter.ts +100 -4
  70. package/src/sdk/local-backend.ts +4 -0
  71. package/src/store/index.ts +15 -1
  72. package/src/store/slices/chatSlice.test.ts +325 -0
  73. package/src/store/slices/chatSlice.ts +468 -0
  74. package/src/store/slices/scriptSlice.test.ts +75 -0
  75. package/src/store/slices/scriptSlice.ts +256 -9
  76. package/src/vite-env.d.ts +10 -0
  77. package/vite.config.ts +21 -2
  78. package/dist/assets/ifc-lite_bg-BOvNXJA_.wasm +0 -0
  79. package/dist/assets/index-ByrFvN5A.css +0 -1
  80. package/dist/assets/index-CN7qDq7G.js +0 -216
@@ -0,0 +1,69 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ import { PricingTable, SignInButton, SignedIn, SignedOut } from '@clerk/clerk-react';
6
+ import { useEffect, useMemo } from 'react';
7
+ import { ArrowLeft } from 'lucide-react';
8
+ import { Button } from '@/components/ui/button';
9
+ import { useViewerStore } from '@/store';
10
+
11
+ export function UpgradePage() {
12
+ const hasPro = useViewerStore((s) => s.chatHasPro);
13
+ const returnTo = useMemo(() => {
14
+ const params = new URLSearchParams(window.location.search);
15
+ const candidate = params.get('returnTo');
16
+ return candidate && candidate.startsWith('/') ? candidate : '/';
17
+ }, []);
18
+
19
+ const navigateBack = () => {
20
+ window.history.replaceState({}, '', returnTo);
21
+ window.dispatchEvent(new PopStateEvent('popstate'));
22
+ };
23
+
24
+ // Automatically return to the previous app view once upgrade is active.
25
+ useEffect(() => {
26
+ if (!hasPro) return;
27
+ const timer = window.setTimeout(() => {
28
+ navigateBack();
29
+ }, 400);
30
+ return () => window.clearTimeout(timer);
31
+ }, [hasPro]);
32
+
33
+ return (
34
+ <div className="min-h-screen bg-background text-foreground">
35
+ <div className="mx-auto w-full max-w-5xl px-6 py-8">
36
+ <div className="mb-6 flex items-center justify-between">
37
+ <Button variant="ghost" size="sm" onClick={navigateBack}>
38
+ <ArrowLeft className="mr-2 h-4 w-4" />
39
+ Back to Viewer
40
+ </Button>
41
+ </div>
42
+
43
+ <div className="rounded-lg border bg-card p-6 shadow-sm">
44
+ <h1 className="text-2xl font-semibold">Upgrade to Pro</h1>
45
+ <p className="mt-2 text-sm text-muted-foreground">
46
+ Free includes daily limited access to free models. Pro unlocks paid models and monthly credits.
47
+ </p>
48
+
49
+ <div className="mt-6">
50
+ <SignedOut>
51
+ <div className="flex items-center justify-center py-12">
52
+ <SignInButton
53
+ mode="modal"
54
+ fallbackRedirectUrl={returnTo}
55
+ forceRedirectUrl={returnTo}
56
+ >
57
+ <Button>Sign in to continue</Button>
58
+ </SignInButton>
59
+ </div>
60
+ </SignedOut>
61
+ <SignedIn>
62
+ <PricingTable />
63
+ </SignedIn>
64
+ </div>
65
+ </div>
66
+ </div>
67
+ </div>
68
+ );
69
+ }
@@ -53,6 +53,27 @@ export function Viewport({ geometry, geometryVersion, coordinateInfo, computedIs
53
53
  const rendererRef = useRef<Renderer | null>(null);
54
54
  const [isInitialized, setIsInitialized] = useState(false);
55
55
 
56
+ const focusViewportForKeyboardShortcuts = useCallback(() => {
57
+ const canvas = canvasRef.current;
58
+ if (!canvas) return;
59
+
60
+ const activeElement = document.activeElement;
61
+ if (activeElement instanceof HTMLElement && activeElement !== canvas) {
62
+ const isEditable =
63
+ activeElement.tagName === 'INPUT' ||
64
+ activeElement.tagName === 'TEXTAREA' ||
65
+ activeElement.isContentEditable;
66
+
67
+ if (isEditable) {
68
+ activeElement.blur();
69
+ }
70
+ }
71
+
72
+ if (document.activeElement !== canvas) {
73
+ canvas.focus({ preventScroll: true });
74
+ }
75
+ }, []);
76
+
56
77
  // Selection state
57
78
  const { selectedEntityId, selectedEntityIds, setSelectedEntityId, setSelectedEntity, toggleSelection, models } = useSelectionState();
58
79
  const selectedEntity = useViewerStore((s) => s.selectedEntity);
@@ -835,7 +856,9 @@ export function Viewport({ geometry, geometryVersion, coordinateInfo, computedIs
835
856
  <canvas
836
857
  ref={canvasRef}
837
858
  data-viewport="main"
859
+ tabIndex={-1}
838
860
  className="w-full h-full block"
861
+ onPointerDown={focusViewportForKeyboardShortcuts}
839
862
  />
840
863
  );
841
864
  }
@@ -0,0 +1,144 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * ChatMessage — renders a single user or assistant message.
7
+ * Assistant messages have executable code blocks inline.
8
+ * Streaming messages show a blinking cursor at the end.
9
+ */
10
+
11
+ import { memo, useMemo } from 'react';
12
+ import { User, Bot, Paperclip } from 'lucide-react';
13
+ import { cn } from '@/lib/utils';
14
+ import { ExecutableCodeBlock } from './ExecutableCodeBlock';
15
+ import type { ChatMessage as ChatMessageType } from '@/lib/llm/types';
16
+ import { renderTextContent } from './renderTextContent';
17
+
18
+ interface ChatMessageProps {
19
+ message: ChatMessageType;
20
+ /** Whether this is a live-streaming message */
21
+ isStreaming?: boolean;
22
+ /** Callback for "Fix this" error feedback */
23
+ onFixError?: (code: string, error: string) => void;
24
+ }
25
+
26
+ /**
27
+ * Split assistant content into text segments and code block placeholders.
28
+ * This allows us to render text normally and code blocks as ExecutableCodeBlock.
29
+ */
30
+ function splitContent(content: string): Array<{ type: 'text'; text: string } | { type: 'code'; index: number }> {
31
+ const parts: Array<{ type: 'text'; text: string } | { type: 'code'; index: number }> = [];
32
+ const regex = /```\w*\n[\s\S]*?```/g;
33
+ let lastIndex = 0;
34
+ let match: RegExpExecArray | null;
35
+ let codeIndex = 0;
36
+
37
+ while ((match = regex.exec(content)) !== null) {
38
+ if (match.index > lastIndex) {
39
+ const text = content.slice(lastIndex, match.index).trim();
40
+ if (text) parts.push({ type: 'text', text });
41
+ }
42
+ const lang = match[0].match(/```(\w*)/)?.[1] ?? '';
43
+ const isExecutable = ['js', 'javascript', 'ts', 'typescript', ''].includes(lang.toLowerCase())
44
+ || match[0].includes('bim.');
45
+ if (isExecutable) {
46
+ parts.push({ type: 'code', index: codeIndex });
47
+ codeIndex++;
48
+ } else {
49
+ parts.push({ type: 'text', text: match[0] });
50
+ }
51
+ lastIndex = match.index + match[0].length;
52
+ }
53
+
54
+ if (lastIndex < content.length) {
55
+ const text = content.slice(lastIndex).trim();
56
+ if (text) parts.push({ type: 'text', text });
57
+ }
58
+
59
+ return parts;
60
+ }
61
+
62
+ export const ChatMessageComponent = memo(function ChatMessageComponent({
63
+ message,
64
+ isStreaming,
65
+ onFixError,
66
+ }: ChatMessageProps) {
67
+ const isUser = message.role === 'user';
68
+ const contentParts = useMemo(
69
+ () => isUser ? null : splitContent(message.content),
70
+ [message.content, isUser],
71
+ );
72
+
73
+ return (
74
+ <div className={cn('flex gap-2 px-3 py-2', isUser ? 'bg-muted/30' : '')}>
75
+ {/* Avatar */}
76
+ <div className={cn(
77
+ 'shrink-0 w-6 h-6 rounded-full flex items-center justify-center mt-0.5',
78
+ isUser ? 'bg-primary/10 text-primary' : 'bg-blue-500/10 text-blue-500',
79
+ )}>
80
+ {isUser ? <User className="h-3.5 w-3.5" /> : <Bot className="h-3.5 w-3.5" />}
81
+ </div>
82
+
83
+ {/* Content */}
84
+ <div className="flex-1 min-w-0 text-sm">
85
+ {/* User message — plain text */}
86
+ {isUser && (
87
+ <>
88
+ <p className="whitespace-pre-wrap break-words">{message.content}</p>
89
+ {message.attachments && message.attachments.length > 0 && (
90
+ <div className="flex flex-wrap gap-1 mt-1">
91
+ {message.attachments.map((a) => (
92
+ a.isImage && a.imageBase64 ? (
93
+ <img
94
+ key={a.id}
95
+ src={a.imageBase64}
96
+ alt={a.name}
97
+ className="max-w-[200px] max-h-[150px] rounded border object-contain"
98
+ />
99
+ ) : (
100
+ <span key={a.id} className="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-muted text-xs text-muted-foreground">
101
+ <Paperclip className="h-3 w-3" />
102
+ {a.name}
103
+ {a.csvData && <span className="opacity-60">({a.csvData.length} rows)</span>}
104
+ </span>
105
+ )
106
+ ))}
107
+ </div>
108
+ )}
109
+ </>
110
+ )}
111
+
112
+ {/* Assistant message — rich content with code blocks */}
113
+ {!isUser && contentParts && contentParts.map((part, i) => {
114
+ if (part.type === 'text') {
115
+ return (
116
+ <div
117
+ key={i}
118
+ className="prose prose-sm dark:prose-invert max-w-none [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs"
119
+ dangerouslySetInnerHTML={{ __html: renderTextContent(part.text) }}
120
+ />
121
+ );
122
+ }
123
+ const block = message.codeBlocks?.find((b) => b.index === part.index);
124
+ if (!block) return null;
125
+ const execResult = message.execResults?.get(part.index);
126
+ return (
127
+ <ExecutableCodeBlock
128
+ key={`code-${i}`}
129
+ block={block}
130
+ messageId={message.id}
131
+ result={execResult}
132
+ onFixError={onFixError}
133
+ />
134
+ );
135
+ })}
136
+
137
+ {/* Streaming cursor */}
138
+ {isStreaming && (
139
+ <span className="inline-block w-1.5 h-4 bg-blue-500 animate-pulse ml-0.5 align-text-bottom rounded-sm" />
140
+ )}
141
+ </div>
142
+ </div>
143
+ );
144
+ });
@@ -0,0 +1,416 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * ExecutableCodeBlock — renders a code block from an LLM response
7
+ * with a "Run" button that executes it in the QuickJS sandbox.
8
+ * Results (logs, return value, errors) appear in a console-like panel.
9
+ * Failed executions show a "Fix this" button that feeds the error
10
+ * back to the LLM for automatic repair.
11
+ *
12
+ * Auto-execute: when the chat sets status to 'running' (via auto-execute toggle),
13
+ * a useEffect triggers actual sandbox execution automatically.
14
+ */
15
+
16
+ import { memo, useCallback, useState, useEffect, useRef } from 'react';
17
+ import {
18
+ Play,
19
+ Copy,
20
+ CheckCircle2,
21
+ AlertCircle,
22
+ Loader2,
23
+ FileCode2,
24
+ RefreshCw,
25
+ Terminal,
26
+ ChevronDown,
27
+ ChevronRight,
28
+ } from 'lucide-react';
29
+ import { Button } from '@/components/ui/button';
30
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
31
+ import { cn } from '@/lib/utils';
32
+ import { useSandbox } from '@/hooks/useSandbox';
33
+ import { useViewerStore } from '@/store';
34
+ import type { CodeBlock, CodeExecResult } from '@/lib/llm/types';
35
+
36
+ interface ExecutableCodeBlockProps {
37
+ block: CodeBlock;
38
+ messageId: string;
39
+ result?: CodeExecResult;
40
+ /** Callback to trigger a "fix this" error feedback loop */
41
+ onFixError?: (code: string, error: string) => void;
42
+ }
43
+
44
+ /** Format a log arg for display */
45
+ function formatArg(a: unknown): string {
46
+ if (typeof a === 'object' && a !== null) {
47
+ try {
48
+ return JSON.stringify(a, null, 2);
49
+ } catch {
50
+ return String(a);
51
+ }
52
+ }
53
+ return String(a);
54
+ }
55
+
56
+ /** Level prefix for console lines */
57
+ function levelPrefix(level: string): string {
58
+ switch (level) {
59
+ case 'error': return '✕';
60
+ case 'warn': return '⚠';
61
+ case 'info': return 'ℹ';
62
+ default: return '›';
63
+ }
64
+ }
65
+
66
+ function captureCompressedCanvasImage(canvas: HTMLCanvasElement): string {
67
+ const maxSide = 1400;
68
+ const srcW = canvas.width || canvas.clientWidth || 0;
69
+ const srcH = canvas.height || canvas.clientHeight || 0;
70
+ if (srcW <= 0 || srcH <= 0) {
71
+ return canvas.toDataURL('image/jpeg', 0.72);
72
+ }
73
+
74
+ const scale = Math.min(1, maxSide / Math.max(srcW, srcH));
75
+ const outW = Math.max(1, Math.round(srcW * scale));
76
+ const outH = Math.max(1, Math.round(srcH * scale));
77
+ const out = document.createElement('canvas');
78
+ out.width = outW;
79
+ out.height = outH;
80
+ const ctx = out.getContext('2d');
81
+ if (!ctx) {
82
+ return canvas.toDataURL('image/jpeg', 0.72);
83
+ }
84
+ ctx.drawImage(canvas, 0, 0, outW, outH);
85
+ return out.toDataURL('image/jpeg', 0.72);
86
+ }
87
+
88
+ export const ExecutableCodeBlock = memo(function ExecutableCodeBlock({
89
+ block,
90
+ messageId,
91
+ result,
92
+ onFixError,
93
+ }: ExecutableCodeBlockProps) {
94
+ const { execute } = useSandbox();
95
+ const setCodeExecResult = useViewerStore((s) => s.setCodeExecResult);
96
+ const setScriptError = useViewerStore((s) => s.setScriptError);
97
+ const [copied, setCopied] = useState(false);
98
+ const [consoleOpen, setConsoleOpen] = useState(true);
99
+ const consoleEndRef = useRef<HTMLDivElement>(null);
100
+ const autoExecTriggered = useRef(false);
101
+ const copiedResetTimerRef = useRef<number | null>(null);
102
+
103
+ const handleRun = useCallback(async () => {
104
+ setCodeExecResult(messageId, block.index, { status: 'running' });
105
+
106
+ try {
107
+ const scriptResult = await execute(block.code);
108
+ if (scriptResult) {
109
+ setCodeExecResult(messageId, block.index, {
110
+ status: 'success',
111
+ logs: scriptResult.logs,
112
+ value: scriptResult.value,
113
+ durationMs: scriptResult.durationMs,
114
+ });
115
+
116
+ // Auto-capture viewport screenshot if script likely created/modified geometry
117
+ if (block.code.includes('loadIfc') || block.code.includes('bim.create') || block.code.includes('colorize')) {
118
+ // Small delay to let the renderer finish presenting the frame
119
+ setTimeout(() => {
120
+ try {
121
+ const canvas = document.querySelector('canvas');
122
+ if (canvas) {
123
+ const dataUrl = captureCompressedCanvasImage(canvas as HTMLCanvasElement);
124
+ useViewerStore.getState().setChatViewportScreenshot(dataUrl);
125
+ }
126
+ } catch { /* screenshot capture failed — non-critical */ }
127
+ }, 500);
128
+ }
129
+ } else {
130
+ // useSandbox sets scriptLastError synchronously before returning null —
131
+ // read it immediately after the await to get the actual error message.
132
+ const { scriptLastError, scriptLastResult } = useViewerStore.getState();
133
+ setCodeExecResult(messageId, block.index, {
134
+ status: 'error',
135
+ error: scriptLastError ?? 'Script execution failed',
136
+ logs: scriptLastResult?.logs,
137
+ durationMs: scriptLastResult?.durationMs,
138
+ });
139
+ }
140
+ } catch (err) {
141
+ setCodeExecResult(messageId, block.index, {
142
+ status: 'error',
143
+ error: err instanceof Error ? err.message : String(err),
144
+ });
145
+ }
146
+ }, [execute, block.code, block.index, messageId, setCodeExecResult]);
147
+
148
+ // Auto-execute: when the chat auto-execute toggle triggers a 'running' status
149
+ // before this component has executed, trigger actual execution
150
+ useEffect(() => {
151
+ if (result?.status === 'running' && !autoExecTriggered.current) {
152
+ autoExecTriggered.current = true;
153
+ void handleRun();
154
+ }
155
+ // Reset the flag when result goes back to idle / new result
156
+ if (result?.status !== 'running') {
157
+ autoExecTriggered.current = false;
158
+ }
159
+ }, [result?.status, handleRun]);
160
+
161
+ useEffect(() => () => {
162
+ if (copiedResetTimerRef.current !== null) {
163
+ window.clearTimeout(copiedResetTimerRef.current);
164
+ }
165
+ }, []);
166
+
167
+ // Auto-scroll console to bottom when new logs appear
168
+ useEffect(() => {
169
+ consoleEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
170
+ }, [result?.logs?.length, result?.status]);
171
+
172
+ const handleCopy = useCallback(async () => {
173
+ try {
174
+ await navigator.clipboard.writeText(block.code);
175
+ setCopied(true);
176
+ if (copiedResetTimerRef.current !== null) {
177
+ window.clearTimeout(copiedResetTimerRef.current);
178
+ }
179
+ copiedResetTimerRef.current = window.setTimeout(() => {
180
+ setCopied(false);
181
+ copiedResetTimerRef.current = null;
182
+ }, 2000);
183
+ } catch (error) {
184
+ setScriptError(
185
+ error instanceof Error ? error.message : 'Could not copy code block to clipboard.',
186
+ [],
187
+ );
188
+ }
189
+ }, [block.code, setScriptError]);
190
+
191
+ const handleApplyToEditor = useCallback(() => {
192
+ const state = useViewerStore.getState();
193
+ const applyResult = state.applyScriptEditOps([{
194
+ opId: crypto.randomUUID(),
195
+ type: 'replaceSelection',
196
+ baseRevision: state.scriptEditorRevision,
197
+ text: block.code,
198
+ }], {
199
+ intent: 'create',
200
+ });
201
+ if (!applyResult.ok) {
202
+ setScriptError(applyResult.error ?? 'Could not apply code block to the current selection.', applyResult.diagnostic ? [applyResult.diagnostic] : []);
203
+ return;
204
+ }
205
+ setScriptError(null);
206
+ state.setScriptPanelVisible(true);
207
+ }, [block.code, setScriptError]);
208
+
209
+ const handleReplaceAllInEditor = useCallback(() => {
210
+ const state = useViewerStore.getState();
211
+ const applyResult = state.replaceScriptContentFallback(block.code, {
212
+ intent: 'explicit_rewrite',
213
+ source: 'manual_replace_all',
214
+ });
215
+ if (!applyResult.ok) {
216
+ setScriptError(applyResult.error ?? 'Could not replace the script with this code block.', applyResult.diagnostic ? [applyResult.diagnostic] : []);
217
+ return;
218
+ }
219
+ setScriptError(null);
220
+ state.setScriptPanelVisible(true);
221
+ }, [block.code, setScriptError]);
222
+
223
+ const handleFixError = useCallback(() => {
224
+ if (result?.status === 'error' && result.error && onFixError) {
225
+ onFixError(block.code, result.error);
226
+ }
227
+ }, [block.code, result, onFixError]);
228
+
229
+ const isRunning = result?.status === 'running';
230
+ const hasOutput = result && (
231
+ result.status !== 'running' ||
232
+ (result.logs && result.logs.length > 0)
233
+ );
234
+ const hasLogs = result?.logs && result.logs.length > 0;
235
+
236
+ return (
237
+ <div className="my-2 rounded-md border bg-muted/30 overflow-hidden">
238
+ {/* Code header with action buttons */}
239
+ <div className="flex items-center gap-1 px-2 py-1 bg-muted/50 border-b">
240
+ <span className="text-[10px] font-mono text-muted-foreground uppercase">
241
+ {block.language || 'js'}
242
+ </span>
243
+ <div className="flex-1" />
244
+ <Tooltip>
245
+ <TooltipTrigger asChild>
246
+ <Button variant="ghost" size="icon-xs" onClick={handleCopy}>
247
+ {copied ? <CheckCircle2 className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
248
+ </Button>
249
+ </TooltipTrigger>
250
+ <TooltipContent>Copy code</TooltipContent>
251
+ </Tooltip>
252
+ <Tooltip>
253
+ <TooltipTrigger asChild>
254
+ <Button variant="ghost" size="icon-xs" onClick={handleApplyToEditor}>
255
+ <FileCode2 className="h-3 w-3" />
256
+ </Button>
257
+ </TooltipTrigger>
258
+ <TooltipContent>Apply to selection</TooltipContent>
259
+ </Tooltip>
260
+ <Tooltip>
261
+ <TooltipTrigger asChild>
262
+ <Button variant="ghost" size="icon-xs" onClick={handleReplaceAllInEditor}>
263
+ All
264
+ </Button>
265
+ </TooltipTrigger>
266
+ <TooltipContent>Replace entire script</TooltipContent>
267
+ </Tooltip>
268
+ <Tooltip>
269
+ <TooltipTrigger asChild>
270
+ <Button
271
+ variant="default"
272
+ size="sm"
273
+ onClick={handleRun}
274
+ disabled={isRunning}
275
+ className="gap-1 h-6 px-2 text-xs"
276
+ >
277
+ {isRunning ? (
278
+ <Loader2 className="h-3 w-3 animate-spin" />
279
+ ) : (
280
+ <Play className="h-3 w-3" />
281
+ )}
282
+ {isRunning ? 'Running...' : 'Run'}
283
+ </Button>
284
+ </TooltipTrigger>
285
+ <TooltipContent>Execute in sandbox</TooltipContent>
286
+ </Tooltip>
287
+ </div>
288
+
289
+ {/* Code content */}
290
+ <pre className="px-3 py-2 text-xs font-mono overflow-x-auto max-h-[300px] overflow-y-auto">
291
+ <code>{block.code}</code>
292
+ </pre>
293
+
294
+ {/* Console output panel */}
295
+ {(isRunning || hasOutput) && (
296
+ <div className="border-t">
297
+ {/* Console header */}
298
+ <button
299
+ onClick={() => setConsoleOpen(!consoleOpen)}
300
+ className="flex items-center gap-1.5 w-full px-2 py-1 bg-muted text-muted-foreground hover:bg-muted/80 transition-colors"
301
+ >
302
+ {consoleOpen ? (
303
+ <ChevronDown className="h-3 w-3 shrink-0" />
304
+ ) : (
305
+ <ChevronRight className="h-3 w-3 shrink-0" />
306
+ )}
307
+ <Terminal className="h-3 w-3 shrink-0" />
308
+ <span className="text-[10px] font-mono uppercase tracking-wider">Console</span>
309
+ {isRunning && (
310
+ <Loader2 className="h-3 w-3 animate-spin ml-1 text-blue-500" />
311
+ )}
312
+ {result?.status === 'success' && (
313
+ <CheckCircle2 className="h-3 w-3 ml-1 text-emerald-500" />
314
+ )}
315
+ {result?.status === 'error' && (
316
+ <AlertCircle className="h-3 w-3 ml-1 text-destructive" />
317
+ )}
318
+ {result?.durationMs !== undefined && result.status !== 'running' && (
319
+ <span className="text-[10px] font-mono text-muted-foreground/60 ml-auto">
320
+ {result.durationMs}ms
321
+ </span>
322
+ )}
323
+ </button>
324
+
325
+ {/* Console body */}
326
+ {consoleOpen && (
327
+ <div className="bg-muted px-2 py-1.5 text-xs font-mono max-h-[200px] overflow-y-auto">
328
+ {/* Running indicator */}
329
+ {isRunning && (!hasLogs) && (
330
+ <div className="flex items-center gap-1.5 text-blue-500 py-0.5">
331
+ <Loader2 className="h-3 w-3 animate-spin" />
332
+ <span>Executing script...</span>
333
+ </div>
334
+ )}
335
+
336
+ {/* Log entries */}
337
+ {hasLogs && result.logs!.map((log, i) => (
338
+ <div
339
+ key={i}
340
+ className={cn(
341
+ 'flex items-start gap-1.5 py-0.5 leading-relaxed',
342
+ log.level === 'error' && 'text-destructive',
343
+ log.level === 'warn' && 'text-amber-500',
344
+ log.level === 'info' && 'text-blue-500',
345
+ log.level === 'log' && 'text-foreground',
346
+ )}
347
+ >
348
+ <span className="shrink-0 w-3 text-center opacity-60">{levelPrefix(log.level)}</span>
349
+ <span className="whitespace-pre-wrap break-all">
350
+ {log.args.map(formatArg).join(' ')}
351
+ </span>
352
+ </div>
353
+ ))}
354
+
355
+ {/* Error message */}
356
+ {result?.status === 'error' && result.error && (
357
+ <div className="flex items-start gap-1.5 py-0.5 text-destructive">
358
+ <span className="shrink-0 w-3 text-center">✕</span>
359
+ <span className="whitespace-pre-wrap break-all">{result.error}</span>
360
+ </div>
361
+ )}
362
+
363
+ {/* Return value */}
364
+ {result?.status === 'success' && result.value !== undefined && result.value !== null && (
365
+ <div className="flex items-start gap-1.5 py-0.5 text-emerald-500 border-t border-border mt-1 pt-1">
366
+ <span className="shrink-0 w-3 text-center opacity-60">←</span>
367
+ <span className="whitespace-pre-wrap break-all">
368
+ {typeof result.value === 'object'
369
+ ? JSON.stringify(result.value, null, 2)
370
+ : String(result.value)}
371
+ </span>
372
+ </div>
373
+ )}
374
+
375
+ {/* Success footer */}
376
+ {result?.status === 'success' && (
377
+ <div className="flex items-center gap-1 text-emerald-500 border-t border-border mt-1 pt-1">
378
+ <CheckCircle2 className="h-3 w-3" />
379
+ <span>Done{result.durationMs !== undefined ? ` in ${result.durationMs}ms` : ''}</span>
380
+ </div>
381
+ )}
382
+
383
+ <div ref={consoleEndRef} />
384
+ </div>
385
+ )}
386
+
387
+ {/* Error action buttons */}
388
+ {result?.status === 'error' && (
389
+ <div className="flex items-center gap-1.5 px-2 py-1.5 bg-muted border-t border-border">
390
+ {onFixError && (
391
+ <Button
392
+ variant="outline"
393
+ size="sm"
394
+ onClick={handleFixError}
395
+ className="gap-1 h-6 px-2 text-xs text-destructive border-destructive/30 hover:bg-destructive/10 bg-transparent"
396
+ >
397
+ <RefreshCw className="h-3 w-3" />
398
+ Fix this
399
+ </Button>
400
+ )}
401
+ <Button
402
+ variant="outline"
403
+ size="sm"
404
+ onClick={handleRun}
405
+ className="gap-1 h-6 px-2 text-xs bg-transparent"
406
+ >
407
+ <Play className="h-3 w-3" />
408
+ Re-run
409
+ </Button>
410
+ </div>
411
+ )}
412
+ </div>
413
+ )}
414
+ </div>
415
+ );
416
+ });