/* 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/. */ /** * `ExtensionDockHost` — render dock panels contributed by extensions. * * Consumes `dock.left | dock.right | dock.bottom` slot contributions * and renders each as a tabbed panel. The body of each tab loads the * referenced widget JSON from the contributing bundle and renders it * via `WidgetRenderer`. * * Each dock slot is rendered separately so the caller can place them * around the viewport layout independently. Empty slots render * nothing (no chrome, no overhead). * * Spec: docs/architecture/ai-customization/03-ui-surface.md §3. */ import { useEffect, useMemo, useState } from 'react'; import { parseWhen, evaluateWhen, validateWidget, type DockContribution, type SlotContribution, type WhenContext, } from '@ifc-lite/extensions'; import { useSlotContributions } from '@/hooks/useSlotContributions'; import { useExtensionHost } from '@/sdk/ExtensionHostProvider'; import { useViewerStore } from '@/store'; import { WidgetRenderer, type WidgetRendererContext } from './widget/WidgetRenderer'; import { WidgetErrorBoundary } from './widget/WidgetErrorBoundary'; import { ScrollArea } from '@/components/ui/scroll-area'; import { toast } from '@/components/ui/toast'; import { describeRunCommandError } from '@/services/extensions/runtime-errors'; import { cn } from '@/lib/utils'; interface ExtensionDockHostProps { slot: DockContribution['slot']; /** Tailwind class to apply to the container. */ className?: string; } export function ExtensionDockHost({ slot, className }: ExtensionDockHostProps) { const contributions = useSlotContributions(slot); // Derive the when-clause context from live viewer state so // contributions can key on selection / model presence. Future // additions (schema, viewer.open, embed flag) thread through here. const modelLoaded = useViewerStore((s) => s.models.size > 0); // `selectedEntityIds` is the primary selection set — same source // `ExtensionToolbarSlot` reads, so `selection.count` evaluates // consistently across every extension surface. const selectionCount = useViewerStore((s) => s.selectedEntityIds.size); const whenContext = useMemo( () => ({ 'model.loaded': modelLoaded, 'selection.count': selectionCount }), [modelLoaded, selectionCount], ); const visible = useFiltered(contributions, whenContext); const [activeId, setActiveId] = useState(visible[0]?.payload.id); useEffect(() => { if (!visible.find((v) => v.payload.id === activeId)) { setActiveId(visible[0]?.payload.id); } }, [visible, activeId]); if (visible.length === 0) return null; const active = visible.find((v) => v.payload.id === activeId) ?? visible[0]; return (
{visible.map((c) => { const isActive = c.payload.id === active.payload.id; return ( ); })}
); } function useFiltered( contributions: SlotContribution[], whenContext: WhenContext, ): SlotContribution[] { return useMemo(() => { return contributions.filter((c) => { if (!c.payload.when) return true; const parsed = parseWhen(c.payload.when); if (!parsed.ok) return false; return evaluateWhen(parsed.value, whenContext); }); }, [contributions, whenContext]); } function DockBody({ contribution }: { contribution: SlotContribution }) { const host = useExtensionHost(); const [widget, setWidget] = useState(); const [error, setError] = useState(); const ctx: WidgetRendererContext = useMemo( () => ({ state: {}, invokeCommand: (commandId: string) => { host.runCommand(commandId).catch((err) => { console.warn('[ExtensionDockHost] command failed:', err); toast.error(describeRunCommandError(commandId, err)); }); }, }), [host], ); useEffect(() => { let cancelled = false; void (async () => { try { const bundle = host.loader.getBundle(contribution.extensionId); if (!bundle) { if (!cancelled) setError(`Bundle for ${contribution.extensionId} not loaded.`); return; } const file = bundle.files.get(contribution.payload.widget); if (!file) { if (!cancelled) setError(`Widget "${contribution.payload.widget}" not found in bundle.`); return; } const text = file.text ?? new TextDecoder().decode(file.bytes); const json = JSON.parse(text); // Validate the shape before handing it to the renderer so we // surface a clean structured error instead of a deep crash. const validated = validateWidget(json, contribution.payload.widget); if (!validated.ok) { const first = validated.errors[0]; if (!cancelled) setError(`Widget ${first?.path || ''} ${first?.message ?? 'failed validation'}`); return; } if (!cancelled) setWidget(validated.value); } catch (err) { if (!cancelled) setError(err instanceof Error ? err.message : String(err)); } })(); return () => { cancelled = true; }; }, [host, contribution]); if (error) { return (
{error}
); } if (!widget) { return
Loading widget…
; } return (
[0]['node']} ctx={ctx} />
); }