@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
@@ -0,0 +1,313 @@
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
+ * `PromoteToolDialog` — turn a saved script into a persistent tool.
7
+ *
8
+ * Reads the script source, infers a minimal capability set via
9
+ * `inferCapabilities`, lets the user pick a name / category / icon /
10
+ * hotkey, then routes through `CapabilityReview` for the security
11
+ * gate before installing.
12
+ *
13
+ * Spec: docs/architecture/ai-customization/01-extension-model.md +
14
+ * `09-implementation-plan.md` P1.T11 / T12.
15
+ */
16
+
17
+ import { useMemo, useState } from 'react';
18
+ import { ChevronRight, Sparkles, X } from 'lucide-react';
19
+ import {
20
+ inferCapabilities,
21
+ packBundle,
22
+ sha256Hex,
23
+ type Bundle,
24
+ type ExtensionManifest,
25
+ } from '@ifc-lite/extensions';
26
+ import { ICON_CHOICES } from './icon-registry';
27
+ import { cn } from '@/lib/utils';
28
+ import {
29
+ Dialog,
30
+ DialogContent,
31
+ DialogDescription,
32
+ DialogFooter,
33
+ DialogHeader,
34
+ DialogTitle,
35
+ } from '@/components/ui/dialog';
36
+ import { Button } from '@/components/ui/button';
37
+ import { Input } from '@/components/ui/input';
38
+ import { Label } from '@/components/ui/label';
39
+ import { CapabilityReview } from './CapabilityReview';
40
+ import { useExtensionHost } from '@/sdk/ExtensionHostProvider';
41
+ import type { ExtensionInstallSummary } from '@/services/extensions/host';
42
+ import { ExtensionInstallError } from '@/services/extensions/host';
43
+ import { toast } from '@/components/ui/toast';
44
+
45
+ interface PromoteToolDialogProps {
46
+ open: boolean;
47
+ /** The script source the user is promoting. */
48
+ source: string;
49
+ /** Initial label the user can edit. */
50
+ initialName?: string;
51
+ onClose(): void;
52
+ }
53
+
54
+ // Icon choices live in ./icon-registry — a single source of truth
55
+ // shared with ExtensionToolbarSlot so the icon the user picks here
56
+ // is the icon that lands in the menubar.
57
+
58
+ export function PromoteToolDialog({ open, source, initialName, onClose }: PromoteToolDialogProps) {
59
+ const host = useExtensionHost();
60
+ const [name, setName] = useState(initialName ?? 'My tool');
61
+ const [hotkey, setHotkey] = useState('');
62
+ const [icon, setIcon] = useState<string>('sparkles');
63
+ const [pending, setPending] = useState<{ bytes: Uint8Array; summary: ExtensionInstallSummary } | null>(null);
64
+ const [busy, setBusy] = useState(false);
65
+
66
+ const inference = useMemo(() => inferCapabilities(source), [source]);
67
+
68
+ const handlePromote = async () => {
69
+ if (busy) return;
70
+ setBusy(true);
71
+ try {
72
+ const { bytes, summary } = await synthesiseBundle({
73
+ name,
74
+ source,
75
+ hotkey,
76
+ icon,
77
+ capabilities: inference.capabilities,
78
+ });
79
+ setPending({ bytes, summary });
80
+ } catch (err) {
81
+ toast.error(`Failed to package tool: ${err instanceof Error ? err.message : String(err)}`);
82
+ } finally {
83
+ setBusy(false);
84
+ }
85
+ };
86
+
87
+ const handleApprove = async (grants: string[]) => {
88
+ if (!pending || busy) return;
89
+ setBusy(true);
90
+ try {
91
+ const status = await host.installFromBytes(pending.bytes, grants);
92
+ // Point the user at the payoff — the actual button. The
93
+ // synthesised manifest puts the command on `toolbar.right`,
94
+ // so it shows up as an icon button at the top-right of the
95
+ // toolbar. Mention the hotkey too if they set one.
96
+ const where = hotkey.trim()
97
+ ? `It's now a button in the toolbar (top-right) — or press ${hotkey.trim()}.`
98
+ : `It's now a button in the toolbar (top-right).`;
99
+ toast.success(`Installed "${name}". ${where}`);
100
+ setPending(null);
101
+ onClose();
102
+ void status;
103
+ } catch (err) {
104
+ if (err instanceof ExtensionInstallError) {
105
+ toast.error(`Install rejected: ${err.validationErrors[0]?.message ?? err.message}`);
106
+ } else {
107
+ toast.error(`Install failed: ${err instanceof Error ? err.message : String(err)}`);
108
+ }
109
+ } finally {
110
+ setBusy(false);
111
+ }
112
+ };
113
+
114
+ if (pending) {
115
+ return (
116
+ <CapabilityReview
117
+ open
118
+ summary={pending.summary}
119
+ onApprove={handleApprove}
120
+ onCancel={() => setPending(null)}
121
+ />
122
+ );
123
+ }
124
+
125
+ return (
126
+ <Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
127
+ <DialogContent className="max-w-lg">
128
+ <DialogHeader>
129
+ <div className="flex items-center gap-2">
130
+ <Sparkles className="h-5 w-5 text-primary" />
131
+ <DialogTitle>Promote script to a tool</DialogTitle>
132
+ </div>
133
+ <DialogDescription>
134
+ Turn this saved script into a persistent, sandboxed tool. The tool
135
+ appears in the command palette and on the toolbar. It runs in the
136
+ same sandbox as your scripts with only the capabilities you grant.
137
+ </DialogDescription>
138
+ </DialogHeader>
139
+
140
+ <div className="space-y-4">
141
+ <div className="space-y-2">
142
+ <Label htmlFor="tool-name">Name</Label>
143
+ <Input
144
+ id="tool-name"
145
+ value={name}
146
+ onChange={(e) => setName(e.target.value)}
147
+ placeholder="Fire-rating report"
148
+ />
149
+ </div>
150
+ <div className="space-y-2">
151
+ <Label htmlFor="tool-hotkey">Hotkey (optional)</Label>
152
+ <Input
153
+ id="tool-hotkey"
154
+ value={hotkey}
155
+ onChange={(e) => setHotkey(e.target.value)}
156
+ placeholder="Ctrl+Alt+F"
157
+ />
158
+ </div>
159
+
160
+ <div className="space-y-2">
161
+ <Label>Icon</Label>
162
+ <div
163
+ role="radiogroup"
164
+ aria-label="Pick a toolbar icon"
165
+ className="grid grid-cols-10 gap-1.5 p-2 rounded-md border bg-muted/40"
166
+ >
167
+ {ICON_CHOICES.map(({ key, Icon, label }) => {
168
+ const selected = icon === key;
169
+ return (
170
+ <button
171
+ key={key}
172
+ type="button"
173
+ role="radio"
174
+ aria-checked={selected}
175
+ aria-label={label}
176
+ title={label}
177
+ onClick={() => setIcon(key)}
178
+ className={cn(
179
+ 'flex items-center justify-center h-8 w-8 rounded border transition-colors',
180
+ selected
181
+ ? 'border-primary bg-primary/10 text-primary'
182
+ : 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground',
183
+ )}
184
+ >
185
+ <Icon className="h-4 w-4" />
186
+ </button>
187
+ );
188
+ })}
189
+ </div>
190
+ </div>
191
+
192
+ <div className="rounded-md border bg-muted/40 p-3">
193
+ <div className="text-xs font-semibold mb-2">Inferred capabilities</div>
194
+ {inference.capabilities.length === 0 ? (
195
+ <div className="text-xs text-muted-foreground">
196
+ No `bim.*` calls detected. The tool will request only
197
+ <code className="font-mono ml-1">model.read</code>.
198
+ </div>
199
+ ) : (
200
+ <ul className="space-y-1">
201
+ {inference.capabilities.map((cap) => (
202
+ <li key={cap} className="text-xs flex items-center gap-2">
203
+ <ChevronRight className="h-3 w-3 text-muted-foreground" />
204
+ <code className="font-mono">{cap}</code>
205
+ </li>
206
+ ))}
207
+ </ul>
208
+ )}
209
+ {inference.observations.some((o) => o.unknown) && (
210
+ <div className="mt-2 text-[11px] text-amber-600 dark:text-amber-400">
211
+ Unknown `bim.*` calls detected — review the source before approving.
212
+ </div>
213
+ )}
214
+ {inference.parseErrors.length > 0 && (
215
+ <div className="mt-2 text-[11px] text-destructive">
216
+ Script does not parse cleanly — promotion may fail.
217
+ </div>
218
+ )}
219
+ </div>
220
+ </div>
221
+
222
+ <DialogFooter>
223
+ <Button variant="ghost" onClick={onClose}>
224
+ <X className="mr-1 h-4 w-4" />
225
+ Cancel
226
+ </Button>
227
+ <Button onClick={handlePromote} disabled={busy || name.trim().length === 0}>
228
+ <Sparkles className="mr-1 h-4 w-4" />
229
+ Review & install
230
+ </Button>
231
+ </DialogFooter>
232
+ </DialogContent>
233
+ </Dialog>
234
+ );
235
+ }
236
+
237
+ interface SynthArgs {
238
+ name: string;
239
+ source: string;
240
+ hotkey: string;
241
+ icon: string;
242
+ capabilities: string[];
243
+ }
244
+
245
+ async function synthesiseBundle(
246
+ args: SynthArgs,
247
+ ): Promise<{ bytes: Uint8Array; summary: ExtensionInstallSummary }> {
248
+ const slug = slugFromName(args.name);
249
+ const id = `com.local.tools.${slug}`;
250
+ const commandId = `${id}.run`;
251
+ const caps = args.capabilities.length > 0 ? args.capabilities : ['model.read'];
252
+ // Engine range MUST match the running SDK or the loader skips the
253
+ // bundle on install — the tool then never appears as a toolbar
254
+ // button. Pin to ">=<currentSdk>" using the live app version
255
+ // instead of a hardcoded guess.
256
+ const sdkVersion =
257
+ typeof __APP_VERSION__ === 'string' && __APP_VERSION__.length > 0
258
+ ? __APP_VERSION__
259
+ : '1.0.0';
260
+ const manifest: ExtensionManifest = {
261
+ manifestVersion: 1,
262
+ id,
263
+ name: args.name,
264
+ description: `Promoted from a saved script.`,
265
+ version: '0.1.0',
266
+ engines: { ifcLiteSdk: `>=${sdkVersion}` },
267
+ capabilities: caps,
268
+ activation: [`onCommand:${commandId}`],
269
+ contributes: {
270
+ commands: [{ id: commandId, title: args.name, icon: args.icon }],
271
+ toolbar: [{ command: commandId, slot: 'toolbar.right' }],
272
+ ...(args.hotkey
273
+ ? { keybindings: [{ command: commandId, key: args.hotkey.trim() }] }
274
+ : {}),
275
+ },
276
+ entry: { commands: { [commandId]: 'src/commands/run.js' } },
277
+ };
278
+ const handler = wrapScriptAsCommand(args.source);
279
+ const files = new Map<string, { path: string; bytes: Uint8Array; text?: string }>();
280
+ const manifestText = `${JSON.stringify(manifest, null, 2)}\n`;
281
+ files.set('manifest.json', { path: 'manifest.json', bytes: new TextEncoder().encode(manifestText), text: manifestText });
282
+ files.set('src/commands/run.js', { path: 'src/commands/run.js', bytes: new TextEncoder().encode(handler), text: handler });
283
+ const bundle: Bundle = { manifest, files, source: { kind: 'memory' } };
284
+ const bytes = packBundle(bundle);
285
+ const hash = await sha256Hex(bytes);
286
+ return {
287
+ bytes,
288
+ summary: { id, version: '0.1.0', bundleHash: hash, capabilities: caps, bundle, signed: false },
289
+ };
290
+ }
291
+
292
+ function slugFromName(name: string): string {
293
+ return name
294
+ .toLowerCase()
295
+ .replace(/[^a-z0-9]+/g, '-')
296
+ .replace(/^-+|-+$/g, '')
297
+ || `tool-${Math.random().toString(36).slice(2, 8)}`;
298
+ }
299
+
300
+ function wrapScriptAsCommand(source: string): string {
301
+ // `run` is intentionally NOT async. The bim.* SDK is fully
302
+ // synchronous, and a promoted script is plain top-level code. An
303
+ // `async` wrapper would return a pending promise whose body only
304
+ // runs once the QuickJS job queue is drained — so the tool would
305
+ // silently do nothing (0 logs, instant "success"). A sync function
306
+ // runs to completion inside evalCode, exactly like the one-shot.
307
+ return `/* Promoted from a saved script. */
308
+ function run(ctx) {
309
+ const bim = ctx.bim;
310
+ ${source.trim().split('\n').map((line) => ` ${line}`).join('\n')}
311
+ }
312
+ `;
313
+ }
@@ -0,0 +1,222 @@
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
+ * `RepairQueuePanel` — surface SDK-update revalidation results.
7
+ *
8
+ * Runs `ExtensionHostService.revalidateForSdk(currentSdk)` on mount,
9
+ * lists each extension with compatibility status + test outcome, and
10
+ * lets the user trigger an AI-assisted repair for items in the
11
+ * `needsRepair` bucket. Repair routing seeds the chat with a fix
12
+ * prompt; the chat panel then drives the regular authoring loop.
13
+ *
14
+ * Spec: docs/architecture/ai-customization/06-self-improvement.md §5.
15
+ */
16
+
17
+ import { useCallback, useState } from 'react';
18
+ import { CheckCircle2, RefreshCcw, ShieldAlert, Wrench, X } from 'lucide-react';
19
+ import type { RevalidationItem, RevalidationSummary } from '@ifc-lite/extensions';
20
+ import { Button } from '@/components/ui/button';
21
+ import { ScrollArea } from '@/components/ui/scroll-area';
22
+ import { useExtensionHost } from '@/sdk/ExtensionHostProvider';
23
+ import { useViewerStore } from '@/store';
24
+ import { toast } from '@/components/ui/toast';
25
+ import { HelpHint } from './HelpHint';
26
+
27
+ interface RepairQueuePanelProps {
28
+ /** SDK version to revalidate against. Defaults to APP_VERSION. */
29
+ sdkVersion?: string;
30
+ onClose?: () => void;
31
+ }
32
+
33
+ export function RepairQueuePanel({ sdkVersion, onClose }: RepairQueuePanelProps) {
34
+ const host = useExtensionHost();
35
+ const queueChatPrompt = useViewerStore((s) => s.queueChatPrompt);
36
+ const setChatPanelVisible = useViewerStore((s) => s.setChatPanelVisible);
37
+ const setScriptPanelVisible = useViewerStore((s) => s.setScriptPanelVisible);
38
+ const [summary, setSummary] = useState<RevalidationSummary | undefined>();
39
+ const [busy, setBusy] = useState(false);
40
+ // SDK version comes from the Vite-injected __APP_VERSION__ define.
41
+ // We deliberately do NOT fall back to '0.0.0' on miss — a fake low
42
+ // version would flag every range as outdated and produce a wave of
43
+ // false-positive repair prompts.
44
+ const version =
45
+ sdkVersion
46
+ ?? (typeof __APP_VERSION__ === 'string' && __APP_VERSION__.length > 0 ? __APP_VERSION__ : undefined);
47
+
48
+ const run = useCallback(async () => {
49
+ if (!version) return;
50
+ setBusy(true);
51
+ try {
52
+ const next = await host.revalidateForSdk(version);
53
+ setSummary(next);
54
+ } catch (err) {
55
+ toast.error(`Revalidation failed: ${err instanceof Error ? err.message : String(err)}`);
56
+ } finally {
57
+ setBusy(false);
58
+ }
59
+ }, [host, version]);
60
+
61
+ // Eager-run is gated behind an explicit user click. Mounting alone
62
+ // shouldn't spin up sandboxes for every installed extension — that
63
+ // can be expensive when many extensions are installed and outdated.
64
+
65
+ const repairItem = (item: RevalidationItem) => {
66
+ if (!version) return;
67
+ queueChatPrompt(buildRepairPrompt(item, version));
68
+ setChatPanelVisible(true);
69
+ setScriptPanelVisible(true);
70
+ toast.success(`Routing repair for ${item.extensionId}…`);
71
+ };
72
+
73
+ return (
74
+ <div className="flex flex-col h-full">
75
+ <div className="flex items-center justify-between border-b px-4 py-3">
76
+ <div className="flex items-center gap-2">
77
+ <Wrench className="h-4 w-4" />
78
+ <h2 className="text-sm font-semibold">Repair queue</h2>
79
+ {summary && (
80
+ <span className="text-[11px] text-muted-foreground">
81
+ SDK {summary.sdk} · {summary.needsRepair.length} need fixing
82
+ </span>
83
+ )}
84
+ <HelpHint label="Repair queue">
85
+ <p>
86
+ When the viewer SDK bumps, extensions whose declared
87
+ <code> engines.ifcLiteSdk</code> range no longer matches
88
+ are flagged here.
89
+ </p>
90
+ <p>
91
+ <strong>Run check</strong> spins up a sandbox for each
92
+ outdated extension and runs its manifest tests against
93
+ the new SDK. Failing tests get a <strong>Repair</strong>
94
+ button that seeds chat with a fix prompt — the AI
95
+ authoring loop produces the patched bundle.
96
+ </p>
97
+ <p>
98
+ Doesn't run automatically (each check spawns sandboxes).
99
+ </p>
100
+ </HelpHint>
101
+ </div>
102
+ <div className="flex items-center gap-1">
103
+ <Button size="sm" variant="ghost" onClick={() => void run()} disabled={busy || !version}>
104
+ <RefreshCcw className="mr-1 h-3.5 w-3.5" />
105
+ Re-run
106
+ </Button>
107
+ {onClose && (
108
+ <Button size="icon" variant="ghost" onClick={onClose} aria-label="Close">
109
+ <X className="h-3.5 w-3.5" />
110
+ </Button>
111
+ )}
112
+ </div>
113
+ </div>
114
+
115
+ <ScrollArea className="flex-1">
116
+ {!version ? (
117
+ <div className="px-6 py-12 text-center text-sm text-rose-600 dark:text-rose-400">
118
+ SDK version unknown — cannot revalidate. Set <code className="font-mono">__APP_VERSION__</code> via Vite define.
119
+ </div>
120
+ ) : !summary ? (
121
+ <div className="px-6 py-12 text-center text-sm text-muted-foreground space-y-3">
122
+ <div>No compatibility check has run for this session.</div>
123
+ <Button size="sm" variant="outline" onClick={() => void run()} disabled={busy}>
124
+ <RefreshCcw className="mr-1 h-3.5 w-3.5" />
125
+ Run check
126
+ </Button>
127
+ </div>
128
+ ) : summary.items.length === 0 ? (
129
+ <div className="flex flex-col items-center justify-center gap-2 px-6 py-12 text-center">
130
+ <CheckCircle2 className="h-8 w-8 text-emerald-500" />
131
+ <div className="text-sm font-medium">No installed extensions</div>
132
+ </div>
133
+ ) : (
134
+ <ul className="divide-y">
135
+ {summary.items.map((item) => (
136
+ <RepairRow key={item.extensionId} item={item} onRepair={() => repairItem(item)} />
137
+ ))}
138
+ </ul>
139
+ )}
140
+ </ScrollArea>
141
+ </div>
142
+ );
143
+ }
144
+
145
+ function RepairRow({
146
+ item,
147
+ onRepair,
148
+ }: {
149
+ item: RevalidationItem;
150
+ onRepair: () => void;
151
+ }) {
152
+ const tone =
153
+ item.outcome === 'pass'
154
+ ? 'text-emerald-600 dark:text-emerald-400'
155
+ : item.outcome === 'skipped'
156
+ ? 'text-muted-foreground'
157
+ : 'text-rose-600 dark:text-rose-400';
158
+ return (
159
+ <li className="px-4 py-3">
160
+ <div className="flex items-start justify-between gap-3">
161
+ <div className="flex-1 min-w-0">
162
+ <div className="flex items-center gap-2">
163
+ {item.outcome === 'pass' ? (
164
+ <CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
165
+ ) : (
166
+ <ShieldAlert className={`h-3.5 w-3.5 ${tone}`} />
167
+ )}
168
+ <code className="text-xs font-mono break-all">{item.extensionId}</code>
169
+ <span className={`text-[10px] uppercase tracking-wide font-semibold ${tone}`}>
170
+ {item.outcome}
171
+ </span>
172
+ </div>
173
+ <div className="mt-1 text-[11px] text-muted-foreground">
174
+ Range <code className="font-mono">{item.compatibility.declared}</code> · {item.compatibility.reason}
175
+ </div>
176
+ {item.tests && item.tests.failed > 0 && (
177
+ <div className="mt-1 text-[11px] text-rose-600 dark:text-rose-400">
178
+ {item.tests.failed} test{item.tests.failed === 1 ? '' : 's'} failed:
179
+ {' '}
180
+ {item.tests.results.find((r) => !r.passed)?.error}
181
+ </div>
182
+ )}
183
+ </div>
184
+ {itemNeedsRepair(item) && (
185
+ <Button size="sm" variant="outline" onClick={onRepair}>
186
+ <Wrench className="mr-1 h-3.5 w-3.5" />
187
+ Repair
188
+ </Button>
189
+ )}
190
+ </div>
191
+ </li>
192
+ );
193
+ }
194
+
195
+ /**
196
+ * Whether a row should show a Repair button. Mirrors the
197
+ * `needsRepair` filter in `revalidateAgainstSdk` exactly — a failed
198
+ * test OR a skipped extension whose declared range is outdated — so
199
+ * the header count and the actionable rows never disagree.
200
+ */
201
+ function itemNeedsRepair(item: RevalidationItem): boolean {
202
+ return (
203
+ item.outcome === 'fail'
204
+ || (item.outcome === 'skipped' && item.compatibility.status === 'outdated')
205
+ );
206
+ }
207
+
208
+ function buildRepairPrompt(item: RevalidationItem, sdk: string): string {
209
+ const failures = item.tests?.results.filter((r) => !r.passed) ?? [];
210
+ return [
211
+ `Repair extension ${item.extensionId} for SDK ${sdk}.`,
212
+ '',
213
+ `The declared engine range was \`${item.compatibility.declared}\` (status: ${item.compatibility.status}).`,
214
+ '',
215
+ failures.length > 0
216
+ ? `${failures.length} test${failures.length === 1 ? '' : 's'} failed under the new SDK:`
217
+ : 'No failing tests were captured; revalidation flagged compatibility only.',
218
+ ...failures.map((f) => `- ${f.name}: ${f.error ?? 'unknown'}`),
219
+ '',
220
+ 'Update the bundle so tests pass against the new SDK while keeping the same user-visible behaviour. Bump engines.ifcLiteSdk as appropriate.',
221
+ ].join('\n');
222
+ }
@@ -0,0 +1,92 @@
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
+ * Shared icon registry for extension commands.
7
+ *
8
+ * The picker (`PromoteToolDialog`) and the renderers
9
+ * (`ExtensionToolbarSlot`, command palette, context menu) all read
10
+ * from this single source so the icon a user picks actually shows up
11
+ * everywhere their command appears.
12
+ *
13
+ * Manifests record only the string key — never a component reference —
14
+ * so the bundle stays serialisable and portable.
15
+ */
16
+
17
+ import {
18
+ AlertTriangle, Beaker, Box, Brain, Calculator, Camera, CheckCircle2,
19
+ ClipboardList, Download, Eye, FileBarChart, FileText, Filter, Flame,
20
+ Gauge, Hammer, Layers, Lightbulb, Maximize2, Palette, Ruler, ScanSearch,
21
+ Scissors, Settings, Shield, Sparkles, Tag, Target, Wrench,
22
+ type LucideIcon,
23
+ } from 'lucide-react';
24
+
25
+ export interface IconChoice {
26
+ key: string;
27
+ Icon: LucideIcon;
28
+ label: string;
29
+ }
30
+
31
+ /**
32
+ * Curated lucide subset (~29). Keys are lowercase-hyphenated so they
33
+ * survive JSON round-trips in `manifest.contributes.commands[].icon`.
34
+ */
35
+ export const ICON_CHOICES: readonly IconChoice[] = [
36
+ { key: 'sparkles', Icon: Sparkles, label: 'AI / magic' },
37
+ { key: 'wrench', Icon: Wrench, label: 'Tool' },
38
+ { key: 'hammer', Icon: Hammer, label: 'Build' },
39
+ { key: 'palette', Icon: Palette, label: 'Color' },
40
+ { key: 'eye', Icon: Eye, label: 'View' },
41
+ { key: 'filter', Icon: Filter, label: 'Filter' },
42
+ { key: 'shield', Icon: Shield, label: 'Compliance' },
43
+ { key: 'flame', Icon: Flame, label: 'Fire rating' },
44
+ { key: 'ruler', Icon: Ruler, label: 'Measure' },
45
+ { key: 'calculator', Icon: Calculator, label: 'Quantity' },
46
+ { key: 'box', Icon: Box, label: 'Element' },
47
+ { key: 'layers', Icon: Layers, label: 'Storey' },
48
+ { key: 'tag', Icon: Tag, label: 'Classification' },
49
+ { key: 'target', Icon: Target, label: 'Isolate' },
50
+ { key: 'scan-search', Icon: ScanSearch, label: 'Audit' },
51
+ { key: 'clipboard-list', Icon: ClipboardList, label: 'Schedule' },
52
+ { key: 'file-text', Icon: FileText, label: 'Report' },
53
+ { key: 'file-bar-chart', Icon: FileBarChart, label: 'Chart' },
54
+ { key: 'download', Icon: Download, label: 'Export' },
55
+ { key: 'camera', Icon: Camera, label: 'Snapshot' },
56
+ { key: 'scissors', Icon: Scissors, label: 'Section' },
57
+ { key: 'maximize-2', Icon: Maximize2, label: 'Fly to' },
58
+ { key: 'gauge', Icon: Gauge, label: 'Performance' },
59
+ { key: 'lightbulb', Icon: Lightbulb, label: 'Idea' },
60
+ { key: 'alert-triangle', Icon: AlertTriangle, label: 'Warning' },
61
+ { key: 'beaker', Icon: Beaker, label: 'Test' },
62
+ { key: 'brain', Icon: Brain, label: 'Memory' },
63
+ { key: 'check-circle-2', Icon: CheckCircle2, label: 'Validate' },
64
+ { key: 'settings', Icon: Settings, label: 'Setting' },
65
+ ];
66
+
67
+ const ICON_LOOKUP = new Map<string, LucideIcon>(
68
+ ICON_CHOICES.map((c) => [c.key, c.Icon]),
69
+ );
70
+
71
+ /**
72
+ * Resolve a manifest icon string to a lucide component. Falls back to
73
+ * `Sparkles` when the key is unknown or absent so the toolbar still
74
+ * renders something rather than a missing-icon hole. Also accepts a
75
+ * loose match on common variants (case, leading "icon-", underscores)
76
+ * so AI-authored manifests with slight key drift still resolve.
77
+ */
78
+ export function resolveExtensionIcon(key: string | undefined | null): LucideIcon {
79
+ if (!key) return Sparkles;
80
+ const direct = ICON_LOOKUP.get(key);
81
+ if (direct) return direct;
82
+ // Tolerate AI-authored variants the picker doesn't emit but the model
83
+ // might guess: `Wrench`, `alertTriangle`, `Alert_Triangle`,
84
+ // `icon-wrench`, etc. → all collapse to the kebab-case canonical key.
85
+ const normalised = key
86
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
87
+ .toLowerCase()
88
+ .replace(/^icon[-_]/, '')
89
+ .replace(/_/g, '-')
90
+ .trim();
91
+ return ICON_LOOKUP.get(normalised) ?? Sparkles;
92
+ }
@@ -0,0 +1,49 @@
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
+ * Centralised toast phrasing for the extensions / flavors surfaces.
7
+ * Keeps tone, capitalisation, and trailing-punctuation consistent
8
+ * across call sites.
9
+ *
10
+ * Callers pass the result to `toast.success` / `toast.info` /
11
+ * `toast.error` so the colour mapping stays at the call site.
12
+ */
13
+
14
+ export function flavorSwitched(name: string): string {
15
+ return `Switched to ${name}`;
16
+ }
17
+
18
+ export function flavorExported(filename: string): string {
19
+ return `Exported ${filename}`;
20
+ }
21
+
22
+ export function flavorImported(name: string): string {
23
+ return `Imported ${name}`;
24
+ }
25
+
26
+ export function flavorDeleted(name: string): string {
27
+ return `Deleted ${name}`;
28
+ }
29
+
30
+ export function flavorReset(): string {
31
+ return 'Reset to baseline flavor';
32
+ }
33
+
34
+ export function testsPassed(id: string, passed: number, total: number): string {
35
+ return `${id}: ${passed}/${total} tests passed`;
36
+ }
37
+
38
+ export function testsNotDeclared(id: string): string {
39
+ return `${id} declares no tests`;
40
+ }
41
+
42
+ export function testsFailed(id: string, failed: number, firstError: string): string {
43
+ return `${id}: ${failed} test${failed === 1 ? '' : 's'} failed — ${firstError}`;
44
+ }
45
+
46
+ export function failed(operation: string, err: unknown): string {
47
+ const cause = err instanceof Error ? err.message : String(err);
48
+ return `${operation} failed — ${cause}`;
49
+ }