@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,481 @@
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
+ * `ExtensionsPanel` — dock panel surface for managing installed user
7
+ * extensions.
8
+ *
9
+ * Listing: each installed extension shows its id, version, granted
10
+ * capabilities (collapsed to count), enable/disable switch, and
11
+ * uninstall button.
12
+ *
13
+ * Import: drag a `.iflx` file onto the dropzone (or click "Import") to
14
+ * launch the capability review dialog. After approval, the host
15
+ * installs the bundle and the list refreshes.
16
+ *
17
+ * Phase 1 scope. The audit log view and promote-to-tool flow are
18
+ * separate components landing later.
19
+ */
20
+
21
+ import { useCallback, useEffect, useRef, useState } from 'react';
22
+ import { Beaker, FilePlus, FileText, GitFork, Lightbulb, Puzzle, Shield, Sparkles, Trash2, Upload, Wrench, X } from 'lucide-react';
23
+ import { toast } from '@/components/ui/toast';
24
+ import { Button } from '@/components/ui/button';
25
+ import { Switch } from '@/components/ui/switch';
26
+ import { useExtensionHost } from '@/sdk/ExtensionHostProvider';
27
+ import { useInstalledExtensions } from '@/hooks/useInstalledExtensions';
28
+ import { useForkExtension } from '@/hooks/useForkExtension';
29
+ import { useRunExtensionTests } from '@/hooks/useRunExtensionTests';
30
+ import { CapabilityReview } from './CapabilityReview';
31
+ import { AuditLogPanel } from './AuditLogPanel';
32
+ import { IdeasPanel } from './IdeasPanel';
33
+ import { RepairQueuePanel } from './RepairQueuePanel';
34
+ import { PrivacyPanel } from './PrivacyPanel';
35
+ import type { ExtensionInstallSummary } from '@/services/extensions/host';
36
+ import { ExtensionInstallError } from '@/services/extensions/host';
37
+ import { ExtensionStorageQuotaError } from '@/services/extensions/idb-storage';
38
+ import { useViewerStore } from '@/store';
39
+ import * as toastText from './toast-helpers';
40
+ import { HelpHint } from './HelpHint';
41
+
42
+ interface ExtensionsPanelProps {
43
+ onClose?: () => void;
44
+ }
45
+
46
+ export function ExtensionsPanel({ onClose }: ExtensionsPanelProps) {
47
+ const host = useExtensionHost();
48
+ const installed = useInstalledExtensions();
49
+ const handleFork = useForkExtension();
50
+ const { runTests, isRunning } = useRunExtensionTests();
51
+ const pendingAuthoredBundle = useViewerStore((s) => s.pendingAuthoredBundle);
52
+ const setPendingAuthoredBundle = useViewerStore((s) => s.setPendingAuthoredBundle);
53
+ /** Empty-state "describe in chat" CTA + Sparkles button. */
54
+ const queueChatPrompt = useViewerStore((s) => s.queueChatPrompt);
55
+ const setChatPanelVisible = useViewerStore((s) => s.setChatPanelVisible);
56
+ const setScriptPanelVisible = useViewerStore((s) => s.setScriptPanelVisible);
57
+ /** Active-flavor name surfaced in the panel header to give the concept impressions. */
58
+ const setFlavorDialogRequested = useViewerStore((s) => s.setFlavorDialogRequested);
59
+ const [activeFlavorName, setActiveFlavorName] = useState<string | undefined>();
60
+ useEffect(() => {
61
+ let cancelled = false;
62
+ const refresh = async () => {
63
+ try {
64
+ const flavor = await host.flavors.getActive();
65
+ if (!cancelled) setActiveFlavorName(flavor?.name);
66
+ } catch {
67
+ // Best-effort: header chip just goes blank if read fails.
68
+ }
69
+ };
70
+ void refresh();
71
+ const off = host.flavors.onChange(() => void refresh());
72
+ return () => { cancelled = true; off(); };
73
+ }, [host]);
74
+ const fileInputRef = useRef<HTMLInputElement>(null);
75
+ const [pending, setPending] = useState<{
76
+ bytes: Uint8Array;
77
+ summary: ExtensionInstallSummary;
78
+ previousGrants?: readonly string[];
79
+ previousVersion?: string;
80
+ } | null>(null);
81
+ const [busy, setBusy] = useState(false);
82
+ const [dragOver, setDragOver] = useState(false);
83
+ const [view, setView] = useState<'installed' | 'ideas' | 'audit' | 'repair' | 'privacy'>('installed');
84
+ /** Deep-link entry point (Command Palette "Author an extension…"). */
85
+ const extensionsRequestedView = useViewerStore((s) => s.extensionsRequestedView);
86
+ const setExtensionsRequestedView = useViewerStore((s) => s.setExtensionsRequestedView);
87
+
88
+ useEffect(() => {
89
+ if (extensionsRequestedView) {
90
+ setView(extensionsRequestedView);
91
+ setExtensionsRequestedView(null);
92
+ }
93
+ }, [extensionsRequestedView, setExtensionsRequestedView]);
94
+
95
+ const handleFiles = useCallback(
96
+ async (files: FileList | null) => {
97
+ if (!files || files.length === 0) return;
98
+ const file = files[0];
99
+ if (!file.name.toLowerCase().endsWith('.iflx')) {
100
+ toast.error(`Expected a .iflx extension bundle, got ${file.name}.`);
101
+ return;
102
+ }
103
+ try {
104
+ const bytes = new Uint8Array(await file.arrayBuffer());
105
+ const preview = await host.previewBundle(bytes);
106
+ if (!preview.ok) {
107
+ toast.error(`Bundle did not unpack: ${preview.errors[0]?.message ?? 'unknown error'}`);
108
+ return;
109
+ }
110
+ // Detect upgrade: same id already installed → pass the previous
111
+ // grants into the review screen so it can surface a diff.
112
+ const records = await host.listInstalled();
113
+ const existing = records.find((r) => r.id === preview.value.id);
114
+ setPending({
115
+ bytes,
116
+ summary: preview.value,
117
+ previousGrants: existing?.grantedCapabilities,
118
+ previousVersion: existing ? `v${existing.version}` : undefined,
119
+ });
120
+ } catch (err) {
121
+ toast.error(`Failed to read file: ${err instanceof Error ? err.message : String(err)}`);
122
+ }
123
+ },
124
+ [host],
125
+ );
126
+
127
+ // Authoring loop hand-off: when the chat panel produces a clean
128
+ // bundle, it stashes the bytes in `pendingAuthoredBundle` and opens
129
+ // the Extensions panel. Pick them up on mount, route through the
130
+ // standard preview → Capability Review flow.
131
+ useEffect(() => {
132
+ if (!pendingAuthoredBundle) return;
133
+ // Don't clobber a capability review already on screen (e.g. from a
134
+ // file import). Leave the authored bundle queued — the effect
135
+ // re-runs once `pending` clears.
136
+ if (pending) return;
137
+ const bytes = pendingAuthoredBundle;
138
+ void (async () => {
139
+ try {
140
+ const preview = await host.previewBundle(bytes);
141
+ if (!preview.ok) {
142
+ toast.error(`Authored bundle didn't unpack: ${preview.errors[0]?.message ?? 'unknown'}`);
143
+ setPendingAuthoredBundle(null);
144
+ return;
145
+ }
146
+ const records = await host.listInstalled();
147
+ const existing = records.find((r) => r.id === preview.value.id);
148
+ setPending({
149
+ bytes,
150
+ summary: preview.value,
151
+ previousGrants: existing?.grantedCapabilities,
152
+ previousVersion: existing ? `v${existing.version}` : undefined,
153
+ });
154
+ setPendingAuthoredBundle(null);
155
+ } catch (err) {
156
+ toast.error(`Authored bundle preview failed: ${err instanceof Error ? err.message : String(err)}`);
157
+ setPendingAuthoredBundle(null);
158
+ }
159
+ })();
160
+ }, [pendingAuthoredBundle, pending, host, setPendingAuthoredBundle]);
161
+
162
+ const handleApprove = useCallback(
163
+ async (grants: string[]) => {
164
+ // Two guards: pending may have been cleared by a parallel cancel,
165
+ // and busy stops a double-click from kicking off two installs of
166
+ // the same bytes.
167
+ if (!pending || busy) return;
168
+ setBusy(true);
169
+ try {
170
+ const status = await host.installFromBytes(pending.bytes, grants);
171
+ toast.success(`${status.id} v${status.version} installed`);
172
+ setPending(null);
173
+ } catch (err) {
174
+ if (err instanceof ExtensionStorageQuotaError) {
175
+ toast.error(
176
+ `Out of browser storage. Uninstall an extension or clear some flavors, then retry.`,
177
+ );
178
+ } else if (err instanceof ExtensionInstallError) {
179
+ toast.error(`Install rejected: ${err.validationErrors[0]?.message ?? err.message}`);
180
+ } else {
181
+ toast.error(`Install failed: ${err instanceof Error ? err.message : String(err)}`);
182
+ }
183
+ } finally {
184
+ setBusy(false);
185
+ }
186
+ },
187
+ [host, pending, busy],
188
+ );
189
+
190
+ return (
191
+ <div className="flex flex-col h-full">
192
+ {/* Title row — always fits regardless of panel width. The tab
193
+ strip moves to its own row below so it can scroll
194
+ horizontally without crowding the title. */}
195
+ <div className="flex items-center justify-between gap-2 border-b px-3 py-2">
196
+ <div className="flex items-center gap-2 min-w-0">
197
+ <Puzzle className="h-4 w-4 shrink-0" />
198
+ <h2 className="text-sm font-semibold shrink-0">Extensions</h2>
199
+ {activeFlavorName && (
200
+ <button
201
+ type="button"
202
+ onClick={() => setFlavorDialogRequested(true)}
203
+ className="shrink-0 text-[10px] uppercase tracking-wide bg-primary/10 text-primary hover:bg-primary/20 rounded px-1.5 py-0.5 font-semibold transition-colors max-w-[110px] truncate"
204
+ title={`Active flavor: ${activeFlavorName}. Click to manage.`}
205
+ aria-label={`Active flavor: ${activeFlavorName}. Click to open the flavor dialog.`}
206
+ >
207
+ {activeFlavorName}
208
+ </button>
209
+ )}
210
+ <HelpHint
211
+ label="Extensions"
212
+ docLink={{
213
+ href: 'https://github.com/LTplus-AG/ifc-lite/blob/main/docs/guide/extensions.md',
214
+ label: 'Read the Extensions guide →',
215
+ }}
216
+ >
217
+ <p>
218
+ <strong>Extensions</strong> are sandboxed bundles of
219
+ JavaScript that add buttons, panels, lenses, or exporters
220
+ to the viewer.
221
+ </p>
222
+ <p>
223
+ The tab strip below jumps to: <strong>Ideas</strong>{' '}
224
+ (mined patterns + starter suggestions),{' '}
225
+ <strong>Repair</strong> (SDK-update compatibility check),
226
+ <strong> Audit</strong> (lifecycle ledger),{' '}
227
+ <strong>Privacy</strong> (action-log controls + prompt
228
+ overlay).
229
+ </p>
230
+ <p>
231
+ Get started by describing one in chat, browsing starter
232
+ ideas, or importing a <code>.iflx</code> bundle.
233
+ </p>
234
+ </HelpHint>
235
+ </div>
236
+ <div className="flex items-center gap-1 shrink-0">
237
+ <Button
238
+ size="sm"
239
+ variant="outline"
240
+ onClick={() => fileInputRef.current?.click()}
241
+ disabled={busy}
242
+ >
243
+ <Upload className="mr-1 h-3.5 w-3.5" />
244
+ Import
245
+ </Button>
246
+ {onClose && (
247
+ <Button
248
+ size="icon"
249
+ variant="ghost"
250
+ onClick={onClose}
251
+ aria-label="Close extensions panel"
252
+ >
253
+ <X className="h-4 w-4" />
254
+ </Button>
255
+ )}
256
+ </div>
257
+ <input
258
+ ref={fileInputRef}
259
+ type="file"
260
+ accept=".iflx"
261
+ className="hidden"
262
+ onChange={(e) => {
263
+ void handleFiles(e.target.files);
264
+ e.target.value = '';
265
+ }}
266
+ />
267
+ </div>
268
+
269
+ {/* Tab strip — its own row so the title row never crowds it.
270
+ Horizontally scrollable when the panel narrows. */}
271
+ <div
272
+ className="flex items-center gap-0 border-b overflow-x-auto px-1"
273
+ role="tablist"
274
+ aria-label="Extension surfaces"
275
+ >
276
+ {(
277
+ [
278
+ { id: 'installed', label: 'Installed', Icon: Puzzle },
279
+ { id: 'ideas', label: 'Ideas', Icon: Lightbulb },
280
+ { id: 'repair', label: 'Repair', Icon: Wrench },
281
+ { id: 'audit', label: 'Audit', Icon: FileText },
282
+ { id: 'privacy', label: 'Privacy', Icon: Shield },
283
+ ] as const
284
+ ).map(({ id, label, Icon }) => {
285
+ const active = view === id;
286
+ return (
287
+ <button
288
+ key={id}
289
+ type="button"
290
+ role="tab"
291
+ aria-selected={active}
292
+ onClick={() => setView(id)}
293
+ className={`shrink-0 flex items-center gap-1 px-3 py-1.5 text-xs font-medium border-b-2 transition-colors ${
294
+ active
295
+ ? 'border-primary text-primary'
296
+ : 'border-transparent text-muted-foreground hover:text-foreground'
297
+ }`}
298
+ >
299
+ <Icon className="h-3.5 w-3.5" />
300
+ {label}
301
+ </button>
302
+ );
303
+ })}
304
+ </div>
305
+
306
+ {/* Body — every sub-view fills the remaining height and owns its
307
+ own scroll. `min-h-0` lets flex children actually shrink so
308
+ inner ScrollArea / overflow-auto kicks in at narrow heights. */}
309
+ <div className="flex-1 min-h-0 flex flex-col">
310
+ {view === 'audit' ? (
311
+ <AuditLogPanel />
312
+ ) : view === 'ideas' ? (
313
+ <IdeasPanel />
314
+ ) : view === 'repair' ? (
315
+ <RepairQueuePanel />
316
+ ) : view === 'privacy' ? (
317
+ <PrivacyPanel />
318
+ ) : (
319
+ <div
320
+ className={`flex-1 min-h-0 overflow-y-auto overflow-x-hidden transition-colors ${
321
+ dragOver ? 'bg-primary/5' : ''
322
+ }`}
323
+ onDragOver={(e) => {
324
+ e.preventDefault();
325
+ setDragOver(true);
326
+ }}
327
+ onDragLeave={() => setDragOver(false)}
328
+ onDrop={(e) => {
329
+ e.preventDefault();
330
+ setDragOver(false);
331
+ void handleFiles(e.dataTransfer.files);
332
+ }}
333
+ >
334
+ {installed.length === 0 ? (
335
+ <div className="flex flex-col items-center gap-3 px-6 py-8">
336
+ <div className="flex flex-col items-center gap-2 text-center">
337
+ <FilePlus className="h-8 w-8 text-muted-foreground" />
338
+ <div className="text-sm font-medium">No extensions installed</div>
339
+ <div className="text-xs text-muted-foreground max-w-xs">
340
+ Extensions are sandboxed bundles that add commands,
341
+ lenses, panels, or exporters. You can install one three
342
+ ways:
343
+ </div>
344
+ </div>
345
+
346
+ <div className="flex flex-col gap-2 w-full max-w-sm mt-2">
347
+ {/* 1. Author via chat — most discoverable for new users. */}
348
+ <Button
349
+ variant="default"
350
+ size="sm"
351
+ onClick={() => {
352
+ queueChatPrompt('Author an extension for me. Help me describe it: what should it do?');
353
+ setChatPanelVisible(true);
354
+ setScriptPanelVisible(true);
355
+ }}
356
+ >
357
+ <Sparkles className="mr-2 h-3.5 w-3.5" />
358
+ Describe one in chat (AI authors it)
359
+ </Button>
360
+
361
+ {/* 2. Browse curated starter ideas. */}
362
+ <Button
363
+ variant="outline"
364
+ size="sm"
365
+ onClick={() => setView('ideas')}
366
+ >
367
+ <Lightbulb className="mr-2 h-3.5 w-3.5" />
368
+ Browse starter ideas
369
+ </Button>
370
+
371
+ {/* 3. Drop / import an .iflx file from elsewhere. */}
372
+ <Button
373
+ variant="ghost"
374
+ size="sm"
375
+ onClick={() => fileInputRef.current?.click()}
376
+ >
377
+ <Upload className="mr-2 h-3.5 w-3.5" />
378
+ Import a .iflx file
379
+ </Button>
380
+ </div>
381
+
382
+ <div className="mt-2 text-[10px] text-muted-foreground text-center">
383
+ All extensions run in a sandbox with explicit capability
384
+ grants. Build one from the CLI with{' '}
385
+ <code className="font-mono">ifc-lite ext init</code>.
386
+ </div>
387
+ </div>
388
+ ) : (
389
+ <ul className="divide-y">
390
+ {installed.map((record) => (
391
+ <li key={record.id} className="px-4 py-3">
392
+ <div className="flex items-start justify-between gap-3">
393
+ <div className="flex-1 min-w-0">
394
+ <div className="font-mono text-xs break-all">{record.id}</div>
395
+ <div className="mt-0.5 text-[11px] text-muted-foreground">
396
+ v{record.version} · {record.grantedCapabilities.length}{' '}
397
+ {record.grantedCapabilities.length === 1 ? 'capability' : 'capabilities'}{' '}
398
+ · {new Date(record.installedAt).toLocaleDateString()}
399
+ </div>
400
+ </div>
401
+ <div className="flex items-center gap-1 shrink-0">
402
+ <Button
403
+ size="icon"
404
+ variant="ghost"
405
+ onClick={() => handleFork(record.id)}
406
+ aria-label={`Fork ${record.id}`}
407
+ title="Fork: edit this extension in the chat"
408
+ >
409
+ <GitFork className="h-3.5 w-3.5" />
410
+ </Button>
411
+ <Button
412
+ size="icon"
413
+ variant="ghost"
414
+ disabled={isRunning(record.id)}
415
+ onClick={() => runTests(record.id)}
416
+ aria-label={`Run tests for ${record.id}`}
417
+ >
418
+ <Beaker className={`h-3.5 w-3.5 ${isRunning(record.id) ? 'animate-pulse' : ''}`} />
419
+ </Button>
420
+ <Switch
421
+ checked={record.enabled}
422
+ onCheckedChange={(checked) => {
423
+ host.setEnabled(record.id, checked).catch((err) => {
424
+ toast.error(toastText.failed(checked ? 'Enable' : 'Disable', err));
425
+ });
426
+ }}
427
+ aria-label={record.enabled ? 'Disable extension' : 'Enable extension'}
428
+ />
429
+ <Button
430
+ size="icon"
431
+ variant="ghost"
432
+ onClick={() => {
433
+ if (!confirm(`Uninstall ${record.id}?`)) return;
434
+ host.uninstall(record.id).catch((err) => {
435
+ toast.error(toastText.failed('Uninstall', err));
436
+ });
437
+ }}
438
+ aria-label={`Uninstall ${record.id}`}
439
+ >
440
+ <Trash2 className="h-3.5 w-3.5" />
441
+ </Button>
442
+ </div>
443
+ </div>
444
+ {record.grantedCapabilities.length > 0 && (
445
+ <div className="mt-2 flex flex-wrap gap-1">
446
+ {record.grantedCapabilities.slice(0, 4).map((cap) => (
447
+ <code
448
+ key={cap}
449
+ className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-mono"
450
+ >
451
+ {cap}
452
+ </code>
453
+ ))}
454
+ {record.grantedCapabilities.length > 4 && (
455
+ <span className="text-[10px] text-muted-foreground self-center">
456
+ +{record.grantedCapabilities.length - 4} more
457
+ </span>
458
+ )}
459
+ </div>
460
+ )}
461
+ </li>
462
+ ))}
463
+ </ul>
464
+ )}
465
+ </div>
466
+ )}
467
+ </div>
468
+
469
+ {pending && (
470
+ <CapabilityReview
471
+ open
472
+ summary={pending.summary}
473
+ previousGrants={pending.previousGrants}
474
+ previousVersion={pending.previousVersion}
475
+ onApprove={handleApprove}
476
+ onCancel={() => setPending(null)}
477
+ />
478
+ )}
479
+ </div>
480
+ );
481
+ }