import { Suspense, useEffect, useMemo, useState } from "react"; import { useNavigate } from "@tanstack/react-router"; import { useAtomValue, useAtomSet, useAtomRefresh } from "@effect/atom-react"; import * as Exit from "effect/Exit"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { effectivePolicyFromSorted } from "@executor-js/sdk"; import { policiesOptimisticAtom, sourceToolsAtom, sourcesOptimisticAtom, sourceAtom, removeSourceOptimistic, refreshSource, } from "../api/atoms"; import { sourceWriteKeys } from "../api/reactivity-keys"; import { ToolTree } from "../components/tool-tree"; import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail"; import type { ToolSummary } from "../components/tool-tree"; import { useScope } from "../hooks/use-scope"; import { usePolicyActions } from "../hooks/use-policy-actions"; import { useSourcePlugins } from "@executor-js/sdk/client"; import { Button } from "../components/button"; import { Badge } from "../components/badge"; import { Skeleton } from "../components/skeleton"; export function SourceDetailPage(props: { namespace: string }) { const { namespace } = props; const sourcePlugins = useSourcePlugins(); const scopeId = useScope(); const source = useAtomValue(sourceAtom(namespace, scopeId)); const tools = useAtomValue(sourceToolsAtom(namespace, scopeId)); const policies = useAtomValue(policiesOptimisticAtom(scopeId)); const refreshSources = useAtomRefresh(sourcesOptimisticAtom(scopeId)); const refreshTools = useAtomRefresh(sourceToolsAtom(namespace, scopeId)); const doRemove = useAtomSet(removeSourceOptimistic(scopeId), { mode: "promiseExit" }); const doRefresh = useAtomSet(refreshSource, { mode: "promiseExit" }); const policyActions = usePolicyActions(scopeId); const navigate = useNavigate(); // HMR: refresh source tools when the backend is hot-reloaded useEffect(() => { if (!import.meta.hot) return; const refresh = () => { refreshTools(); refreshSources(); }; import.meta.hot.on("executor:backend-updated", refresh); return () => { import.meta.hot?.off("executor:backend-updated", refresh); }; }, [refreshTools, refreshSources]); const [selectedToolId, setSelectedToolId] = useState(null); const [confirmDelete, setConfirmDelete] = useState(false); const [deleting, setDeleting] = useState(false); const [refreshing, setRefreshing] = useState(false); const [editing, setEditing] = useState(false); useEffect(() => { setConfirmDelete(false); }, [namespace]); const sourceData = AsyncResult.isSuccess(source) ? source.value : null; const sourceOwnerScopeId = sourceData?.scopeId ?? scopeId; const canRefresh = sourceData ? (sourceData.canRefresh ?? true) : false; const canRemove = sourceData ? (sourceData.canRemove ?? true) : false; const canEdit = sourceData ? (sourceData.canEdit ?? false) : false; // Find the plugin edit component based on source kind const editPlugin = useMemo(() => { if (!sourceData) return null; return sourcePlugins.find((p) => p.key === sourceData.kind) ?? null; }, [sourceData, sourcePlugins]); // Policies are pre-sorted by the server in evaluation order // (innermost scope first, then position ASC). The matcher walks the // list and stops at the first hit, mirroring server-side resolution. const policyList = useMemo( () => (AsyncResult.isSuccess(policies) ? policies.value : []), [policies], ); const sortedPolicies = useMemo( () => [...policyList].sort((a, b) => { if (a.position < b.position) return -1; if (a.position > b.position) return 1; return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; }), [policyList], ); const sourceTools: ToolSummary[] = useMemo(() => { if (!AsyncResult.isSuccess(tools)) return []; return tools.value.map( (t: { readonly id: string; readonly name: string; readonly pluginId: string; readonly description?: string; readonly requiresApproval?: boolean; }) => ({ id: t.id, // Tree path + saved pattern must be the canonical tool id, so // policy rules created from the row actually match at resolve // time. The leaf label is still the short last segment. name: t.id, pluginKey: t.pluginId, description: t.description, policy: effectivePolicyFromSorted(t.id, policyList, t.requiresApproval), }), ); }, [tools, policyList]); const selectedTool = useMemo( () => sourceTools.find((t) => t.id === selectedToolId) ?? null, [sourceTools, selectedToolId], ); const handleDelete = async () => { if (!sourceData) return; setDeleting(true); const exit = await doRemove({ params: { scopeId: sourceOwnerScopeId, sourceId: namespace }, reactivityKeys: sourceWriteKeys, }); if (Exit.isFailure(exit)) { setDeleting(false); setConfirmDelete(false); return; } void navigate({ to: "/" }); }; const handleRefresh = async () => { if (!sourceData) return; setRefreshing(true); await doRefresh({ params: { scopeId: sourceOwnerScopeId, sourceId: namespace }, reactivityKeys: sourceWriteKeys, }); setRefreshing(false); }; const handleEditSave = () => { setEditing(false); }; return (
{/* Header bar */}

{sourceData?.name ?? namespace}

{sourceData?.runtime && ( built-in )} {sourceData?.kind ?? "source"} {AsyncResult.isSuccess(tools) && !editing && ( {sourceTools.length} {sourceTools.length === 1 ? "tool" : "tools"} )}
{canEdit && editPlugin && !editing && !confirmDelete && ( )} {editing && ( )} {canRefresh && !editing && ( )} {canRemove && !editing && (confirmDelete ? (
) : ( ))}
{/* Edit view */} {editing && editPlugin ? (
}>
) : ( <> {editPlugin?.summary && ( setEditing(true)} /> )} {/* Content -- split pane */} {AsyncResult.match(tools, { onInitial: () => , onFailure: () => (
Failed to load tools
), onSuccess: () => (
{/* Left: tool tree */}
void policyActions.set(pattern, action)} onClearPolicy={(pattern) => void policyActions.clear(pattern)} policies={sortedPolicies} />
{/* Right: tool detail */}
{selectedTool ? ( void policyActions.set(pattern, action)} onClearPolicy={(pattern) => void policyActions.clear(pattern)} /> ) : ( 0} /> )}
), })} )}
); } function SourceDetailSkeleton() { return (
{/* Left: tool tree skeleton */}
{Array.from({ length: 8 }).map((_, i) => (
))}
{/* Right: tool detail skeleton */}
); } function EditFormSkeleton() { return (
); }