/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * `ExtensionsPanel` — dock panel surface for managing installed user * extensions. * * Listing: each installed extension shows its id, version, granted * capabilities (collapsed to count), enable/disable switch, and * uninstall button. * * Import: drag a `.iflx` file onto the dropzone (or click "Import") to * launch the capability review dialog. After approval, the host * installs the bundle and the list refreshes. * * Phase 1 scope. The audit log view and promote-to-tool flow are * separate components landing later. */ import { useCallback, useEffect, useRef, useState } from 'react'; import { Beaker, FilePlus, FileText, GitFork, Lightbulb, Puzzle, Shield, Sparkles, Trash2, Upload, Wrench, X } from 'lucide-react'; import { toast } from '@/components/ui/toast'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; import { useExtensionHost } from '@/sdk/ExtensionHostProvider'; import { useInstalledExtensions } from '@/hooks/useInstalledExtensions'; import { useForkExtension } from '@/hooks/useForkExtension'; import { useRunExtensionTests } from '@/hooks/useRunExtensionTests'; import { CapabilityReview } from './CapabilityReview'; import { AuditLogPanel } from './AuditLogPanel'; import { IdeasPanel } from './IdeasPanel'; import { RepairQueuePanel } from './RepairQueuePanel'; import { PrivacyPanel } from './PrivacyPanel'; import type { ExtensionInstallSummary } from '@/services/extensions/host'; import { ExtensionInstallError } from '@/services/extensions/host'; import { ExtensionStorageQuotaError } from '@/services/extensions/idb-storage'; import { useViewerStore } from '@/store'; import * as toastText from './toast-helpers'; import { HelpHint } from './HelpHint'; interface ExtensionsPanelProps { onClose?: () => void; } export function ExtensionsPanel({ onClose }: ExtensionsPanelProps) { const host = useExtensionHost(); const installed = useInstalledExtensions(); const handleFork = useForkExtension(); const { runTests, isRunning } = useRunExtensionTests(); const pendingAuthoredBundle = useViewerStore((s) => s.pendingAuthoredBundle); const setPendingAuthoredBundle = useViewerStore((s) => s.setPendingAuthoredBundle); /** Empty-state "describe in chat" CTA + Sparkles button. */ const queueChatPrompt = useViewerStore((s) => s.queueChatPrompt); const setChatPanelVisible = useViewerStore((s) => s.setChatPanelVisible); const setScriptPanelVisible = useViewerStore((s) => s.setScriptPanelVisible); /** Active-flavor name surfaced in the panel header to give the concept impressions. */ const setFlavorDialogRequested = useViewerStore((s) => s.setFlavorDialogRequested); const [activeFlavorName, setActiveFlavorName] = useState(); useEffect(() => { let cancelled = false; const refresh = async () => { try { const flavor = await host.flavors.getActive(); if (!cancelled) setActiveFlavorName(flavor?.name); } catch { // Best-effort: header chip just goes blank if read fails. } }; void refresh(); const off = host.flavors.onChange(() => void refresh()); return () => { cancelled = true; off(); }; }, [host]); const fileInputRef = useRef(null); const [pending, setPending] = useState<{ bytes: Uint8Array; summary: ExtensionInstallSummary; previousGrants?: readonly string[]; previousVersion?: string; } | null>(null); const [busy, setBusy] = useState(false); const [dragOver, setDragOver] = useState(false); const [view, setView] = useState<'installed' | 'ideas' | 'audit' | 'repair' | 'privacy'>('installed'); /** Deep-link entry point (Command Palette "Author an extension…"). */ const extensionsRequestedView = useViewerStore((s) => s.extensionsRequestedView); const setExtensionsRequestedView = useViewerStore((s) => s.setExtensionsRequestedView); useEffect(() => { if (extensionsRequestedView) { setView(extensionsRequestedView); setExtensionsRequestedView(null); } }, [extensionsRequestedView, setExtensionsRequestedView]); const handleFiles = useCallback( async (files: FileList | null) => { if (!files || files.length === 0) return; const file = files[0]; if (!file.name.toLowerCase().endsWith('.iflx')) { toast.error(`Expected a .iflx extension bundle, got ${file.name}.`); return; } try { const bytes = new Uint8Array(await file.arrayBuffer()); const preview = await host.previewBundle(bytes); if (!preview.ok) { toast.error(`Bundle did not unpack: ${preview.errors[0]?.message ?? 'unknown error'}`); return; } // Detect upgrade: same id already installed → pass the previous // grants into the review screen so it can surface a diff. const records = await host.listInstalled(); const existing = records.find((r) => r.id === preview.value.id); setPending({ bytes, summary: preview.value, previousGrants: existing?.grantedCapabilities, previousVersion: existing ? `v${existing.version}` : undefined, }); } catch (err) { toast.error(`Failed to read file: ${err instanceof Error ? err.message : String(err)}`); } }, [host], ); // Authoring loop hand-off: when the chat panel produces a clean // bundle, it stashes the bytes in `pendingAuthoredBundle` and opens // the Extensions panel. Pick them up on mount, route through the // standard preview → Capability Review flow. useEffect(() => { if (!pendingAuthoredBundle) return; // Don't clobber a capability review already on screen (e.g. from a // file import). Leave the authored bundle queued — the effect // re-runs once `pending` clears. if (pending) return; const bytes = pendingAuthoredBundle; void (async () => { try { const preview = await host.previewBundle(bytes); if (!preview.ok) { toast.error(`Authored bundle didn't unpack: ${preview.errors[0]?.message ?? 'unknown'}`); setPendingAuthoredBundle(null); return; } const records = await host.listInstalled(); const existing = records.find((r) => r.id === preview.value.id); setPending({ bytes, summary: preview.value, previousGrants: existing?.grantedCapabilities, previousVersion: existing ? `v${existing.version}` : undefined, }); setPendingAuthoredBundle(null); } catch (err) { toast.error(`Authored bundle preview failed: ${err instanceof Error ? err.message : String(err)}`); setPendingAuthoredBundle(null); } })(); }, [pendingAuthoredBundle, pending, host, setPendingAuthoredBundle]); const handleApprove = useCallback( async (grants: string[]) => { // Two guards: pending may have been cleared by a parallel cancel, // and busy stops a double-click from kicking off two installs of // the same bytes. if (!pending || busy) return; setBusy(true); try { const status = await host.installFromBytes(pending.bytes, grants); toast.success(`${status.id} v${status.version} installed`); setPending(null); } catch (err) { if (err instanceof ExtensionStorageQuotaError) { toast.error( `Out of browser storage. Uninstall an extension or clear some flavors, then retry.`, ); } else if (err instanceof ExtensionInstallError) { toast.error(`Install rejected: ${err.validationErrors[0]?.message ?? err.message}`); } else { toast.error(`Install failed: ${err instanceof Error ? err.message : String(err)}`); } } finally { setBusy(false); } }, [host, pending, busy], ); return (
{/* Title row — always fits regardless of panel width. The tab strip moves to its own row below so it can scroll horizontally without crowding the title. */}

Extensions

{activeFlavorName && ( )}

Extensions are sandboxed bundles of JavaScript that add buttons, panels, lenses, or exporters to the viewer.

The tab strip below jumps to: Ideas{' '} (mined patterns + starter suggestions),{' '} Repair (SDK-update compatibility check), Audit (lifecycle ledger),{' '} Privacy (action-log controls + prompt overlay).

Get started by describing one in chat, browsing starter ideas, or importing a .iflx bundle.

{onClose && ( )}
{ void handleFiles(e.target.files); e.target.value = ''; }} />
{/* Tab strip — its own row so the title row never crowds it. Horizontally scrollable when the panel narrows. */}
{( [ { id: 'installed', label: 'Installed', Icon: Puzzle }, { id: 'ideas', label: 'Ideas', Icon: Lightbulb }, { id: 'repair', label: 'Repair', Icon: Wrench }, { id: 'audit', label: 'Audit', Icon: FileText }, { id: 'privacy', label: 'Privacy', Icon: Shield }, ] as const ).map(({ id, label, Icon }) => { const active = view === id; return ( ); })}
{/* Body — every sub-view fills the remaining height and owns its own scroll. `min-h-0` lets flex children actually shrink so inner ScrollArea / overflow-auto kicks in at narrow heights. */}
{view === 'audit' ? ( ) : view === 'ideas' ? ( ) : view === 'repair' ? ( ) : view === 'privacy' ? ( ) : (
{ e.preventDefault(); setDragOver(true); }} onDragLeave={() => setDragOver(false)} onDrop={(e) => { e.preventDefault(); setDragOver(false); void handleFiles(e.dataTransfer.files); }} > {installed.length === 0 ? (
No extensions installed
Extensions are sandboxed bundles that add commands, lenses, panels, or exporters. You can install one three ways:
{/* 1. Author via chat — most discoverable for new users. */} {/* 2. Browse curated starter ideas. */} {/* 3. Drop / import an .iflx file from elsewhere. */}
All extensions run in a sandbox with explicit capability grants. Build one from the CLI with{' '} ifc-lite ext init.
) : (
    {installed.map((record) => (
  • {record.id}
    v{record.version} · {record.grantedCapabilities.length}{' '} {record.grantedCapabilities.length === 1 ? 'capability' : 'capabilities'}{' '} · {new Date(record.installedAt).toLocaleDateString()}
    { host.setEnabled(record.id, checked).catch((err) => { toast.error(toastText.failed(checked ? 'Enable' : 'Disable', err)); }); }} aria-label={record.enabled ? 'Disable extension' : 'Enable extension'} />
    {record.grantedCapabilities.length > 0 && (
    {record.grantedCapabilities.slice(0, 4).map((cap) => ( {cap} ))} {record.grantedCapabilities.length > 4 && ( +{record.grantedCapabilities.length - 4} more )}
    )}
  • ))}
)}
)}
{pending && ( setPending(null)} /> )}
); }