import { IconArrowBackUp, IconChevronRight, IconDotsVertical, IconHistory, IconLoader2, IconPencil, IconRefresh, IconTrash, IconX, } from "@tabler/icons-react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { Link, useLocation, useNavigate } from "react-router"; import { buildExtensionHtml } from "../../extensions/html-shell.js"; import { extensionPath, isExtensionPathname } from "../../extensions/path.js"; import { getThemeVars } from "../../extensions/theme.js"; import { SESSION_REPLAY_IFRAME_ATTRIBUTE } from "../../session-replay-iframe-protocol.js"; import { sendToAgentChat } from "../agent-chat.js"; import { AgentToggleButton } from "../AgentPanel.js"; import { agentNativePath, appPath } from "../api-path.js"; import { Popover, PopoverContent, PopoverTrigger, } from "../components/ui/popover.js"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "../components/ui/tooltip.js"; import { PromptComposer } from "../composer/index.js"; import { isEmbedMcpChatBridgeActive } from "../embed-auth.js"; import { useT } from "../i18n.js"; import { ShareButton } from "../sharing/ShareButton.js"; import { deleteOrHideExtension, invalidateExtensionRemoval, } from "./delete-extension.js"; import { extensionLoadError, extensionLoadErrorStatus, shouldRetryExtensionLoad, } from "./extension-load-error.js"; import { ExtensionQueryErrorState } from "./ExtensionQueryErrorState.js"; import { isAllowedExtensionPath, sanitizeExtensionRequestOptions, checkBridgePolicy, type BridgePolicyContext, type ExtensionBridgeRole, } from "./iframe-bridge.js"; import { normalizeAgentNativeExtensionSandbox } from "./portable-extension.js"; const THEME_CSS_VARS = [ "--background", "--foreground", "--card", "--card-foreground", "--popover", "--popover-foreground", "--primary", "--primary-foreground", "--secondary", "--secondary-foreground", "--muted", "--muted-foreground", "--accent", "--accent-foreground", "--destructive", "--destructive-foreground", "--border", "--input", "--ring", "--radius", "--sidebar-background", "--sidebar-foreground", "--sidebar-primary", "--sidebar-primary-foreground", "--sidebar-accent", "--sidebar-accent-foreground", "--sidebar-border", "--sidebar-ring", ]; const EXTENSION_IFRAME_SANDBOX = normalizeAgentNativeExtensionSandbox(undefined); function getParentThemeVars(): Record { const computed = getComputedStyle(document.documentElement); const vars: Record = {}; for (const name of THEME_CSS_VARS) { const val = computed.getPropertyValue(name).trim(); if (val) vars[name] = val; } return vars; } interface Extension { id: string; name: string; description?: string; content?: string; updatedAt?: string; ownerEmail?: string; role?: ExtensionBridgeRole | null; canEdit?: boolean; canDelete?: boolean; source?: { mode?: "database" | "local-files"; entryPath?: string; manifestPath?: string; permissions?: BridgePolicyContext["permissions"]; }; } export interface ExtensionViewerProps { extensionId: string; } function readExtensionTitleSuffix(): string | null { const current = typeof document !== "undefined" ? document.title.trim() : ""; const match = current.match(/^(?:Extension|Tool)s?\s+(?:\u2014|-)\s+(.+)$/); return match?.[1]?.trim() || null; } function extensionDocumentTitle(name: string, suffix: string | null): string { return suffix ? `${name} \u2014 ${suffix}` : `${name} \u2014 Extensions`; } function extensionRole(value: unknown): ExtensionBridgeRole { return value === "owner" || value === "admin" || value === "editor" || value === "viewer" ? value : "viewer"; } function serializeChatValue(value: unknown): string | undefined { if (value === undefined || value === null) return undefined; if (typeof value === "string") return value; try { return JSON.stringify(value); } catch { return String(value); } } function buildExtensionViewerSrcDoc( extension: Extension, isDark: boolean, ): string { const role = extensionRole(extension.role); return buildExtensionHtml( extension.content ?? "", getThemeVars(isDark), isDark, extension.id, { authorEmail: extension.ownerEmail ?? "", viewerEmail: "", isAuthor: role === "owner", role, source: extension.source?.mode, permissions: extension.source?.permissions, }, ); } interface ExtensionHistoryEntry { id: string; extensionId: string; version: number; operation: string; summary: string; name: string; description: string; icon: string | null; actorEmail: string | null; ownerEmail: string; orgId: string | null; visibility: "private" | "org" | "public"; createdAt: string; persisted: boolean; contentLength: number; } interface ExtensionHistoryDiffLine { type: "equal" | "insert" | "delete"; text: string; } interface ExtensionHistoryDetail { entry: ExtensionHistoryEntry & { content?: string }; previous: (ExtensionHistoryEntry & { content?: string }) | null; diff: ExtensionHistoryDiffLine[]; stats: { addedLines: number; deletedLines: number; changed: boolean; }; } type CompactDiffLine = | ExtensionHistoryDiffLine | { type: "omitted"; count: number }; function formatHistoryTime(value: string | undefined): string { if (!value) return ""; const date = new Date(value); if (Number.isNaN(date.getTime())) return ""; return date.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", }); } function operationLabel(operation: string): string { switch (operation) { case "create": return "Created"; case "baseline": return "Baseline"; case "metadata-update": return "Details"; case "content-update": return "Content"; case "restore": return "Restore"; default: return operation; } } function compactDiffLines( lines: ExtensionHistoryDiffLine[], context = 3, ): CompactDiffLine[] { const result: CompactDiffLine[] = []; let i = 0; while (i < lines.length) { const line = lines[i]; if (line.type !== "equal") { result.push(line); i += 1; continue; } let end = i + 1; while (end < lines.length && lines[end].type === "equal") end += 1; const run = lines.slice(i, end); if (run.length > context * 2 + 1) { result.push(...run.slice(0, context)); result.push({ type: "omitted", count: run.length - context * 2 }); result.push(...run.slice(-context)); } else { result.push(...run); } i = end; } return result; } function ExtensionUnavailableState({ status }: { status?: number }) { const sessionExpired = status === 401; const accessDenied = status === 403; const unavailable = status === 404; const title = sessionExpired ? "Session expired" : accessDenied ? "Extension is not shared" : unavailable ? "Extension is unavailable" : "Extension not found"; const message = sessionExpired ? "Sign in again to view this extension." : accessDenied ? "You do not have access to this extension. Ask the owner to share it with your organization or open a different extension." : "This extension may not be shared with you, may have been deleted, or is not available to your account."; return (

{title}

{message}

Back to extensions
); } function diffLineClass(line: CompactDiffLine): string { switch (line.type) { case "insert": return "bg-primary/10 text-primary"; case "delete": return "bg-destructive/10 text-destructive"; case "omitted": return "bg-muted/60 text-muted-foreground"; default: return "text-muted-foreground"; } } function diffLinePrefix(line: CompactDiffLine): string { switch (line.type) { case "insert": return "+"; case "delete": return "-"; case "omitted": return "..."; default: return " "; } } function applyCanonicalLink(path: string): () => void { if (typeof document === "undefined" || typeof window === "undefined") { return () => {}; } let link = document.querySelector('link[rel="canonical"]'); const created = !link; const previousHref = link?.getAttribute("href") ?? null; const previousMarker = link?.dataset.agentNativeExtensionCanonical; if (!link) { link = document.createElement("link"); link.rel = "canonical"; document.head.appendChild(link); } link.dataset.agentNativeExtensionCanonical = "true"; link.href = new URL(appPath(path), window.location.origin).toString(); return () => { if (!link) return; if (created) { link.remove(); return; } if (previousHref === null) { link.removeAttribute("href"); } else { link.href = previousHref; } if (previousMarker === undefined) { delete link.dataset.agentNativeExtensionCanonical; } else { link.dataset.agentNativeExtensionCanonical = previousMarker; } }; } function EditToolPopover({ extension, onOpenChange, }: { extension: Extension; onOpenChange?: (open: boolean) => void; }) { const [open, setOpen] = useState(false); const setOpenAndNotify = (v: boolean) => { setOpen(v); onOpenChange?.(v); }; // Radix's outside-click detection runs in the parent document, so a click // inside the extension iframe (or any other iframe) never fires it. The browser // does shift focus to the iframe though, which blurs the parent window — we // hook that to close the popover so it behaves like a normal click-outside. useEffect(() => { if (!open) return; const handleBlur = () => { // Defer until after the focus actually lands so document.activeElement // reflects the iframe (or whatever the user clicked on). setTimeout(() => { if (document.activeElement?.tagName === "IFRAME") setOpenAndNotify(false); }, 0); }; window.addEventListener("blur", handleBlur); return () => window.removeEventListener("blur", handleBlur); }, [open]); const handleSubmit = (text: string) => { const trimmed = text.trim(); if (!trimmed) return; sendToAgentChat({ message: `Update extension "${extension.name}" (${extension.id}): ${trimmed}`, context: [ `The user is viewing extension "${extension.name}" (id: ${extension.id}) and wants to edit it.`, "This is an existing sandboxed Alpine.js extension stored in SQL. Use list-extensions/update-extension for this extension id.", "Do not call connect-builder and do not route this to a source-code change flow.", ].join("\n"), submit: true, openSidebar: true, newTab: true, }); setOpenAndNotify(false); }; return ( Edit

Edit extension

); } function ExtensionHistoryPopover({ extensionId, canEdit, onRestored, onOpenChange, }: { extensionId: string; canEdit?: boolean; onRestored?: () => void; onOpenChange?: (open: boolean) => void; }) { const t = useT(); const [open, setOpen] = useState(false); const [selectedVersion, setSelectedVersion] = useState(null); const [restoringVersion, setRestoringVersion] = useState(null); const queryClient = useQueryClient(); const setOpenAndNotify = (v: boolean) => { setOpen(v); onOpenChange?.(v); }; const historyQuery = useQuery<{ history: ExtensionHistoryEntry[] }>({ queryKey: ["extension-history", extensionId], queryFn: async () => { const res = await fetch( agentNativePath(`/_agent-native/extensions/${extensionId}/history`), { cache: "no-store" }, ); if (!res.ok) throw new Error("Failed to fetch extension history"); return res.json(); }, enabled: open, }); const history = historyQuery.data?.history ?? []; useEffect(() => { if (!open || history.length === 0) return; if ( !selectedVersion || !history.some((h) => h.version === selectedVersion) ) { setSelectedVersion(history[0].version); } }, [history, open, selectedVersion]); const detailQuery = useQuery({ queryKey: ["extension-history-detail", extensionId, selectedVersion], queryFn: async () => { const res = await fetch( agentNativePath( `/_agent-native/extensions/${extensionId}/history/${selectedVersion}`, ), { cache: "no-store" }, ); if (!res.ok) throw new Error("Failed to fetch extension history version"); return res.json(); }, enabled: open && selectedVersion !== null, }); const latestVersion = history[0]?.version ?? null; const selectedEntry = history.find((entry) => entry.version === selectedVersion) ?? history[0]; const canRestoreSelected = !!canEdit && !!selectedEntry?.persisted && selectedEntry.version !== latestVersion; const restoreSelected = async () => { if (!selectedEntry || !canRestoreSelected) return; setRestoringVersion(selectedEntry.version); try { const res = await fetch( agentNativePath( `/_agent-native/extensions/${extensionId}/history/${selectedEntry.version}/restore`, ), { method: "POST" }, ); if (!res.ok) throw new Error("Failed to restore extension version"); await Promise.all([ queryClient.invalidateQueries({ queryKey: ["extension", extensionId] }), queryClient.invalidateQueries({ queryKey: ["extensions"] }), queryClient.invalidateQueries({ queryKey: ["extension-history", extensionId], }), queryClient.invalidateQueries({ queryKey: ["extension-history-detail", extensionId], }), ]); onRestored?.(); } finally { setRestoringVersion(null); } }; const compactedDiff = compactDiffLines(detailQuery.data?.diff ?? []); return ( History

History

Snapshots are saved when extensions change.

{historyQuery.isLoading ? (
Loading history
) : historyQuery.isError ? ( void historyQuery.refetch()} retrying={historyQuery.isFetching} /> ) : history.length === 0 ? (

No history yet.

) : ( history.map((entry) => ( )) )}

{selectedEntry ? `Version ${selectedEntry.version}` : "Select a version"}

{selectedEntry?.summary ?? "Compare saved extension content"}

{detailQuery.isLoading ? (
Loading diff
) : detailQuery.isError ? ( void detailQuery.refetch()} retrying={detailQuery.isFetching} /> ) : compactedDiff.length === 0 ? (
No content changes in this version.
) : (
                  {compactedDiff.map((line, index) => (
                    
{diffLinePrefix(line)} {line.type === "omitted" ? `${line.count} unchanged lines` : line.text}
))}
)}
); } export function ExtensionViewer({ extensionId }: ExtensionViewerProps) { const t = useT(); const location = useLocation(); const navigate = useNavigate(); const [isDark, setIsDark] = useState(false); const [iframeReady, setIframeReady] = useState(false); const iframeRef = useRef(null); const toolRef = useRef(null); const [isRenaming, setIsRenaming] = useState(false); const [renameValue, setRenameValue] = useState(""); const [refreshKey, setRefreshKey] = useState(0); const renameInputRef = useRef(null); const titleSuffixRef = useRef(undefined); // Tracks how many toolbar popovers are open. Iframes capture pointer events // from areas they visually overlap, so when a popover opens above the iframe, // hover and click on the popover items get swallowed by the iframe. Disabling // pointer-events on the iframe while any popover is open lets the popover // receive its own events. Each popover increments on open / decrements on // close, so concurrent popovers (rare) compose correctly. const [openPopoverCount, setOpenPopoverCount] = useState(0); const onPopoverOpenChange = useCallback((open: boolean) => { setOpenPopoverCount((c) => Math.max(0, c + (open ? 1 : -1))); }, []); const queryClient = useQueryClient(); // (audit H4) Role plumbed through from the iframe's render binding. Until // the iframe announces its role we deny non-trivial helper calls — that // way a malicious extension body that races the announcement can't briefly // operate at higher privilege than the viewer's actual role. const bridgeContextRef = useRef({ role: "viewer", isAuthor: false, }); // (audit H4) Latch the render binding once per iframe instance; later // announcements are attacker-controllable and must be ignored. const bindingLatchedRef = useRef(false); useEffect(() => { setIsDark(document.documentElement.classList.contains("dark")); const observer = new MutationObserver(() => { setIsDark(document.documentElement.classList.contains("dark")); }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"], }); return () => observer.disconnect(); }, []); const sendThemeToIframe = () => { const win = iframeRef.current?.contentWindow; if (!win) return; win.postMessage( { type: "agent-native-theme-update", isDark: document.documentElement.classList.contains("dark"), vars: getParentThemeVars(), }, "*", ); }; useEffect(() => { if (!iframeReady) return; sendThemeToIframe(); }, [isDark, iframeReady]); useEffect(() => { const handleMessage = async (event: MessageEvent) => { if (event.source !== iframeRef.current?.contentWindow) return; const message = event.data; if (!message) return; if (message.type === "agent-native-extension-binding") { // (audit H4) Trust only the FIRST announcement: the shell sends the // server-resolved binding BEFORE user-authored content runs. Later // announcements share the iframe realm with user code and could forge // an owner role, so they are ignored. if (bindingLatchedRef.current) return; bindingLatchedRef.current = true; const binding = message.binding ?? {}; const role: ExtensionBridgeRole = binding.role === "owner" || binding.role === "admin" || binding.role === "editor" || binding.role === "viewer" ? binding.role : "viewer"; bridgeContextRef.current = { role, isAuthor: !!binding.isAuthor, source: binding.source === "local-files" ? "local-files" : "database", permissions: binding && typeof binding.permissions === "object" ? binding.permissions : undefined, }; return; } if ( message.type === "agent-native-extension-consent-granted" || message.type === "agent-native-extension-consent-cancelled" ) { // (audit C1) The consent stub fired; force a reload of the iframe so // the next render returns the extension body (granted) or stays on the // stub (cancelled — viewer can also navigate away). if (message.type === "agent-native-extension-consent-granted") { // Invalidate the cached extension record — author may have edited // since the cache was warmed. queryClient.invalidateQueries({ queryKey: ["extension", extensionId], }); setRefreshKey((k) => k + 1); } return; } if (message.type === "agent-native-send-to-chat") { const text = serializeChatValue(message.message); if (!text?.trim()) return; sendToAgentChat({ message: text, context: serializeChatValue(message.context), submit: message.submit !== false, openSidebar: message.openSidebar !== false, }); return; } if (message.type === "agent-native-extension-keydown") { document.dispatchEvent( new KeyboardEvent("keydown", { key: message.key, code: message.code, metaKey: !!message.metaKey, ctrlKey: !!message.ctrlKey, shiftKey: !!message.shiftKey, altKey: !!message.altKey, bubbles: true, cancelable: true, }), ); return; } if (message.type === "agent-native-extension-error-fix") { const t = toolRef.current; if (!t) return; const errors: string[] = message.errors || []; const errorDetails: Array<{ message: string; stack: string }> = message.errorDetails || []; const consoleLogs: Array<{ level: string; message: string }> = message.consoleLogs || []; const networkLogs: Array<{ path: string; method: string; ok?: boolean; status?: number; error?: string; }> = message.networkLogs || []; const detailedTrace = errorDetails .map((e) => (e.stack ? `${e.message}\n${e.stack}` : e.message)) .join("\n\n"); // Force a fresh read from the server. toolRef.current is bound to the // React Query cache, which is the same state the agent's previous // (broken) turn just wrote — without this, Fix-in-same-chat ends up // patching the agent's prior attempt from chat history instead of the // current DB row, which is why users had to open a new chat to // recover. Cache-bust so we never read a stale fetch. let freshContent: string | undefined; try { const res = await fetch( agentNativePath(`/_agent-native/extensions/${t.id}`), { cache: "no-store" }, ); if (res.ok) { const fresh = (await res.json()) as Extension; freshContent = typeof fresh?.content === "string" ? fresh.content : undefined; } } catch { // Fall through with the cached value — agent can still re-read via // its get-extension tool. } const contextParts = [ `The user is viewing extension "${t.name}" (id: ${t.id}) and there are runtime errors that need fixing.`, `\nFull error details:\n${detailedTrace}`, ]; if (consoleLogs.length > 0) { const consoleStr = consoleLogs .map((l) => `[${l.level}] ${l.message}`) .join("\n"); contextParts.push(`\nRecent console output:\n${consoleStr}`); } if (networkLogs.length > 0) { const netStr = networkLogs .map( (l) => `${l.method} ${l.path} → ${l.ok ? l.status : "FAILED: " + (l.error || l.status)}`, ) .join("\n"); contextParts.push(`\nRecent network requests:\n${netStr}`); } if (freshContent) { contextParts.push( `\nCurrent extension content (just re-read from the database — this is the authoritative source, not anything you may have written in a previous turn):\n\`\`\`html\n${freshContent}\n\`\`\``, ); } sendToAgentChat({ message: `Fix runtime errors in this extension. The content snapshot below was just re-read from the database — treat it as authoritative and ignore any prior version you may have generated in this chat. If in doubt, call get-extension first.\n\nErrors:\n${errors.join("\n")}`, context: contextParts.join("\n"), submit: true, openSidebar: true, }); return; } if (message.type !== "agent-native-extension-request") return; const requestId = String(message.requestId ?? ""); const path = String(message.path ?? ""); const respond = (payload: Record) => { iframeRef.current?.contentWindow?.postMessage( { type: "agent-native-extension-response", requestId, ...payload, }, "*", ); }; if (!requestId || !isAllowedExtensionPath(path, extensionId)) { respond({ error: "Extension request path is not allowed" }); return; } try { const options = sanitizeExtensionRequestOptions(message.options); // (audit H4) Role-aware policy gate: viewer-shared extensions can read // but not write. Decided here in the parent before the request // leaves; the server enforces a second layer. const policy = checkBridgePolicy(path, options.method ?? "GET", { ...bridgeContextRef.current, extensionId, }); if (!policy.ok) { respond({ response: { ok: false, status: 403, statusText: "Forbidden", body: { error: policy.error }, }, }); return; } // (audit H5) Tag every outbound bridge request with the // X-Agent-Native-Extension-Bridge sentinel so the action-routes layer can // enforce per-action `toolCallable` opt-in. The header is added by // the parent — it is NOT taken from the iframe-supplied options // (which were filtered by sanitizeExtensionRequestOptions). const finalHeaders = new Headers(options.headers ?? undefined); finalHeaders.set("X-Agent-Native-Extension-Bridge", "1"); finalHeaders.set("X-Agent-Native-Extension-Id", extensionId); finalHeaders.set("X-Agent-Native-Tool-Bridge", "1"); finalHeaders.set("X-Agent-Native-Tool-Id", extensionId); const res = await fetch(agentNativePath(path), { ...options, headers: finalHeaders, credentials: "same-origin", }); const text = await res.text(); let body: unknown = text; if (text) { try { body = JSON.parse(text); } catch { body = text; } } respond({ response: { ok: res.ok, status: res.status, statusText: res.statusText, body, }, }); } catch (err: any) { respond({ error: err?.message ?? "Extension host request failed" }); } }; window.addEventListener("message", handleMessage); return () => window.removeEventListener("message", handleMessage); }, [extensionId, queryClient]); const { data: extension, error: extensionError, failureReason: extensionFailureReason, isFetching, isLoading, isError, refetch, } = useQuery({ queryKey: ["extension", extensionId], queryFn: async () => { const res = await fetch( agentNativePath(`/_agent-native/extensions/${extensionId}`), ); if (res.status === 401) { throw extensionLoadError(401, "Session expired"); } if (res.status === 404) { throw extensionLoadError(404, "Extension not found"); } if (res.status === 403) { throw extensionLoadError(403, "Extension access denied"); } if (!res.ok) { throw extensionLoadError(res.status, "Failed to fetch extension"); } return res.json(); }, retry: shouldRetryExtensionLoad, retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 4000), refetchOnMount: "always", }); toolRef.current = extension ?? null; useEffect(() => { if (!extension) return; const canonicalPath = extensionPath(extension.id, extension.name); if (titleSuffixRef.current === undefined) { titleSuffixRef.current = readExtensionTitleSuffix(); } document.title = extensionDocumentTitle( extension.name, titleSuffixRef.current, ); if ( isExtensionPathname(location.pathname, extension.id) && location.pathname !== canonicalPath ) { navigate(`${canonicalPath}${location.search}${location.hash}`, { replace: true, }); } return applyCanonicalLink(canonicalPath); }, [extension, location.hash, location.pathname, location.search, navigate]); const iframeSrc = useMemo( () => agentNativePath( `/_agent-native/extensions/${extensionId}/render?dark=${document.documentElement.classList.contains("dark")}&v=${encodeURIComponent(extension?.updatedAt ?? "")}&r=${refreshKey}`, ), [extensionId, extension?.updatedAt, refreshKey], ); const iframeSrcDoc = useMemo(() => { if (!extension?.content || !isEmbedMcpChatBridgeActive()) return undefined; return buildExtensionViewerSrcDoc(extension, isDark); }, [extension, isDark]); const unavailableStatus = extensionLoadErrorStatus( extensionError ?? extensionFailureReason, ); const latestFetchDenied = unavailableStatus === 401 || unavailableStatus === 403 || unavailableStatus === 404; useEffect(() => { setIframeReady(false); // Reset role to deny-by-default on every reload — the new render's // binding announcement re-establishes the role before any helper call. bridgeContextRef.current = { role: "viewer", isAuthor: false }; bindingLatchedRef.current = false; }, [extensionId, extension?.updatedAt, refreshKey]); const startRename = useCallback(() => { if (!extension) return; setRenameValue(extension.name); setIsRenaming(true); requestAnimationFrame(() => renameInputRef.current?.select()); }, [extension]); const submitRename = useCallback(async () => { const trimmed = renameValue.trim(); if (!trimmed || !extension || trimmed === extension.name) { setIsRenaming(false); return; } queryClient.setQueryData(["extension", extensionId], (old) => old ? { ...old, name: trimmed } : old, ); queryClient.setQueryData(["extensions"], (old) => (old ?? []).map((t) => t.id === extensionId ? { ...t, name: trimmed } : t, ), ); setIsRenaming(false); try { await fetch(agentNativePath(`/_agent-native/extensions/${extensionId}`), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: trimmed }), }); queryClient.invalidateQueries({ queryKey: ["extension", extensionId] }); queryClient.invalidateQueries({ queryKey: ["extensions"] }); } catch { queryClient.invalidateQueries({ queryKey: ["extension", extensionId] }); queryClient.invalidateQueries({ queryKey: ["extensions"] }); } }, [renameValue, extension, extensionId, queryClient]); if (isLoading || (!extension && isFetching)) { return (
); } if (isError && !latestFetchDenied) { return ( void refetch()} retrying={isFetching} /> ); } if (latestFetchDenied || !extension) { return ; } const isLocalExtension = extension.source?.mode === "local-files"; return (
Refresh {!isLocalExtension && ( <> setRefreshKey((k) => k + 1)} onOpenChange={onPopoverOpenChange} /> )} {!isLocalExtension && ( <> Extensions can be shared inside your organization only — they run with the viewer's credentials, so cross-org access isn't supported. } /> )}
{isLocalExtension && (
Repo-backed extension. Edit{" "} {extension.source?.entryPath ?? "extensions/*/index.html"} {" "} in your workspace, then refresh this preview.
)}
{!iframeReady && (
)}