@ifc-lite/viewer 1.23.0 → 1.25.0

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 (109) hide show
  1. package/.turbo/turbo-build.log +34 -31
  2. package/CHANGELOG.md +96 -0
  3. package/dist/assets/{basketViewActivator-Dn_bHUl2.js → basketViewActivator-CU8_toGq.js} +7 -7
  4. package/dist/assets/{bcf-B9SFl84i.js → bcf-DXGDhw56.js} +23 -23
  5. package/dist/assets/{deflate-yMpdCIqk.js → deflate-Bb1_H2Yf.js} +1 -1
  6. package/dist/assets/{exporters-D-BvrNIg.js → exporters-DZhLN0ux.js} +1861 -1658
  7. package/dist/assets/geometry-controller.worker-DQOSYqtw.js +7 -0
  8. package/dist/assets/geometry.worker-B62e03Ao.js +1 -0
  9. package/dist/assets/{geotiff-D1tvcDCb.js → geotiff-y0ZxbRJd.js} +10 -10
  10. package/dist/assets/{ids-DZLs0snJ.js → ids-DruUNtfD.js} +4 -4
  11. package/dist/assets/ifc-lite-Ch2T9pP9.js +7 -0
  12. package/dist/assets/{ifc-lite_bg-DyHX37GQ.wasm → ifc-lite_bg-D7O1WHgP.wasm} +0 -0
  13. package/dist/assets/{ifc-lite_bg-BIryVCXQ.wasm → ifc-lite_bg-iH_07wf8.wasm} +0 -0
  14. package/dist/assets/index-Bws3UAkj.css +1 -0
  15. package/dist/assets/{index-CXSBhkcJ.js → index-Dr88ZlSY.js} +64100 -47030
  16. package/dist/assets/{jpeg-DUMcZp24.js → jpeg-B3_loqFe.js} +1 -1
  17. package/dist/assets/lens-PYsLu_MA.js +1 -0
  18. package/dist/assets/{lerc-IN4uWojP.js → lerc-nkwS8ZUe.js} +1 -1
  19. package/dist/assets/{lzw-Cnw0hH-m.js → lzw-D3cW5Wpg.js} +1 -1
  20. package/dist/assets/{native-bridge-BVf2uzoH.js → native-bridge-BcYJooq8.js} +2 -2
  21. package/dist/assets/{packbits-BskJCwk0.js → packbits-DDN4xzB5.js} +1 -1
  22. package/dist/assets/{parser.worker-BdtkkaGf.js → parser.worker-BW1IMUed.js} +3 -3
  23. package/dist/assets/raw-CoIXstQ-.js +1 -0
  24. package/dist/assets/{sandbox-VLI_y7cl.js → sandbox-DETNEyQb.js} +498 -470
  25. package/dist/assets/{server-client-BLcKaWQB.js → server-client-CmzJOeS7.js} +1 -1
  26. package/dist/assets/{wasm-bridge-BAfZh7YT.js → wasm-bridge-CT7mK9W0.js} +1 -1
  27. package/dist/assets/{webimage-Db2xzze3.js → webimage-CBjgg4up.js} +1 -1
  28. package/dist/assets/{workerHelpers--sAYm9yN.js → workerHelpers-IEQDo8r3.js} +1 -1
  29. package/dist/assets/{zstd-BDToOQyD.js → zstd-C8oQ6qdS.js} +1 -1
  30. package/dist/index.html +8 -8
  31. package/package.json +11 -9
  32. package/src/App.tsx +5 -2
  33. package/src/components/extensions/AuditLogPanel.tsx +259 -0
  34. package/src/components/extensions/BundlePreview.tsx +102 -0
  35. package/src/components/extensions/CapabilityReview.tsx +333 -0
  36. package/src/components/extensions/ExtensionDockHost.tsx +192 -0
  37. package/src/components/extensions/ExtensionToolbarSlot.tsx +106 -0
  38. package/src/components/extensions/ExtensionsPanel.tsx +481 -0
  39. package/src/components/extensions/FlavorDialog.tsx +398 -0
  40. package/src/components/extensions/FlavorImportPreview.tsx +79 -0
  41. package/src/components/extensions/FlavorIndicator.tsx +81 -0
  42. package/src/components/extensions/FlavorListView.tsx +318 -0
  43. package/src/components/extensions/FlavorMergeDialog.tsx +326 -0
  44. package/src/components/extensions/HelpHint.tsx +182 -0
  45. package/src/components/extensions/IdeasPanel.tsx +344 -0
  46. package/src/components/extensions/PlanCard.tsx +227 -0
  47. package/src/components/extensions/PrivacyPanel.tsx +312 -0
  48. package/src/components/extensions/PromoteToolDialog.tsx +313 -0
  49. package/src/components/extensions/RepairQueuePanel.tsx +222 -0
  50. package/src/components/extensions/icon-registry.ts +92 -0
  51. package/src/components/extensions/toast-helpers.ts +49 -0
  52. package/src/components/extensions/widget/WidgetErrorBoundary.tsx +62 -0
  53. package/src/components/extensions/widget/WidgetRenderer.tsx +428 -0
  54. package/src/components/viewer/ChatPanel.tsx +251 -3
  55. package/src/components/viewer/CommandPalette.tsx +74 -4
  56. package/src/components/viewer/Drawing2DCanvas.tsx +178 -1
  57. package/src/components/viewer/EntityContextMenu.tsx +70 -0
  58. package/src/components/viewer/ExportDialog.tsx +9 -1
  59. package/src/components/viewer/KeyboardShortcutsDialog.tsx +21 -6
  60. package/src/components/viewer/LensPanel.tsx +50 -0
  61. package/src/components/viewer/MainToolbar.tsx +170 -87
  62. package/src/components/viewer/ScriptPanel.tsx +105 -1
  63. package/src/components/viewer/Section2DPanel.tsx +58 -2
  64. package/src/components/viewer/StatusBar.tsx +18 -0
  65. package/src/components/viewer/ViewerLayout.tsx +53 -4
  66. package/src/components/viewer/Viewport.tsx +72 -0
  67. package/src/hooks/useActionLogger.test.ts +161 -0
  68. package/src/hooks/useActionLogger.ts +141 -0
  69. package/src/hooks/useForkExtension.ts +51 -0
  70. package/src/hooks/useIfcFederation.ts +7 -1
  71. package/src/hooks/useInstalledExtensions.ts +43 -0
  72. package/src/hooks/usePrivacyDisclosure.ts +48 -0
  73. package/src/hooks/useRunExtensionTests.ts +67 -0
  74. package/src/hooks/useSlotContributions.ts +38 -0
  75. package/src/hooks/useSymbolicAnnotations.test.ts +124 -0
  76. package/src/hooks/useSymbolicAnnotations.ts +776 -0
  77. package/src/lib/desktop-product.ts +7 -1
  78. package/src/lib/lens/adapter.ts +14 -0
  79. package/src/lib/llm/prompt-cache.ts +77 -0
  80. package/src/lib/llm/stream-client.ts +20 -2
  81. package/src/lib/llm/stream-direct.ts +11 -1
  82. package/src/lib/llm/system-prompt.ts +42 -0
  83. package/src/lib/safe-mode.ts +30 -0
  84. package/src/sdk/ExtensionHostProvider.tsx +103 -0
  85. package/src/services/extensions/flavor-service.ts +183 -0
  86. package/src/services/extensions/host-commands.ts +112 -0
  87. package/src/services/extensions/host-installer.ts +289 -0
  88. package/src/services/extensions/host.ts +514 -0
  89. package/src/services/extensions/idb-flavor-storage.test.ts +140 -0
  90. package/src/services/extensions/idb-flavor-storage.ts +241 -0
  91. package/src/services/extensions/idb-log-storage.test.ts +110 -0
  92. package/src/services/extensions/idb-log-storage.ts +171 -0
  93. package/src/services/extensions/idb-storage.ts +228 -0
  94. package/src/services/extensions/runtime-errors.ts +26 -0
  95. package/src/services/extensions/sandbox-factory.ts +217 -0
  96. package/src/store/constants.ts +48 -6
  97. package/src/store/index.ts +6 -1
  98. package/src/store/slices/drawing2DSlice.ts +8 -0
  99. package/src/store/slices/extensionsSlice.ts +90 -0
  100. package/src/store/slices/lensSlice.ts +28 -0
  101. package/src/store/slices/visibilitySlice.test.ts +6 -0
  102. package/src/store/slices/visibilitySlice.ts +17 -8
  103. package/src/store/types.ts +2 -0
  104. package/dist/assets/geometry-controller.worker-Cm5pvyR6.js +0 -7
  105. package/dist/assets/geometry.worker-ClNvXIrj.js +0 -1
  106. package/dist/assets/ifc-lite-BDg0iIbj.js +0 -7
  107. package/dist/assets/index-DS_xJQfP.css +0 -1
  108. package/dist/assets/lens-CpjUdqpw.js +0 -1
  109. package/dist/assets/raw-DzTtEZIY.js +0 -1
@@ -28,7 +28,9 @@ import {
28
28
  Loader2,
29
29
  ArrowDown,
30
30
  Zap,
31
+ Wrench,
31
32
  } from 'lucide-react';
33
+ import { PromoteToolDialog } from '@/components/extensions/PromoteToolDialog';
32
34
  import { Button } from '@/components/ui/button';
33
35
  import { toast } from '@/components/ui/toast';
34
36
  import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -58,6 +60,12 @@ import { ByokKeyModal } from './chat/ByokKeyModal';
58
60
  import { ByokStreamingPill } from './chat/ByokStreamingPill';
59
61
  import type { BYOKProvider } from '@/lib/llm/clipboard-detect';
60
62
  import { useSandbox } from '@/hooks/useSandbox';
63
+ import { useOptionalExtensionHost } from '@/sdk/ExtensionHostProvider';
64
+ import {
65
+ classifyIntent,
66
+ packBundle,
67
+ validateBundleResponse,
68
+ } from '@ifc-lite/extensions';
61
69
 
62
70
  // Environment variable for the proxy URL
63
71
  const PROXY_URL = import.meta.env.VITE_LLM_PROXY_URL as string || '/api/chat';
@@ -203,6 +211,68 @@ interface ChatPanelProps {
203
211
  }
204
212
 
205
213
  export function ChatPanel({ onClose }: ChatPanelProps) {
214
+ const extensionHost = useOptionalExtensionHost();
215
+ /** Most recent chat classification; surfaced in the status bar as authoring telemetry. */
216
+ const [authoringTelemetry, setAuthoringTelemetry] = useState<{
217
+ intent: 'authoring' | 'fork';
218
+ startedAt: number;
219
+ } | null>(null);
220
+ const setPendingAuthoredBundle = useViewerStore((s) => s.setPendingAuthoredBundle);
221
+ const setExtensionsPanelVisible = useViewerStore((s) => s.setExtensionsPanelVisible);
222
+ const setExtensionsRequestedView = useViewerStore((s) => s.setExtensionsRequestedView);
223
+ const setScriptPanelVisible = useViewerStore((s) => s.setScriptPanelVisible);
224
+ const chatToolReady = useViewerStore((s) => s.chatToolReady);
225
+ const setChatToolReady = useViewerStore((s) => s.setChatToolReady);
226
+ /**
227
+ * Local: the inline Promote-to-tool dialog (script path). `source`
228
+ * is snapshotted from the live editor when the dialog opens — the
229
+ * dialog is modal so the script can't change underneath it, and
230
+ * snapshotting avoids re-rendering ChatPanel on every keystroke.
231
+ */
232
+ const [promoteFromChat, setPromoteFromChat] = useState<{ open: boolean; source: string }>({
233
+ open: false,
234
+ source: '',
235
+ });
236
+ /** One-shot guard for the "use the Ideas panel" authoring hint toast. */
237
+ const authoringHintShownRef = useRef(false);
238
+
239
+ /**
240
+ * Try to parse an authoring response as an extension bundle. If it
241
+ * validates, pack it and surface a toast linking to the Extensions
242
+ * panel for the user to review and install. Failures stay silent —
243
+ * the regular chat flow already showed the response.
244
+ */
245
+ const handleAuthoringResponse = useCallback(
246
+ async (fullText: string): Promise<boolean> => {
247
+ try {
248
+ const result = validateBundleResponse(fullText);
249
+ if (!result.ok || !result.manifest || !result.parsed) return false;
250
+ // Assemble a Bundle from the parsed pieces, pack it, hand it
251
+ // to the Extensions panel.
252
+ const files = new Map<string, { path: string; bytes: Uint8Array; text?: string }>();
253
+ const encoder = new TextEncoder();
254
+ const manifestText = JSON.stringify(result.manifest, null, 2);
255
+ files.set('manifest.json', {
256
+ path: 'manifest.json',
257
+ bytes: encoder.encode(manifestText),
258
+ text: manifestText,
259
+ });
260
+ for (const [path, text] of Object.entries(result.parsed.files)) {
261
+ files.set(path, { path, bytes: encoder.encode(text), text });
262
+ }
263
+ const bytes = packBundle({ manifest: result.manifest, files });
264
+ setPendingAuthoredBundle(bytes);
265
+ // Drive the inline CTA card instead of relying on a toast the
266
+ // user scrolls past — the bundle is one click from installed.
267
+ setChatToolReady({ kind: 'bundle', name: result.manifest.name });
268
+ return true;
269
+ } catch (err) {
270
+ console.warn('[ChatPanel] authoring response parse failed:', err);
271
+ return false;
272
+ }
273
+ },
274
+ [setPendingAuthoredBundle, setChatToolReady],
275
+ );
206
276
  const messages = useViewerStore((s) => s.chatMessages);
207
277
  const status = useViewerStore((s) => s.chatStatus);
208
278
  const streamingContent = useViewerStore((s) => s.chatStreamingContent);
@@ -439,6 +509,54 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
439
509
  setChatError('AI assistant is available with Desktop Pro.');
440
510
  return;
441
511
  }
512
+ // Clear any stale post-authoring CTA — this turn re-establishes it
513
+ // on completion if it's another authoring turn.
514
+ setChatToolReady(null);
515
+
516
+ // Classify the prompt for the action log and to nudge the user
517
+ // toward plan-first authoring when appropriate. The classifier is
518
+ // rule-based and content-free metadata only — we record the coarse
519
+ // intent the message looks like, never the prompt text itself.
520
+ const classified = options?.intent === 'repair'
521
+ ? { intent: 'one-shot' as const, confidence: 1, reason: 'repair turn' }
522
+ : classifyIntent(text, {
523
+ hasExistingExtension: false,
524
+ hasLoadedModel: useViewerStore.getState().models.size > 0,
525
+ });
526
+ extensionHost?.emitAction('chat.message', {
527
+ intent: classified.intent === 'out-of-scope' ? 'one-shot' : classified.intent,
528
+ });
529
+ // For high-confidence authoring/fork intents, suggest the
530
+ // plan-first path. Non-blocking — we still send through the
531
+ // existing chat pipeline so the user always gets a response.
532
+ if (
533
+ (classified.intent === 'authoring' || classified.intent === 'fork')
534
+ && classified.confidence >= 0.75
535
+ && !options?.intent
536
+ ) {
537
+ // Show the "use the Ideas panel" hint at most once per chat
538
+ // session — a multi-turn authoring conversation shouldn't
539
+ // re-toast it on every message.
540
+ if (!authoringHintShownRef.current) {
541
+ authoringHintShownRef.current = true;
542
+ toast.info(
543
+ classified.intent === 'fork'
544
+ ? 'Heads up: that reads like a fork. Use the Extensions → Ideas panel for diff-based authoring.'
545
+ : 'Heads up: that reads like an authoring request. The Extensions → Ideas panel offers plan-first authoring.',
546
+ );
547
+ }
548
+ setAuthoringTelemetry({ intent: classified.intent, startedAt: Date.now() });
549
+ } else if (classified.intent !== 'authoring' && classified.intent !== 'fork') {
550
+ setAuthoringTelemetry(null);
551
+ }
552
+
553
+ // Authoring turns write code into the Script Editor — open it so
554
+ // the user watches the tool take shape instead of only seeing
555
+ // chat text. Chat is *part* of the authoring surface, not a
556
+ // detour away from it.
557
+ if (classified.intent === 'authoring' || classified.intent === 'fork') {
558
+ setScriptPanelVisible(true);
559
+ }
442
560
 
443
561
  // Resolve the stream route BEFORE any user-visible side effects (adding
444
562
  // the user message, clearing attachments, setting sending state). If the
@@ -528,6 +646,19 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
528
646
  const fileAttachments = supportsFileAttachments
529
647
  ? collectActiveFileAttachments(allMessages, filtered.accepted)
530
648
  : [];
649
+ // Personal prompt overlay from the active flavor (RFC §06.4) —
650
+ // durable user preferences appended to the system prompt. Best
651
+ // effort: a missing host / flavor / overlay just omits it.
652
+ let personalOverlay: string | undefined;
653
+ if (extensionHost) {
654
+ try {
655
+ const activeFlavor = await extensionHost.flavors.getActive();
656
+ const content = activeFlavor?.promptOverlay?.content?.trim();
657
+ if (content) personalOverlay = content;
658
+ } catch {
659
+ // Overlay is non-essential — never block a chat turn on it.
660
+ }
661
+ }
531
662
  const systemPrompt = buildSystemPrompt(modelContext, fileAttachments, {
532
663
  content: liveScriptContext.content,
533
664
  revision: liveScriptContext.revision,
@@ -535,6 +666,13 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
535
666
  }, {
536
667
  userPrompt: text.trim(),
537
668
  diagnostics: effectiveDiagnostics,
669
+ // Include the extension-authoring contract when the classifier
670
+ // flagged the turn as authoring/fork — the LLM gets the manifest
671
+ // schema, widget DSL, and capability catalogue in context so it
672
+ // can emit a valid bundle.
673
+ includeAuthoringContract:
674
+ classified.intent === 'authoring' || classified.intent === 'fork',
675
+ personalOverlay,
538
676
  });
539
677
  const contextWindow = activeModelInfo?.contextWindow ?? 128_000;
540
678
  const inputBudget = Math.max(
@@ -815,6 +953,43 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
815
953
  }
816
954
  }
817
955
 
956
+ // Authoring loop: when the classifier flagged this turn as
957
+ // 'authoring' or 'fork', the response may contain a bundle in
958
+ // the ifc-extension-* fenced format. If it does, surface the
959
+ // bundle CTA. If it doesn't but code landed in the editor,
960
+ // surface the script CTA so "promote to tool" is one click
961
+ // away — the user never has to hunt for the Promote button.
962
+ //
963
+ // Offer the script-path install CTA whenever the assistant
964
+ // produced runnable code this turn — NOT only on authoring-
965
+ // classified turns. The classifier tags follow-up messages
966
+ // ("yes, use Pset_DoorCommon") as one-shot, but that's often
967
+ // the turn where the final code lands. A one-shot script is
968
+ // just as promotable as an "authored" one.
969
+ const offerScriptInstall = () => {
970
+ if (options?.intent === 'repair') return;
971
+ const wroteCode = responseEditState.appliedAny || responseEditState.fallbackApplied;
972
+ const code = useViewerStore.getState().scriptEditorContent;
973
+ const hasRealCode =
974
+ code.trim().length > 0 && !/Write your BIM script here/.test(code);
975
+ if (wroteCode && hasRealCode) {
976
+ setChatToolReady({ kind: 'script', name: '' });
977
+ }
978
+ };
979
+
980
+ if (
981
+ (classified.intent === 'authoring' || classified.intent === 'fork')
982
+ && !options?.intent
983
+ ) {
984
+ // Authoring-classified turn — try the bundle path first; if
985
+ // no bundle was emitted, fall back to the script CTA.
986
+ void handleAuthoringResponse(fullText).then((bundleFound) => {
987
+ if (!bundleFound) offerScriptInstall();
988
+ });
989
+ } else {
990
+ offerScriptInstall();
991
+ }
992
+
818
993
  commitAssistantTurn();
819
994
  };
820
995
  const handleUsageInfo = (info: UsageInfo) => {
@@ -884,7 +1059,8 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
884
1059
  canUseAiAssistant, status, activeModel, attachments,
885
1060
  addMessage, setChatStatus, updateStreaming, finalizeAssistant,
886
1061
  setChatError, setChatAbortController, clearAttachments, setChatUsage, resizeInput,
887
- buildRepairPromptFromLiveState, triggerAutoRepair, execute,
1062
+ buildRepairPromptFromLiveState, triggerAutoRepair, execute, extensionHost,
1063
+ setChatToolReady, handleAuthoringResponse, setScriptPanelVisible,
888
1064
  ]);
889
1065
 
890
1066
  const handleSend = useCallback(() => {
@@ -994,20 +1170,24 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
994
1170
  if (messages.length <= 2) {
995
1171
  resetScriptEditorForNewChat();
996
1172
  clearMessages();
1173
+ setChatToolReady(null);
1174
+ authoringHintShownRef.current = false;
997
1175
  setInputText('');
998
1176
  setLastFinishReason(null);
999
1177
  } else {
1000
1178
  setShowClearConfirm(true);
1001
1179
  }
1002
- }, [messages.length, clearMessages, resetScriptEditorForNewChat]);
1180
+ }, [messages.length, clearMessages, resetScriptEditorForNewChat, setChatToolReady]);
1003
1181
 
1004
1182
  const confirmClear = useCallback(() => {
1005
1183
  resetScriptEditorForNewChat();
1006
1184
  clearMessages();
1185
+ setChatToolReady(null);
1186
+ authoringHintShownRef.current = false;
1007
1187
  setInputText('');
1008
1188
  setLastFinishReason(null);
1009
1189
  setShowClearConfirm(false);
1010
- }, [clearMessages, resetScriptEditorForNewChat]);
1190
+ }, [clearMessages, resetScriptEditorForNewChat, setChatToolReady]);
1011
1191
 
1012
1192
  // ── File upload (button + drag-drop + paste) ──
1013
1193
  const processFiles = useCallback(async (files: FileList | File[]) => {
@@ -1260,6 +1440,16 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1260
1440
 
1261
1441
  <ModelSelector />
1262
1442
  <ByokStreamingPill modelId={activeModel} className="ml-1" />
1443
+ {authoringTelemetry && (
1444
+ <span
1445
+ className="ml-1 text-[10px] uppercase tracking-wide font-semibold bg-primary/15 text-primary rounded px-1.5 py-0.5"
1446
+ title={`Authoring contract attached (${authoringTelemetry.intent})`}
1447
+ >
1448
+ {authoringTelemetry.intent === 'fork' ? 'Fork' : 'Authoring'}
1449
+ {' · '}
1450
+ {Math.round((Date.now() - authoringTelemetry.startedAt) / 1000)}s
1451
+ </span>
1452
+ )}
1263
1453
  <div className="flex-1" />
1264
1454
 
1265
1455
  <Tooltip>
@@ -1395,6 +1585,56 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1395
1585
  <span className="text-xs">Thinking...</span>
1396
1586
  </div>
1397
1587
  )}
1588
+
1589
+ {/* Post-authoring install CTA — rendered INLINE at the end of
1590
+ the conversation, directly under the generated code, so the
1591
+ "now install it" step is impossible to miss. Highlighted
1592
+ (ring + accent) the moment a workflow is authored. */}
1593
+ {chatToolReady && status === 'idle' && (
1594
+ <div className="mx-3 my-3 rounded-lg border-2 border-primary bg-primary/10 p-3 shadow-sm ring-2 ring-primary/30">
1595
+ <div className="flex items-center gap-2 mb-1">
1596
+ <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground">
1597
+ <Wrench className="h-4 w-4" />
1598
+ </div>
1599
+ <div className="text-sm font-semibold">
1600
+ {chatToolReady.kind === 'bundle'
1601
+ ? `"${chatToolReady.name || 'Your extension'}" is ready`
1602
+ : 'Your tool is ready'}
1603
+ </div>
1604
+ </div>
1605
+ <p className="text-xs text-muted-foreground mb-2.5 pl-9">
1606
+ Last step — turn this into a permanent{' '}
1607
+ <span className="font-medium text-foreground">one-click button in your toolbar</span>.
1608
+ </p>
1609
+ <div className="flex items-center gap-2 pl-9">
1610
+ <Button
1611
+ size="sm"
1612
+ onClick={() => {
1613
+ if (chatToolReady.kind === 'bundle') {
1614
+ setExtensionsRequestedView('installed');
1615
+ setExtensionsPanelVisible(true);
1616
+ } else {
1617
+ setPromoteFromChat({
1618
+ open: true,
1619
+ source: useViewerStore.getState().scriptEditorContent,
1620
+ });
1621
+ }
1622
+ setChatToolReady(null);
1623
+ }}
1624
+ >
1625
+ <Wrench className="mr-1 h-3.5 w-3.5" />
1626
+ {chatToolReady.kind === 'bundle' ? 'Review & install' : 'Install as tool'}
1627
+ </Button>
1628
+ <Button
1629
+ size="sm"
1630
+ variant="ghost"
1631
+ onClick={() => setChatToolReady(null)}
1632
+ >
1633
+ Not now
1634
+ </Button>
1635
+ </div>
1636
+ </div>
1637
+ )}
1398
1638
  </div>
1399
1639
 
1400
1640
  {/* Scroll to bottom button */}
@@ -1469,6 +1709,14 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1469
1709
  </div>
1470
1710
  )}
1471
1711
 
1712
+ {/* Promote-to-tool dialog, opened from the post-authoring CTA.
1713
+ `source` was snapshotted from the editor at open time. */}
1714
+ <PromoteToolDialog
1715
+ open={promoteFromChat.open}
1716
+ source={promoteFromChat.source}
1717
+ onClose={() => setPromoteFromChat((p) => ({ ...p, open: false }))}
1718
+ />
1719
+
1472
1720
  {/* Input area */}
1473
1721
  <div className="shrink-0 border-t p-2">
1474
1722
  <div className="flex items-end gap-1.5">
@@ -44,6 +44,7 @@ import {
44
44
  ClipboardCheck,
45
45
  FileSpreadsheet,
46
46
  Palette,
47
+ Puzzle,
47
48
  Camera,
48
49
  Download,
49
50
  FileJson,
@@ -58,6 +59,7 @@ import {
58
59
  Sparkles,
59
60
  Eraser,
60
61
  MapPin,
62
+ Pencil,
61
63
  PenLine,
62
64
  Slice,
63
65
  Layers3,
@@ -76,11 +78,17 @@ import {
76
78
  executeBasketClear,
77
79
  } from '@/store/basket/basketCommands';
78
80
  import { useSandbox } from '@/hooks/useSandbox';
81
+ import { useSlotContributions } from '@/hooks/useSlotContributions';
82
+ import { useOptionalExtensionHost } from '@/sdk/ExtensionHostProvider';
83
+ import { resolveExtensionIcon } from '@/components/extensions/icon-registry';
84
+ import type { CommandContribution } from '@ifc-lite/extensions';
85
+ import { toast as paletteToast } from '@/components/ui/toast';
79
86
  import { SCRIPT_TEMPLATES } from '@/lib/scripts/templates';
80
87
  import { GLTFExporter, CSVExporter } from '@ifc-lite/export';
81
88
  import { getRecentFiles, formatFileSize, getCachedFile } from '@/lib/recent-files';
82
89
  import type { RecentFileEntry } from '@/lib/recent-files';
83
90
  import { closeActiveAnalysisExtension } from '@/services/analysis-extensions';
91
+ import { describeRunCommandError } from '@/services/extensions/runtime-errors';
84
92
 
85
93
  // ── Types ──────────────────────────────────────────────────────────────
86
94
 
@@ -93,7 +101,8 @@ type Category =
93
101
  | 'Panels'
94
102
  | 'Export'
95
103
  | 'Automation'
96
- | 'Preferences';
104
+ | 'Preferences'
105
+ | 'Extensions';
97
106
 
98
107
  interface Command {
99
108
  id: string;
@@ -190,14 +199,16 @@ function downloadBlob(data: BlobPart, name: string, mime: string) {
190
199
  URL.revokeObjectURL(url);
191
200
  }
192
201
 
193
- /** Exclusively activate a right-panel content panel (BCF / IDS / Lens).
202
+ /** Exclusively activate a right-panel content panel (BCF / IDS / Lens / Extensions).
194
203
  * Closes all others first so the if-else chain in ViewerLayout renders it.
195
204
  * If the target is already active, closes it (back to Properties). */
196
- function activateRightPanel(panel: 'bcf' | 'ids' | 'lens') {
205
+
206
+ function activateRightPanel(panel: 'bcf' | 'ids' | 'lens' | 'extensions') {
197
207
  const s = useViewerStore.getState();
198
208
  const isActive =
199
209
  panel === 'bcf' ? s.bcfPanelVisible :
200
210
  panel === 'ids' ? s.idsPanelVisible :
211
+ panel === 'extensions' ? s.extensionsPanelVisible :
201
212
  s.lensPanelVisible;
202
213
 
203
214
  closeActiveAnalysisExtension();
@@ -206,12 +217,14 @@ function activateRightPanel(panel: 'bcf' | 'ids' | 'lens') {
206
217
  s.setBcfPanelVisible(false);
207
218
  s.setIdsPanelVisible(false);
208
219
  s.setLensPanelVisible(false);
220
+ s.setExtensionsPanelVisible(false);
209
221
 
210
222
  if (!isActive) {
211
223
  // Open the target, expand right panel
212
224
  s.setRightPanelCollapsed(false);
213
225
  if (panel === 'bcf') s.setBcfPanelVisible(true);
214
226
  else if (panel === 'ids') s.setIdsPanelVisible(true);
227
+ else if (panel === 'extensions') s.setExtensionsPanelVisible(true);
215
228
  else s.setLensPanelVisible(true);
216
229
  }
217
230
  // If was active → all closed → falls back to Properties
@@ -259,6 +272,8 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
259
272
  const navigatedByKeyboard = useRef(false);
260
273
 
261
274
  const { execute } = useSandbox();
275
+ const extensionCommands = useSlotContributions<CommandContribution>('commandPalette');
276
+ const extensionHost = useOptionalExtensionHost();
262
277
 
263
278
  useEffect(() => {
264
279
  if (open) {
@@ -395,6 +410,8 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
395
410
  action: () => { useViewerStore.getState().toggleTypeVisibility('openings'); } },
396
411
  { id: 'vis:site', label: 'Site', keywords: 'IfcSite terrain show hide', category: 'Visibility', icon: Building2,
397
412
  action: () => { useViewerStore.getState().toggleTypeVisibility('site'); } },
413
+ { id: 'vis:ifcAnnotations', label: 'Annotations & Grids', keywords: 'IfcAnnotation IfcGrid IfcGridAxis 2d drawing symbols text dimension grid axis bubble tag show hide', category: 'Visibility', icon: Pencil,
414
+ action: () => { useViewerStore.getState().toggleTypeVisibility('ifcAnnotations'); } },
398
415
  { id: 'vis:reset-colors', label: 'Reset Colors', keywords: 'clear color override', category: 'Visibility', icon: Palette,
399
416
  action: () => { execute('bim.viewer.resetColors()\nconsole.log("Colors reset")'); } },
400
417
  );
@@ -417,6 +434,27 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
417
434
  action: () => { activateBottomPanel('gantt'); } },
418
435
  { id: 'panel:lens', label: 'Lens Rules', keywords: 'color filter highlight', category: 'Panels', icon: Palette,
419
436
  action: () => { activateRightPanel('lens'); } },
437
+ { id: 'panel:extensions', label: 'Extensions', keywords: 'extension plugin install manage iflx', category: 'Panels', icon: Puzzle,
438
+ action: () => { activateRightPanel('extensions'); } },
439
+ // ── Customization entry points — first-class discoverability
440
+ // for new users who don't know extensions/flavors exist. Each
441
+ // routes to the right surface and pre-seeds context where
442
+ // helpful (e.g. open Ideas tab with the empty-plan flow).
443
+ { id: 'extensions:author', label: 'Author an extension…',
444
+ keywords: 'create new build plan chat ai extension generate',
445
+ category: 'Tools', icon: Sparkles,
446
+ action: () => {
447
+ const s = useViewerStore.getState();
448
+ activateRightPanel('extensions');
449
+ s.setExtensionsRequestedView('ideas');
450
+ s.setIdeasOpenEmptyPlan(true);
451
+ } },
452
+ { id: 'extensions:flavors', label: 'Manage flavors…',
453
+ keywords: 'flavor profile switch export import merge customization',
454
+ category: 'Panels', icon: Palette,
455
+ action: () => {
456
+ useViewerStore.getState().setFlavorDialogRequested(true);
457
+ } },
420
458
  );
421
459
 
422
460
  // ── Schedule / 4D (Tools) ─────────────────────────────
@@ -503,8 +541,40 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
503
541
  action: () => { useViewerStore.getState().toggleHoverTooltips(); } },
504
542
  );
505
543
 
544
+ // ── Extension contributions ──
545
+ // Surfaced under the "Extensions" category. Clicking dispatches
546
+ // through the activation event so the runtime executes the
547
+ // bundle's command handler (or surfaces the failure clearly).
548
+ for (const contribution of extensionCommands) {
549
+ const payload = contribution.payload;
550
+ if (!payload?.id || !payload.title) continue;
551
+ c.push({
552
+ id: `ext:${payload.id}`,
553
+ label: payload.title,
554
+ keywords: `${payload.id} ${payload.paletteCategory ?? ''} extension`,
555
+ category: 'Extensions',
556
+ // `resolveExtensionIcon` is the shared icon registry the
557
+ // picker writes against, so the icon the user chose is the
558
+ // icon shown in the palette.
559
+ icon: resolveExtensionIcon(payload.icon),
560
+ detail: payload.paletteCategory,
561
+ action: () => {
562
+ if (!extensionHost) return;
563
+ // Fire the activation event first so onCommand:<id>-subscribed
564
+ // extensions wake up, then invoke the command handler. The
565
+ // runtime dedupes activations.
566
+ void extensionHost.dispatcher
567
+ .fire(`onCommand:${payload.id}` as `onCommand:${string}`)
568
+ .then(() => extensionHost.runCommand(payload.id))
569
+ .catch((err) => {
570
+ paletteToast.error(describeRunCommandError(payload.id, err));
571
+ });
572
+ },
573
+ });
574
+ }
575
+
506
576
  return c;
507
- }, [execute, recentFiles]);
577
+ }, [execute, recentFiles, extensionCommands, extensionHost]);
508
578
 
509
579
  // ── Search: score, filter, sort ──
510
580
  // When searching, results are FLAT sorted by relevance — no category grouping.