@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.
- package/.turbo/turbo-build.log +34 -31
- package/CHANGELOG.md +96 -0
- package/dist/assets/{basketViewActivator-Dn_bHUl2.js → basketViewActivator-CU8_toGq.js} +7 -7
- package/dist/assets/{bcf-B9SFl84i.js → bcf-DXGDhw56.js} +23 -23
- package/dist/assets/{deflate-yMpdCIqk.js → deflate-Bb1_H2Yf.js} +1 -1
- package/dist/assets/{exporters-D-BvrNIg.js → exporters-DZhLN0ux.js} +1861 -1658
- package/dist/assets/geometry-controller.worker-DQOSYqtw.js +7 -0
- package/dist/assets/geometry.worker-B62e03Ao.js +1 -0
- package/dist/assets/{geotiff-D1tvcDCb.js → geotiff-y0ZxbRJd.js} +10 -10
- package/dist/assets/{ids-DZLs0snJ.js → ids-DruUNtfD.js} +4 -4
- package/dist/assets/ifc-lite-Ch2T9pP9.js +7 -0
- package/dist/assets/{ifc-lite_bg-DyHX37GQ.wasm → ifc-lite_bg-D7O1WHgP.wasm} +0 -0
- package/dist/assets/{ifc-lite_bg-BIryVCXQ.wasm → ifc-lite_bg-iH_07wf8.wasm} +0 -0
- package/dist/assets/index-Bws3UAkj.css +1 -0
- package/dist/assets/{index-CXSBhkcJ.js → index-Dr88ZlSY.js} +64100 -47030
- package/dist/assets/{jpeg-DUMcZp24.js → jpeg-B3_loqFe.js} +1 -1
- package/dist/assets/lens-PYsLu_MA.js +1 -0
- package/dist/assets/{lerc-IN4uWojP.js → lerc-nkwS8ZUe.js} +1 -1
- package/dist/assets/{lzw-Cnw0hH-m.js → lzw-D3cW5Wpg.js} +1 -1
- package/dist/assets/{native-bridge-BVf2uzoH.js → native-bridge-BcYJooq8.js} +2 -2
- package/dist/assets/{packbits-BskJCwk0.js → packbits-DDN4xzB5.js} +1 -1
- package/dist/assets/{parser.worker-BdtkkaGf.js → parser.worker-BW1IMUed.js} +3 -3
- package/dist/assets/raw-CoIXstQ-.js +1 -0
- package/dist/assets/{sandbox-VLI_y7cl.js → sandbox-DETNEyQb.js} +498 -470
- package/dist/assets/{server-client-BLcKaWQB.js → server-client-CmzJOeS7.js} +1 -1
- package/dist/assets/{wasm-bridge-BAfZh7YT.js → wasm-bridge-CT7mK9W0.js} +1 -1
- package/dist/assets/{webimage-Db2xzze3.js → webimage-CBjgg4up.js} +1 -1
- package/dist/assets/{workerHelpers--sAYm9yN.js → workerHelpers-IEQDo8r3.js} +1 -1
- package/dist/assets/{zstd-BDToOQyD.js → zstd-C8oQ6qdS.js} +1 -1
- package/dist/index.html +8 -8
- package/package.json +11 -9
- package/src/App.tsx +5 -2
- package/src/components/extensions/AuditLogPanel.tsx +259 -0
- package/src/components/extensions/BundlePreview.tsx +102 -0
- package/src/components/extensions/CapabilityReview.tsx +333 -0
- package/src/components/extensions/ExtensionDockHost.tsx +192 -0
- package/src/components/extensions/ExtensionToolbarSlot.tsx +106 -0
- package/src/components/extensions/ExtensionsPanel.tsx +481 -0
- package/src/components/extensions/FlavorDialog.tsx +398 -0
- package/src/components/extensions/FlavorImportPreview.tsx +79 -0
- package/src/components/extensions/FlavorIndicator.tsx +81 -0
- package/src/components/extensions/FlavorListView.tsx +318 -0
- package/src/components/extensions/FlavorMergeDialog.tsx +326 -0
- package/src/components/extensions/HelpHint.tsx +182 -0
- package/src/components/extensions/IdeasPanel.tsx +344 -0
- package/src/components/extensions/PlanCard.tsx +227 -0
- package/src/components/extensions/PrivacyPanel.tsx +312 -0
- package/src/components/extensions/PromoteToolDialog.tsx +313 -0
- package/src/components/extensions/RepairQueuePanel.tsx +222 -0
- package/src/components/extensions/icon-registry.ts +92 -0
- package/src/components/extensions/toast-helpers.ts +49 -0
- package/src/components/extensions/widget/WidgetErrorBoundary.tsx +62 -0
- package/src/components/extensions/widget/WidgetRenderer.tsx +428 -0
- package/src/components/viewer/ChatPanel.tsx +251 -3
- package/src/components/viewer/CommandPalette.tsx +74 -4
- package/src/components/viewer/Drawing2DCanvas.tsx +178 -1
- package/src/components/viewer/EntityContextMenu.tsx +70 -0
- package/src/components/viewer/ExportDialog.tsx +9 -1
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +21 -6
- package/src/components/viewer/LensPanel.tsx +50 -0
- package/src/components/viewer/MainToolbar.tsx +170 -87
- package/src/components/viewer/ScriptPanel.tsx +105 -1
- package/src/components/viewer/Section2DPanel.tsx +58 -2
- package/src/components/viewer/StatusBar.tsx +18 -0
- package/src/components/viewer/ViewerLayout.tsx +53 -4
- package/src/components/viewer/Viewport.tsx +72 -0
- package/src/hooks/useActionLogger.test.ts +161 -0
- package/src/hooks/useActionLogger.ts +141 -0
- package/src/hooks/useForkExtension.ts +51 -0
- package/src/hooks/useIfcFederation.ts +7 -1
- package/src/hooks/useInstalledExtensions.ts +43 -0
- package/src/hooks/usePrivacyDisclosure.ts +48 -0
- package/src/hooks/useRunExtensionTests.ts +67 -0
- package/src/hooks/useSlotContributions.ts +38 -0
- package/src/hooks/useSymbolicAnnotations.test.ts +124 -0
- package/src/hooks/useSymbolicAnnotations.ts +776 -0
- package/src/lib/desktop-product.ts +7 -1
- package/src/lib/lens/adapter.ts +14 -0
- package/src/lib/llm/prompt-cache.ts +77 -0
- package/src/lib/llm/stream-client.ts +20 -2
- package/src/lib/llm/stream-direct.ts +11 -1
- package/src/lib/llm/system-prompt.ts +42 -0
- package/src/lib/safe-mode.ts +30 -0
- package/src/sdk/ExtensionHostProvider.tsx +103 -0
- package/src/services/extensions/flavor-service.ts +183 -0
- package/src/services/extensions/host-commands.ts +112 -0
- package/src/services/extensions/host-installer.ts +289 -0
- package/src/services/extensions/host.ts +514 -0
- package/src/services/extensions/idb-flavor-storage.test.ts +140 -0
- package/src/services/extensions/idb-flavor-storage.ts +241 -0
- package/src/services/extensions/idb-log-storage.test.ts +110 -0
- package/src/services/extensions/idb-log-storage.ts +171 -0
- package/src/services/extensions/idb-storage.ts +228 -0
- package/src/services/extensions/runtime-errors.ts +26 -0
- package/src/services/extensions/sandbox-factory.ts +217 -0
- package/src/store/constants.ts +48 -6
- package/src/store/index.ts +6 -1
- package/src/store/slices/drawing2DSlice.ts +8 -0
- package/src/store/slices/extensionsSlice.ts +90 -0
- package/src/store/slices/lensSlice.ts +28 -0
- package/src/store/slices/visibilitySlice.test.ts +6 -0
- package/src/store/slices/visibilitySlice.ts +17 -8
- package/src/store/types.ts +2 -0
- package/dist/assets/geometry-controller.worker-Cm5pvyR6.js +0 -7
- package/dist/assets/geometry.worker-ClNvXIrj.js +0 -1
- package/dist/assets/ifc-lite-BDg0iIbj.js +0 -7
- package/dist/assets/index-DS_xJQfP.css +0 -1
- package/dist/assets/lens-CpjUdqpw.js +0 -1
- 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
|
+
}
|