import { IconPlus } from "@tabler/icons-react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; import { sendToAgentChat } from "../agent-chat.js"; import { agentNativePath } from "../api-path.js"; import { Popover, PopoverContent, PopoverTrigger, } from "../components/ui/popover.js"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "../components/ui/tooltip.js"; import { useT } from "../i18n.js"; import { EmbeddedExtension } from "./EmbeddedExtension.js"; import { ExtensionQueryErrorState } from "./ExtensionQueryErrorState.js"; interface SlotInstall { installId: string; extensionId: string; name: string; description: string; icon: string | null; updatedAt: string; position: number; config: string | null; } interface AvailableTool { extensionId: string; name: string; description: string; icon: string | null; config: string | null; } export interface ExtensionSlotProps { /** Stable slot identifier — convention: `..`. */ id: string; /** Object pushed to each embedded extension as `slotContext`. */ context?: Record | null; /** Show a small "+" affordance when the slot has no installs. Default: false. */ showEmptyAffordance?: boolean; /** Optional className applied to the wrapper. */ className?: string; /** Optional className applied to each EmbeddedExtension. */ toolClassName?: string; /** Fires once when the slot query and all installed extensions are ready. */ onReady?: () => void; } /** * A named UI slot that user-installed extensions can render into. Apps drop this * component wherever they want to allow extensions; the framework handles * fetching, sandboxing, context delivery, and lifecycle. * * Example: * * */ export function ExtensionSlot({ id, context, showEmptyAffordance, className, toolClassName, onReady, }: ExtensionSlotProps) { const t = useT(); const readyInstallIds = useRef(new Set()); const readyNotified = useRef(false); const installsQuery = useQuery({ queryKey: ["slot-installs", id], queryFn: async () => { const res = await fetch( agentNativePath( `/_agent-native/slots/${encodeURIComponent(id)}/installs`, ), ); if (!res.ok) throw new Error(`Failed to load slot installs (${res.status})`); return res.json(); }, }); const installs = installsQuery.data ?? []; useEffect(() => { readyInstallIds.current.clear(); readyNotified.current = false; }, [id]); useEffect(() => { if (readyNotified.current || installsQuery.isLoading) return; if ( installsQuery.isError || installs.length === 0 || readyInstallIds.current.size >= installs.length ) { readyNotified.current = true; onReady?.(); } }, [installs, installsQuery.isError, installsQuery.isLoading, onReady]); const markInstallReady = (installId: string) => { readyInstallIds.current.add(installId); if ( !readyNotified.current && !installsQuery.isLoading && readyInstallIds.current.size >= installs.length ) { readyNotified.current = true; onReady?.(); } }; if (installsQuery.isLoading) { return null; } if (installsQuery.isError) { return ( void installsQuery.refetch()} retrying={installsQuery.isFetching} /> ); } if (installs.length === 0) { if (!showEmptyAffordance) return null; return (
); } return (
{installs.map((install) => ( markInstallReady(install.installId)} onUnavailable={() => markInstallReady(install.installId)} /> ))}
); } function SlotEmptyAffordance({ slotId }: { slotId: string }) { const t = useT(); const [open, setOpen] = useState(false); const availableQuery = useQuery({ queryKey: ["slot-available", slotId], queryFn: async () => { const res = await fetch( agentNativePath( `/_agent-native/slots/${encodeURIComponent(slotId)}/available`, ), ); if (!res.ok) { throw new Error(`Failed to load available extensions (${res.status})`); } return res.json(); }, enabled: open, }); const available = availableQuery.data ?? []; const queryClient = useQueryClient(); const install = async (extensionId: string) => { queryClient.setQueryData( ["slot-installs", slotId], (old) => { const extension = available.find((t) => t.extensionId === extensionId); if (!extension || !old) return old; return [ ...old, { installId: `optimistic-${extensionId}`, extensionId, name: extension.name, description: extension.description, icon: extension.icon, updatedAt: new Date().toISOString(), position: old.length, config: extension.config, }, ]; }, ); setOpen(false); try { await fetch( agentNativePath( `/_agent-native/slots/${encodeURIComponent(slotId)}/install`, ), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ extensionId }), }, ); } finally { queryClient.invalidateQueries({ queryKey: ["slot-installs", slotId] }); } }; const requestNew = () => { setOpen(false); sendToAgentChat({ message: t("extensions.createWidgetPrompt", { slotId }), submit: false, openSidebar: true, }); }; const slotDescription = describeSlot(slotId, t); return ( {t("extensions.addWidget")}

{slotDescription.title}

{slotDescription.description}

{availableQuery.isLoading && (
{t("extensions.loading")}
)} {availableQuery.isError && ( void availableQuery.refetch()} retrying={availableQuery.isFetching} /> )} {!availableQuery.isLoading && !availableQuery.isError && available.length === 0 && (
{t("extensions.noWidgetsAvailable")}
)} {available.map((extension) => ( ))}
); } function describeSlot( slotId: string, t: ReturnType, ): { title: string; description: string } { if (slotId === "mail.contact-sidebar.bottom") { return { title: t("extensions.contactSidebarWidget"), description: t("extensions.contactSidebarDescription"), }; } if (slotId === "calendar.event-detail.bottom") { return { title: t("extensions.eventDetailWidget"), description: t("extensions.eventDetailDescription"), }; } return { title: t("extensions.addWidgetHere"), description: slotId, }; }