@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
@@ -12,13 +12,14 @@
12
12
 
13
13
  import { useRef, useEffect } from 'react';
14
14
  import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection } from '@codemirror/view';
15
- import { EditorState, Compartment } from '@codemirror/state';
15
+ import { EditorState, Compartment, Transaction } from '@codemirror/state';
16
16
  import { javascript } from '@codemirror/lang-javascript';
17
17
  import { autocompletion, type CompletionContext, type CompletionResult, type Completion } from '@codemirror/autocomplete';
18
- import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
18
+ import { defaultKeymap, history, historyKeymap, indentWithTab, undo, redo, undoDepth, redoDepth } from '@codemirror/commands';
19
19
  import { syntaxHighlighting, defaultHighlightStyle, bracketMatching, indentOnInput } from '@codemirror/language';
20
20
  import { highlightSelectionMatches } from '@codemirror/search';
21
21
  import { NAMESPACE_SCHEMAS } from '@ifc-lite/sandbox/schema';
22
+ import type { ScriptEditorSelection, ScriptEditorTextChange } from '@/lib/llm/types';
22
23
 
23
24
  /** Shared structural styles (mode-agnostic) */
24
25
  const baseTheme = EditorView.theme({
@@ -201,20 +202,44 @@ function bimCompletions(context: CompletionContext): CompletionResult | null {
201
202
  interface CodeEditorProps {
202
203
  value: string;
203
204
  onChange: (value: string) => void;
205
+ onSelectionChange?: (selection: ScriptEditorSelection) => void;
206
+ onHistoryChange?: (canUndo: boolean, canRedo: boolean) => void;
207
+ registerApplyAdapter?: ((adapter: {
208
+ apply: (
209
+ nextContent: string,
210
+ selection: ScriptEditorSelection,
211
+ options?: { userEvent?: string; changes?: ScriptEditorTextChange[] },
212
+ ) => void;
213
+ undo: () => void;
214
+ redo: () => void;
215
+ } | null) => void);
204
216
  onRun?: () => void;
205
217
  onSave?: () => void;
206
218
  className?: string;
207
219
  }
208
220
 
209
- export function CodeEditor({ value, onChange, onRun, onSave, className }: CodeEditorProps) {
221
+ export function CodeEditor({
222
+ value,
223
+ onChange,
224
+ onSelectionChange,
225
+ onHistoryChange,
226
+ registerApplyAdapter,
227
+ onRun,
228
+ onSave,
229
+ className,
230
+ }: CodeEditorProps) {
210
231
  const containerRef = useRef<HTMLDivElement>(null);
211
232
  const viewRef = useRef<EditorView | null>(null);
212
233
  const onChangeRef = useRef(onChange);
234
+ const onSelectionChangeRef = useRef(onSelectionChange);
235
+ const onHistoryChangeRef = useRef(onHistoryChange);
213
236
  const onRunRef = useRef(onRun);
214
237
  const onSaveRef = useRef(onSave);
215
238
 
216
239
  // Keep callback refs up to date without recreating the editor
217
240
  onChangeRef.current = onChange;
241
+ onSelectionChangeRef.current = onSelectionChange;
242
+ onHistoryChangeRef.current = onHistoryChange;
218
243
  onRunRef.current = onRun;
219
244
  onSaveRef.current = onSave;
220
245
 
@@ -239,6 +264,16 @@ export function CodeEditor({ value, onChange, onRun, onSave, className }: CodeEd
239
264
  if (update.docChanged) {
240
265
  onChangeRef.current(update.state.doc.toString());
241
266
  }
267
+ if (update.selectionSet || update.docChanged) {
268
+ const main = update.state.selection.main;
269
+ onSelectionChangeRef.current?.({ from: main.from, to: main.to });
270
+ }
271
+ if (update.docChanged) {
272
+ onHistoryChangeRef.current?.(
273
+ undoDepth(update.state) > 0,
274
+ redoDepth(update.state) > 0,
275
+ );
276
+ }
242
277
  });
243
278
 
244
279
  const state = EditorState.create({
@@ -274,6 +309,35 @@ export function CodeEditor({ value, onChange, onRun, onSave, className }: CodeEd
274
309
  });
275
310
 
276
311
  viewRef.current = view;
312
+ const initialSelection = view.state.selection.main;
313
+ onSelectionChangeRef.current?.({ from: initialSelection.from, to: initialSelection.to });
314
+ onHistoryChangeRef.current?.(undoDepth(view.state) > 0, redoDepth(view.state) > 0);
315
+ registerApplyAdapter?.({
316
+ apply: (nextContent, selection, options) => {
317
+ const active = viewRef.current;
318
+ if (!active) return;
319
+ const safeFrom = Math.max(0, Math.min(selection.from, nextContent.length));
320
+ const safeTo = Math.max(safeFrom, Math.min(selection.to, nextContent.length));
321
+ const changes = options?.changes && options.changes.length > 0
322
+ ? options.changes
323
+ : [{ from: 0, to: active.state.doc.length, insert: nextContent }];
324
+ active.dispatch({
325
+ changes,
326
+ selection: { anchor: safeFrom, head: safeTo },
327
+ annotations: options?.userEvent ? [Transaction.userEvent.of(options.userEvent)] : undefined,
328
+ });
329
+ },
330
+ undo: () => {
331
+ const active = viewRef.current;
332
+ if (!active) return;
333
+ undo(active);
334
+ },
335
+ redo: () => {
336
+ const active = viewRef.current;
337
+ if (!active) return;
338
+ redo(active);
339
+ },
340
+ });
277
341
 
278
342
  // Watch for light/dark mode changes on <html> class
279
343
  const observer = new MutationObserver(() => {
@@ -282,13 +346,15 @@ export function CodeEditor({ value, onChange, onRun, onSave, className }: CodeEd
282
346
  observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
283
347
 
284
348
  return () => {
349
+ onHistoryChangeRef.current?.(false, false);
350
+ registerApplyAdapter?.(null);
285
351
  observer.disconnect();
286
352
  view.destroy();
287
353
  viewRef.current = null;
288
354
  };
289
355
  // Only create once — value is set via initial doc
290
356
  // eslint-disable-next-line react-hooks/exhaustive-deps
291
- }, []);
357
+ }, [registerApplyAdapter]);
292
358
 
293
359
  // Sync external value changes into the editor (e.g., loading a different script)
294
360
  const lastExternalValue = useRef(value);
@@ -218,6 +218,7 @@ function activateBottomPanel(panel: 'script' | 'list') {
218
218
  s.setListPanelVisible(false);
219
219
 
220
220
  if (!isActive) {
221
+ s.setRightPanelCollapsed(false);
221
222
  if (panel === 'script') s.setScriptPanelVisible(true);
222
223
  else s.setListPanelVisible(true);
223
224
  }
@@ -319,27 +319,42 @@ export function HierarchyPanel() {
319
319
  setStoreysSelection(storeyIds);
320
320
  }
321
321
  }
322
+ } else if (node.type === 'IfcSpace') {
323
+ const spaceId = node.expressIds[0];
324
+ const modelId = node.modelIds[0];
325
+ const globalId = node.globalIds[0] ?? spaceId;
326
+
327
+ setSelectedEntityIds([]);
328
+
329
+ if (modelId && modelId !== 'legacy') {
330
+ setSelectedEntityId(globalId);
331
+ setSelectedEntity({ modelId, expressId: spaceId });
332
+ setActiveModel(modelId);
333
+ } else {
334
+ setSelectedEntityId(globalId);
335
+ setSelectedEntity({ modelId: 'legacy', expressId: spaceId });
336
+ }
337
+
338
+ if (node.hasChildren) {
339
+ toggleExpand(node.id);
340
+ }
322
341
  } else if (node.type === 'element') {
323
342
  // Element click - select it
324
- const elementId = node.expressIds[0]; // Original expressId
343
+ const elementId = node.expressIds[0];
325
344
  const modelId = node.modelIds[0];
345
+ const globalId = node.globalIds[0] ?? elementId;
326
346
 
327
347
  // Clear multi-selection (e.g. from a prior type-group click) so only
328
348
  // this single element is highlighted, matching Viewport pick behavior
329
349
  setSelectedEntityIds([]);
330
350
 
331
351
  if (modelId !== 'legacy') {
332
- // Multi-model: need to convert to globalId for renderer
333
- const model = models.get(modelId);
334
- const globalId = elementId + (model?.idOffset ?? 0);
335
352
  setSelectedEntityId(globalId);
336
353
  setSelectedEntity({ modelId, expressId: elementId });
337
354
  setActiveModel(modelId);
338
355
  } else {
339
- // Legacy single-model: expressId = globalId (offset is 0)
340
- setSelectedEntityId(elementId);
341
- // Also set selectedEntity for property panel (was missing, causing blank panel)
342
- setSelectedEntity(resolveEntityRef(elementId));
356
+ setSelectedEntityId(globalId);
357
+ setSelectedEntity(resolveEntityRef(globalId));
343
358
  }
344
359
  }
345
360
  }, [selectedStoreys, setStoreysSelection, clearStoreySelection, setSelectedEntityId, setSelectedEntityIds, setSelectedEntity, setSelectedEntities, setActiveModel, toggleExpand, unifiedStoreys, models, isolateEntities, getNodeElements, setHierarchyBasketSelection, toGlobalId]);
@@ -352,8 +367,8 @@ export function HierarchyPanel() {
352
367
  ? node.expressIds.some(id => selectedStoreys.has(id))
353
368
  : node.type === 'IfcBuildingStorey'
354
369
  ? selectedStoreys.has(node.expressIds[0])
355
- : node.type === 'element'
356
- ? selectedEntityId === node.expressIds[0]
370
+ : node.type === 'IfcSpace' || node.type === 'element'
371
+ ? selectedEntityId === (node.globalIds[0] ?? node.expressIds[0])
357
372
  : node.type === 'ifc-type'
358
373
  ? (() => {
359
374
  const typeExpressId = node.entityExpressId;
@@ -369,8 +384,8 @@ export function HierarchyPanel() {
369
384
  // Compute visibility inline - for elements check directly, for storeys use getNodeElements
370
385
  let nodeHidden = false;
371
386
  if (node.type === 'element') {
372
- nodeHidden = hiddenEntities.has(node.expressIds[0]);
373
- } else if (node.type === 'IfcBuildingStorey' || node.type === 'unified-storey' ||
387
+ nodeHidden = hiddenEntities.has(node.globalIds[0] ?? node.expressIds[0]);
388
+ } else if (node.type === 'IfcBuildingStorey' || node.type === 'IfcSpace' || node.type === 'unified-storey' ||
374
389
  node.type === 'type-group' || node.type === 'ifc-type' ||
375
390
  (node.type === 'model-header' && node.id.startsWith('contrib-'))) {
376
391
  const elements = getNodeElements(node);
@@ -385,7 +400,7 @@ export function HierarchyPanel() {
385
400
  }
386
401
 
387
402
  return { isSelected, nodeHidden, modelVisible };
388
- }, [selectedStoreys, selectedEntityId, hiddenEntities, getNodeElements, models]);
403
+ }, [selectedStoreys, selectedEntityId, hiddenEntities, getNodeElements, models, toGlobalId]);
389
404
 
390
405
  if (!ifcDataStore && models.size === 0) {
391
406
  return (
@@ -36,7 +36,9 @@ import {
36
36
  ClipboardCheck,
37
37
  Palette,
38
38
  Orbit,
39
+ Layout,
39
40
  LayoutTemplate,
41
+ FileCode2,
40
42
  } from 'lucide-react';
41
43
  import { Button } from '@/components/ui/button';
42
44
  import { Separator } from '@/components/ui/separator';
@@ -70,6 +72,7 @@ import { ThemeSwitch } from './ThemeSwitch';
70
72
  import { toast } from '@/components/ui/toast';
71
73
 
72
74
  type Tool = 'select' | 'pan' | 'orbit' | 'walk' | 'measure' | 'section';
75
+ type WorkspacePanel = 'script' | 'list' | 'bcf' | 'ids' | 'lens';
73
76
 
74
77
  // #region FIX: Move ToolButton OUTSIDE MainToolbar to prevent recreation on every render
75
78
  // This fixes Radix UI Tooltip's asChild prop becoming stale during re-renders
@@ -176,13 +179,11 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
176
179
  const toggleTypeVisibility = useViewerStore((state) => state.toggleTypeVisibility);
177
180
  const resetViewerState = useViewerStore((state) => state.resetViewerState);
178
181
  const bcfPanelVisible = useViewerStore((state) => state.bcfPanelVisible);
179
- const toggleBcfPanel = useViewerStore((state) => state.toggleBcfPanel);
180
182
  const setBcfPanelVisible = useViewerStore((state) => state.setBcfPanelVisible);
181
183
  const idsPanelVisible = useViewerStore((state) => state.idsPanelVisible);
182
- const toggleIdsPanel = useViewerStore((state) => state.toggleIdsPanel);
183
184
  const setIdsPanelVisible = useViewerStore((state) => state.setIdsPanelVisible);
184
185
  const listPanelVisible = useViewerStore((state) => state.listPanelVisible);
185
- const toggleListPanel = useViewerStore((state) => state.toggleListPanel);
186
+ const setListPanelVisible = useViewerStore((state) => state.setListPanelVisible);
186
187
  const setRightPanelCollapsed = useViewerStore((state) => state.setRightPanelCollapsed);
187
188
  const projectionMode = useViewerStore((state) => state.projectionMode);
188
189
  const toggleProjectionMode = useViewerStore((state) => state.toggleProjectionMode);
@@ -193,8 +194,9 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
193
194
  const toggleBasketPresentationVisible = useViewerStore((state) => state.toggleBasketPresentationVisible);
194
195
  // Lens state
195
196
  const lensPanelVisible = useViewerStore((state) => state.lensPanelVisible);
196
- const toggleLensPanel = useViewerStore((state) => state.toggleLensPanel);
197
197
  const setLensPanelVisible = useViewerStore((state) => state.setLensPanelVisible);
198
+ const scriptPanelVisible = useViewerStore((state) => state.scriptPanelVisible);
199
+ const setScriptPanelVisible = useViewerStore((state) => state.setScriptPanelVisible);
198
200
 
199
201
  // Check which type geometries exist across ALL loaded models (federation-aware).
200
202
  // PERF: Use meshes.length as dep proxy instead of full geometryResult, and
@@ -361,6 +363,61 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
361
363
  goHomeFromStore();
362
364
  }, []);
363
365
 
366
+ const handleToggleBottomPanel = useCallback((panel: 'script' | 'list') => {
367
+ const isScriptPanel = panel === 'script';
368
+ const nextScriptVisible = isScriptPanel ? !scriptPanelVisible : false;
369
+ const nextListVisible = isScriptPanel ? false : !listPanelVisible;
370
+
371
+ setScriptPanelVisible(nextScriptVisible);
372
+ setListPanelVisible(nextListVisible);
373
+
374
+ if (nextScriptVisible || nextListVisible) {
375
+ setRightPanelCollapsed(false);
376
+ }
377
+ }, [listPanelVisible, scriptPanelVisible, setListPanelVisible, setRightPanelCollapsed, setScriptPanelVisible]);
378
+
379
+ const handleToggleRightPanel = useCallback((panel: 'bcf' | 'ids' | 'lens') => {
380
+ const nextBcfVisible = panel === 'bcf' ? !bcfPanelVisible : false;
381
+ const nextIdsVisible = panel === 'ids' ? !idsPanelVisible : false;
382
+ const nextLensVisible = panel === 'lens' ? !lensPanelVisible : false;
383
+
384
+ setBcfPanelVisible(nextBcfVisible);
385
+ setIdsPanelVisible(nextIdsVisible);
386
+ setLensPanelVisible(nextLensVisible);
387
+
388
+ if (nextBcfVisible || nextIdsVisible || nextLensVisible) {
389
+ setRightPanelCollapsed(false);
390
+ }
391
+ }, [
392
+ bcfPanelVisible,
393
+ idsPanelVisible,
394
+ lensPanelVisible,
395
+ setBcfPanelVisible,
396
+ setIdsPanelVisible,
397
+ setLensPanelVisible,
398
+ setRightPanelCollapsed,
399
+ ]);
400
+
401
+ const activeWorkspacePanels = useMemo(() => {
402
+ const panels = new Set<WorkspacePanel>();
403
+ if (scriptPanelVisible) panels.add('script');
404
+ if (listPanelVisible) panels.add('list');
405
+ if (bcfPanelVisible) panels.add('bcf');
406
+ if (idsPanelVisible) panels.add('ids');
407
+ if (lensPanelVisible) panels.add('lens');
408
+ return panels;
409
+ }, [bcfPanelVisible, idsPanelVisible, lensPanelVisible, listPanelVisible, scriptPanelVisible]);
410
+
411
+ const workspacePanelLabel = useMemo(() => {
412
+ if (activeWorkspacePanels.size === 0) return null;
413
+ if (activeWorkspacePanels.size > 1) return 'Multiple Panels';
414
+ if (activeWorkspacePanels.has('script')) return 'Script Editor';
415
+ if (activeWorkspacePanels.has('list')) return 'Lists';
416
+ if (activeWorkspacePanels.has('bcf')) return 'BCF Issues';
417
+ if (activeWorkspacePanels.has('ids')) return 'IDS Validation';
418
+ return 'Lens Rules';
419
+ }, [activeWorkspacePanels]);
420
+
364
421
  const handleExportGLB = useCallback(() => {
365
422
  if (!geometryResult) return;
366
423
  try {
@@ -624,76 +681,61 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
624
681
  <ExportChangesButton />
625
682
 
626
683
  {/* ── Panels ── */}
627
- {/* BCF Issues Button */}
628
- <Tooltip>
629
- <TooltipTrigger asChild>
630
- <Button
631
- variant={bcfPanelVisible ? 'default' : 'ghost'}
632
- size="icon-sm"
633
- onClick={(e) => {
634
- (e.currentTarget as HTMLButtonElement).blur();
635
- if (!bcfPanelVisible) {
636
- // Close other right-panel content first, then expand
637
- setIdsPanelVisible(false);
638
- setLensPanelVisible(false);
639
- setRightPanelCollapsed(false);
640
- }
641
- toggleBcfPanel();
642
- }}
643
- className={cn(bcfPanelVisible && 'bg-primary text-primary-foreground')}
684
+ <DropdownMenu>
685
+ <Tooltip>
686
+ <TooltipTrigger asChild>
687
+ <DropdownMenuTrigger asChild>
688
+ <Button
689
+ variant={activeWorkspacePanels.size > 0 ? 'default' : 'ghost'}
690
+ size="icon-sm"
691
+ aria-label={workspacePanelLabel ? `Panels: ${workspacePanelLabel}` : 'Panels'}
692
+ className={cn(activeWorkspacePanels.size > 0 && 'bg-primary text-primary-foreground')}
693
+ >
694
+ <Layout className="h-4 w-4" />
695
+ </Button>
696
+ </DropdownMenuTrigger>
697
+ </TooltipTrigger>
698
+ <TooltipContent>{workspacePanelLabel ? `Panels: ${workspacePanelLabel}` : 'Panels'}</TooltipContent>
699
+ </Tooltip>
700
+ <DropdownMenuContent align="start" className="w-56">
701
+ <DropdownMenuCheckboxItem
702
+ checked={activeWorkspacePanels.has('script')}
703
+ onCheckedChange={() => handleToggleBottomPanel('script')}
644
704
  >
645
- <MessageSquare className="h-4 w-4" />
646
- </Button>
647
- </TooltipTrigger>
648
- <TooltipContent>BCF Issues</TooltipContent>
649
- </Tooltip>
650
-
651
- {/* IDS Validation Button */}
652
- <Tooltip>
653
- <TooltipTrigger asChild>
654
- <Button
655
- variant={idsPanelVisible ? 'default' : 'ghost'}
656
- size="icon-sm"
657
- onClick={(e) => {
658
- (e.currentTarget as HTMLButtonElement).blur();
659
- if (!idsPanelVisible) {
660
- // Close other right-panel content first, then expand
661
- setBcfPanelVisible(false);
662
- setLensPanelVisible(false);
663
- setRightPanelCollapsed(false);
664
- }
665
- toggleIdsPanel();
666
- }}
667
- className={cn(idsPanelVisible && 'bg-primary text-primary-foreground')}
705
+ <FileCode2 className="h-4 w-4 mr-2" />
706
+ Script Editor
707
+ </DropdownMenuCheckboxItem>
708
+ <DropdownMenuCheckboxItem
709
+ checked={activeWorkspacePanels.has('list')}
710
+ onCheckedChange={() => handleToggleBottomPanel('list')}
668
711
  >
669
- <ClipboardCheck className="h-4 w-4" />
670
- </Button>
671
- </TooltipTrigger>
672
- <TooltipContent>IDS Validation</TooltipContent>
673
- </Tooltip>
674
-
675
- {/* Lists Button */}
676
- <Tooltip>
677
- <TooltipTrigger asChild>
678
- <Button
679
- variant={listPanelVisible ? 'default' : 'ghost'}
680
- size="icon-sm"
681
- onClick={(e) => {
682
- (e.currentTarget as HTMLButtonElement).blur();
683
- // Close script panel (bottom-panel exclusivity)
684
- useViewerStore.getState().setScriptPanelVisible(false);
685
- if (!listPanelVisible) {
686
- setRightPanelCollapsed(false);
687
- }
688
- toggleListPanel();
689
- }}
690
- className={cn(listPanelVisible && 'bg-primary text-primary-foreground')}
712
+ <FileSpreadsheet className="h-4 w-4 mr-2" />
713
+ Lists
714
+ </DropdownMenuCheckboxItem>
715
+ <DropdownMenuSeparator />
716
+ <DropdownMenuCheckboxItem
717
+ checked={activeWorkspacePanels.has('bcf')}
718
+ onCheckedChange={() => handleToggleRightPanel('bcf')}
691
719
  >
692
- <FileSpreadsheet className="h-4 w-4" />
693
- </Button>
694
- </TooltipTrigger>
695
- <TooltipContent>Lists</TooltipContent>
696
- </Tooltip>
720
+ <MessageSquare className="h-4 w-4 mr-2" />
721
+ BCF Issues
722
+ </DropdownMenuCheckboxItem>
723
+ <DropdownMenuCheckboxItem
724
+ checked={activeWorkspacePanels.has('ids')}
725
+ onCheckedChange={() => handleToggleRightPanel('ids')}
726
+ >
727
+ <ClipboardCheck className="h-4 w-4 mr-2" />
728
+ IDS Validation
729
+ </DropdownMenuCheckboxItem>
730
+ <DropdownMenuCheckboxItem
731
+ checked={activeWorkspacePanels.has('lens')}
732
+ onCheckedChange={() => handleToggleRightPanel('lens')}
733
+ >
734
+ <Palette className="h-4 w-4 mr-2" />
735
+ Lens Rules
736
+ </DropdownMenuCheckboxItem>
737
+ </DropdownMenuContent>
738
+ </DropdownMenu>
697
739
 
698
740
  <Separator orientation="vertical" className="h-6 mx-1" />
699
741
 
@@ -821,30 +863,6 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
821
863
  </DropdownMenuContent>
822
864
  </DropdownMenu>
823
865
 
824
- {/* Lens (rule-based filtering) */}
825
- <Tooltip>
826
- <TooltipTrigger asChild>
827
- <Button
828
- variant={lensPanelVisible ? 'default' : 'ghost'}
829
- size="icon-sm"
830
- onClick={(e) => {
831
- (e.currentTarget as HTMLButtonElement).blur();
832
- if (!lensPanelVisible) {
833
- // Close other right-panel content first, then expand
834
- setBcfPanelVisible(false);
835
- setIdsPanelVisible(false);
836
- setRightPanelCollapsed(false);
837
- }
838
- toggleLensPanel();
839
- }}
840
- className={cn(lensPanelVisible && 'bg-primary text-primary-foreground')}
841
- >
842
- <Palette className="h-4 w-4" />
843
- </Button>
844
- </TooltipTrigger>
845
- <TooltipContent>Lens (Color Rules)</TooltipContent>
846
- </Tooltip>
847
-
848
866
  <Separator orientation="vertical" className="h-6 mx-1" />
849
867
 
850
868
  {/* ── Camera & View ── */}