diff --git a/packages/core/api/src/handlers/tools.ts b/packages/core/api/src/handlers/tools.ts index 04577ac91..0d3ad5d47 100644 --- a/packages/core/api/src/handlers/tools.ts +++ b/packages/core/api/src/handlers/tools.ts @@ -11,7 +11,14 @@ export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handler .handle("list", () => capture(Effect.gen(function* () { const executor = yield* ExecutorService; - const tools = yield* executor.tools.list(); + // Tools page is a management view — include policy-blocked tools + // (so users can unblock them) and load annotations so the row can + // show the plugin's default approval state when no user rule + // matches. Mirrors the per-source `sources.tools` handler. + const tools = yield* executor.tools.list({ + includeAnnotations: true, + includeBlocked: true, + }); return tools.map((t) => ({ id: ToolId.make(t.id), pluginId: t.pluginId, @@ -19,6 +26,7 @@ export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handler name: t.name, description: t.description, mayElicit: t.annotations?.mayElicit, + requiresApproval: t.annotations?.requiresApproval, })); })), ) diff --git a/packages/core/api/src/tools/api.ts b/packages/core/api/src/tools/api.ts index 812222884..78562dc4a 100644 --- a/packages/core/api/src/tools/api.ts +++ b/packages/core/api/src/tools/api.ts @@ -24,6 +24,7 @@ const ToolMetadataResponse = Schema.Struct({ name: Schema.String, description: Schema.optional(Schema.String), mayElicit: Schema.optional(Schema.Boolean), + requiresApproval: Schema.optional(Schema.Boolean), }); const ToolSchemaResponse = Schema.Struct({ diff --git a/packages/react/src/components/tool-detail.tsx b/packages/react/src/components/tool-detail.tsx index 806d36750..e290878e2 100644 --- a/packages/react/src/components/tool-detail.tsx +++ b/packages/react/src/components/tool-detail.tsx @@ -5,31 +5,31 @@ import { toolSchemaAtom } from "../api/atoms"; import { ScopeId, ToolId, type EffectivePolicy, type ToolPolicyAction } from "@executor-js/sdk"; import { Badge } from "./badge"; import { Button } from "./button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "./dropdown-menu"; import { Markdown } from "./markdown"; import { SchemaExplorer } from "./schema-explorer"; import { ExpandableCodeBlock } from "./expandable-code-block"; import { CardStack, CardStackHeader, CardStackContent } from "./card-stack"; import { CopyButton } from "./copy-button"; -import { ChevronRight } from "lucide-react"; - -const POLICY_LABEL: Record = { - approve: "Auto-approve", - require_approval: "Require approval", - block: "Blocked", -}; - -const POLICY_VARIANT: Record< - ToolPolicyAction, - "default" | "secondary" | "outline" | "destructive" -> = { - approve: "secondary", - require_approval: "outline", - block: "destructive", -}; +import { ChevronRight, ChevronDownIcon } from "lucide-react"; +import { cn } from "../lib/utils"; +import { + POLICY_ACTION_LABEL, + POLICY_ACTIONS_IN_ORDER, + POLICY_BADGE_VARIANT, + POLICY_STATE_LABEL, +} from "../lib/policy-display"; // Render the effective policy as a badge. User policies show the // matched pattern; plugin defaults read "Default: ". Silent for -// the auto-approve plugin default — that's the safe state and the +// the always-run plugin default — that's the safe state and the // header would just be noise. const policyBadgeFor = (policy: EffectivePolicy) => { if (policy.source === "plugin-default" && policy.action === "approve") { @@ -37,16 +37,16 @@ const policyBadgeFor = (policy: EffectivePolicy) => { } if (policy.source === "user") { return { - variant: POLICY_VARIANT[policy.action], + variant: POLICY_BADGE_VARIANT[policy.action], title: `Matched policy: ${policy.pattern}`, - text: `${POLICY_LABEL[policy.action]} · ${policy.pattern}`, + text: `${POLICY_STATE_LABEL[policy.action]} · ${policy.pattern}`, className: "font-mono text-[10px]", }; } return { variant: "outline" as const, title: "No matching policy — plugin default applies", - text: `Default: ${POLICY_LABEL[policy.action]}`, + text: `Default: ${POLICY_STATE_LABEL[policy.action]}`, className: "text-[10px] text-muted-foreground", }; }; @@ -94,6 +94,10 @@ export function ToolDetail(props: { /** Resolved effective policy — user-authored or plugin-default, * unified into one shape. Surfaces in the header. */ policy?: EffectivePolicy; + /** When provided, the policy badge becomes a dropdown trigger that + * applies a user rule to this tool's exact id. */ + onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; + onClearPolicy?: (pattern: string) => void; }) { const toolContract = useAtomValue(toolSchemaAtom(props.scopeId, props.toolId as ToolId)); const [tab, setTab] = useState<"schema" | "typescript">("schema"); @@ -136,16 +140,12 @@ export function ToolDetail(props: {

{displayName}

- {(() => { - if (!props.policy) return null; - const badge = policyBadgeFor(props.policy); - if (!badge) return null; - return ( - - {badge.text} - - ); - })()} +
{props.toolDescription && (
@@ -244,6 +244,99 @@ export function ToolDetail(props: { ); } +// --------------------------------------------------------------------------- +// PolicyBadgeMenu — clickable header badge that opens the same +// Always run / Require approval / Block / Clear menu the tree row uses. +// Falls back to a plain Badge when no actions are provided. +// --------------------------------------------------------------------------- + +function PolicyBadgeMenu(props: { + toolName: string; + policy?: EffectivePolicy; + onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; + onClearPolicy?: (pattern: string) => void; +}) { + const interactive = !!props.onSetPolicy; + // The "Clear" affordance only makes sense when there's a user rule + // pinned to this exact tool id — clearing a wildcard rule from a + // single tool's detail header would silently affect siblings. + const hasExactUserRule = + props.policy?.source === "user" && props.policy.pattern === props.toolName; + const currentAction = hasExactUserRule ? props.policy?.action : undefined; + + if (!interactive) { + if (!props.policy) return null; + const badge = policyBadgeFor(props.policy); + if (!badge) return null; + return ( + + {badge.text} + + ); + } + + // Interactive trigger always renders, even when the effective policy + // would otherwise be "silent" (auto-approve plugin-default), so the + // user can click it to override. + const badge = props.policy ? policyBadgeFor(props.policy) : null; + const triggerLabel = badge?.text ?? "Set policy"; + const triggerVariant = badge?.variant ?? "outline"; + const triggerTitle = badge?.title ?? "Set policy"; + const triggerClassName = badge?.className ?? "text-[10px] text-muted-foreground"; + + return ( + + + + + + {props.toolName} + + {POLICY_ACTIONS_IN_ORDER.map((action) => ( + props.onSetPolicy?.(props.toolName, action)} + > + {POLICY_ACTION_LABEL[action]} + {currentAction === action && ( + + ✓ + + )} + + ))} + {hasExactUserRule && props.onClearPolicy && ( + <> + + props.onClearPolicy?.(props.toolName)} + className="text-muted-foreground" + > + Clear + + + )} + + + ); +} + // --------------------------------------------------------------------------- // Empty state // --------------------------------------------------------------------------- diff --git a/packages/react/src/components/tool-tree.tsx b/packages/react/src/components/tool-tree.tsx index 522e0ceca..85a8ac622 100644 --- a/packages/react/src/components/tool-tree.tsx +++ b/packages/react/src/components/tool-tree.tsx @@ -1,9 +1,23 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { ChevronRightIcon, SearchIcon, XIcon } from "lucide-react"; +import { ChevronRightIcon, MoreHorizontalIcon, SearchIcon, XIcon } from "lucide-react"; import type { EffectivePolicy, ToolPolicyAction } from "@executor-js/sdk"; import { Button } from "./button"; import { Input } from "./input"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "./dropdown-menu"; import { cn } from "../lib/utils"; +import { + POLICY_ACTION_LABEL, + POLICY_ACTIONS_IN_ORDER, + POLICY_INDICATOR_COLOR, + POLICY_STATE_LABEL, +} from "../lib/policy-display"; // --------------------------------------------------------------------------- // Types @@ -20,30 +34,7 @@ export interface ToolSummary { readonly policy: EffectivePolicy; } -// Color + label for the per-row policy indicator. Mirrors the badges on -// the /policies page so the same action looks the same everywhere. -const POLICY_INDICATOR: Record< - ToolPolicyAction, - { readonly label: string; readonly dot: string; readonly ring: string } -> = { - approve: { - label: "Auto-approve", - dot: "bg-emerald-500", - ring: "ring-emerald-500/70", - }, - require_approval: { - label: "Require approval", - dot: "bg-amber-500", - ring: "ring-amber-500/70", - }, - block: { - label: "Blocked", - dot: "bg-destructive", - ring: "ring-destructive/70", - }, -}; - -// What the dot looks like for a given effective policy. Auto-approve as +// What the dot looks like for a given effective policy. Always-run as // a plugin default is silent (the safe state — no point cluttering every // row); everything else gets a dot. User policies are filled, plugin // defaults are hollow rings. @@ -51,17 +42,16 @@ const indicatorFor = (policy: EffectivePolicy) => { if (policy.source === "plugin-default" && policy.action === "approve") { return null; } - const ind = POLICY_INDICATOR[policy.action]; + const colors = POLICY_INDICATOR_COLOR[policy.action]; + const stateLabel = POLICY_STATE_LABEL[policy.action]; const filled = policy.source === "user"; const label = policy.source === "user" - ? `${ind.label} (matched ${policy.pattern})` - : `Plugin default: ${ind.label}`; + ? `${stateLabel} (matched ${policy.pattern})` + : `Plugin default: ${stateLabel}`; return { label, - className: filled - ? ind.dot - : cn("bg-transparent ring-1", ind.ring), + className: filled ? colors.dot : cn("bg-transparent ring-1", colors.ring), }; }; @@ -224,8 +214,24 @@ export function ToolTree(props: { tools: readonly ToolSummary[]; selectedToolId: string | null; onSelect: (toolId: string) => void; + /** When provided, each row gets a hover-revealed action menu that + * applies (or clears) a user policy for that exact node. Leaf rows + * emit the tool's full dotted id; group rows emit `prefix.*`. */ + onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; + onClearPolicy?: (pattern: string) => void; + /** Sorted user-authored policies (most-precedent first). Used to + * decide whether a node has its own exact-pattern user rule today + * (so the menu can show a "Clear" option). Optional — when absent, + * the menu hides "Clear". */ + policies?: ReadonlyArray<{ readonly pattern: string; readonly action: ToolPolicyAction }>; }) { - const { tools, selectedToolId, onSelect } = props; + const { tools, selectedToolId, onSelect, onSetPolicy, onClearPolicy, policies } = props; + const exactPatterns = useMemo(() => { + if (!policies) return new Map(); + const m = new Map(); + for (const p of policies) m.set(p.pattern, p.action); + return m; + }, [policies]); const [search, setSearch] = useState(""); const [manualOpen, setManualOpen] = useState>(() => new Set()); const searchRef = useRef(null); @@ -342,16 +348,23 @@ export function ToolTree(props: { active={row.tool.id === selectedToolId} onSelect={() => onSelect(row.tool.id)} search={search} + onSetPolicy={onSetPolicy} + onClearPolicy={onClearPolicy} + exactRule={exactPatterns.get(row.tool.name)} /> ) : ( toggleGroup(row.path)} search={search} + onSetPolicy={onSetPolicy} + onClearPolicy={onClearPolicy} + exactRule={exactPatterns.get(`${row.path}.*`)} /> ), ) @@ -370,34 +383,127 @@ const rowIndent = (depth: number) => 12 + depth * 16; const rowBaseClasses = "relative flex h-auto w-full items-center justify-start gap-2 rounded-none py-2 text-xs font-normal transition-[background-color] duration-150"; +function PolicyActionMenu(props: { + pattern: string; + /** Action of an existing user rule with this exact pattern, if any. + * When set, the menu shows a "Clear" option and marks the active + * action with a check. */ + current?: ToolPolicyAction; + onSet: (pattern: string, action: ToolPolicyAction) => void; + onClear?: (pattern: string) => void; + align?: "start" | "end"; + /** When true, the trigger is rendered. Otherwise children are not + * emitted at all and the row falls back to its plain layout. */ + triggerLabel: string; + triggerClassName?: string; +}) { + return ( + + + + + e.stopPropagation()} + > + {props.pattern} + + {POLICY_ACTIONS_IN_ORDER.map((action) => ( + props.onSet(props.pattern, action)} + > + {POLICY_ACTION_LABEL[action]} + {props.current === action && ( + + ✓ + + )} + + ))} + {props.current && props.onClear && ( + <> + + props.onClear?.(props.pattern)} + className="text-muted-foreground" + > + Clear + + + )} + + + ); +} + function ToolGroupRow(props: { + path: string; segment: string; depth: number; count: number; open: boolean; onToggle: () => void; search: string; + onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; + onClearPolicy?: (pattern: string) => void; + exactRule?: ToolPolicyAction; }) { + const showActions = !!props.onSetPolicy; return ( - +
+ + {showActions && ( +
+ +
+ )} +
); } @@ -408,33 +514,65 @@ function ToolLeafRow(props: { active: boolean; onSelect: () => void; search: string; + onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void; + onClearPolicy?: (pattern: string) => void; + exactRule?: ToolPolicyAction; }) { const label = props.tool.name.split(".").pop() ?? props.tool.name; const indicator = indicatorFor(props.tool.policy); + const showActions = !!props.onSetPolicy; return ( - + {showActions && ( +
+ +
)} - +
); } diff --git a/packages/react/src/hooks/use-policy-actions.ts b/packages/react/src/hooks/use-policy-actions.ts new file mode 100644 index 000000000..fbf8d8365 --- /dev/null +++ b/packages/react/src/hooks/use-policy-actions.ts @@ -0,0 +1,128 @@ +import { useCallback, useMemo } from "react"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { generateKeyBetween } from "fractional-indexing"; +import { PolicyId, type ScopeId, type ToolPolicyAction } from "@executor-js/sdk"; + +import { + createPolicyOptimistic, + policiesOptimisticAtom, + removePolicyOptimistic, + updatePolicyOptimistic, +} from "../api/atoms"; +import { policyWriteKeys } from "../api/reactivity-keys"; + +// Specificity score for ordering. Higher = more specific = should sit at a +// lower position-key (higher precedence). New rules are auto-placed below +// any more-specific existing rules so a freshly-added group rule never +// silently shadows an existing leaf rule. +// `*` → 0 +// `vercel.*` → 2 (1 literal segment, wildcard) +// `vercel.dns.*` → 4 (2 literal segments, wildcard) +// `vercel.dns` → 5 (2 literal segments, exact — beats same-prefix wildcard) +// `vercel.dns.create` → 7 (3 literal segments, exact) +const specificity = (pattern: string): number => { + if (pattern === "*") return 0; + if (pattern.endsWith(".*")) { + const prefix = pattern.slice(0, -2); + return prefix.split(".").length * 2; + } + return pattern.split(".").length * 2 + 1; +}; + +export interface PolicyAction { + /** Set the action on a pattern. If a user rule with this exact pattern + * already exists, update it. Otherwise create with auto-placed + * position so more-specific rules keep precedence. */ + readonly set: (pattern: string, action: ToolPolicyAction) => Promise; + /** Remove the user rule with this exact pattern, if any. No-op if none. */ + readonly clear: (pattern: string) => Promise; + /** True while a write is in flight. */ + readonly busy: boolean; +} + +export const usePolicyActions = (scopeId: ScopeId): PolicyAction => { + const policies = useAtomValue(policiesOptimisticAtom(scopeId)); + const doCreate = useAtomSet(createPolicyOptimistic(scopeId), { mode: "promise" }); + const doUpdate = useAtomSet(updatePolicyOptimistic(scopeId), { mode: "promise" }); + const doRemove = useAtomSet(removePolicyOptimistic(scopeId), { mode: "promise" }); + + // Sorted by position ASC (lowest position = highest precedence first), + // matching server evaluation order. Optimistic placeholder rows carry + // `position: ""` and sort to the very top — that's fine for lookup but + // they're skipped when computing insert position. + const sorted = useMemo(() => { + if (!AsyncResult.isSuccess(policies)) return [] as ReadonlyArray<{ + readonly id: string; + readonly pattern: string; + readonly action: ToolPolicyAction; + readonly position: string; + }>; + return [...policies.value].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; + }); + }, [policies]); + + const busy = policies.waiting; + + const computePosition = useCallback( + (newPattern: string): string | undefined => { + const committed = sorted.filter((r) => r.position !== ""); + if (committed.length === 0) return undefined; + const newScore = specificity(newPattern); + // Walk down the list (most-precedent first); place the new rule + // just before the first existing rule whose specificity is <= the + // new one. That way more-specific rules stay above us, and we win + // against everything equally or less specific. + let idx = committed.findIndex((r) => specificity(r.pattern) <= newScore); + if (idx === -1) idx = committed.length; // append at bottom + const prev = idx === 0 ? null : committed[idx - 1]!.position; + const next = idx === committed.length ? null : committed[idx]!.position; + return generateKeyBetween(prev, next); + }, + [sorted], + ); + + const findExact = useCallback( + (pattern: string) => sorted.find((r) => r.pattern === pattern && r.position !== ""), + [sorted], + ); + + const set = useCallback( + async (pattern: string, action: ToolPolicyAction) => { + const existing = findExact(pattern); + if (existing) { + if (existing.action === action) return; + await doUpdate({ + params: { scopeId, policyId: PolicyId.make(existing.id) }, + payload: { action }, + reactivityKeys: policyWriteKeys, + }); + return; + } + const position = computePosition(pattern); + await doCreate({ + params: { scopeId }, + payload: position === undefined ? { pattern, action } : { pattern, action, position }, + reactivityKeys: policyWriteKeys, + }); + }, + [scopeId, doCreate, doUpdate, findExact, computePosition], + ); + + const clear = useCallback( + async (pattern: string) => { + const existing = findExact(pattern); + if (!existing) return; + await doRemove({ + params: { scopeId, policyId: PolicyId.make(existing.id) }, + reactivityKeys: policyWriteKeys, + }); + }, + [scopeId, doRemove, findExact], + ); + + return { set, clear, busy }; +}; diff --git a/packages/react/src/lib/policy-display.ts b/packages/react/src/lib/policy-display.ts new file mode 100644 index 000000000..d0f189dde --- /dev/null +++ b/packages/react/src/lib/policy-display.ts @@ -0,0 +1,50 @@ +// Shared display strings + colors for tool policy actions. Three views +// (Tools tree row dot, Tool detail header badge, Policies page row badge +// + select) need to render the same action consistently — keeping the +// labels here lets a rename ("Auto-approve" → "Always run") happen in +// one place. Splitting `state` vs `action` labels because `block` reads +// as "Blocked" when describing current state, "Block" as a verb in menus. + +import type { ToolPolicyAction } from "@executor-js/sdk"; + +/** Verb form — menus, select items, "what should this rule do". */ +export const POLICY_ACTION_LABEL: Record = { + approve: "Always run", + require_approval: "Require approval", + block: "Block", +}; + +/** State form — badges, indicator tooltips, "what is the current + * state". Diverges from the verb form for `block` only. */ +export const POLICY_STATE_LABEL: Record = { + ...POLICY_ACTION_LABEL, + block: "Blocked", +}; + +/** Badge variant per action — semantic color via the Badge component. */ +export const POLICY_BADGE_VARIANT: Record< + ToolPolicyAction, + "default" | "secondary" | "outline" | "destructive" +> = { + approve: "secondary", + require_approval: "outline", + block: "destructive", +}; + +/** Dot + ring color classes for the per-row indicator in `ToolTree`. + * Filled dot = user-authored rule; ring-only = plugin default. */ +export const POLICY_INDICATOR_COLOR: Record< + ToolPolicyAction, + { readonly dot: string; readonly ring: string } +> = { + approve: { dot: "bg-emerald-500", ring: "ring-emerald-500/70" }, + require_approval: { dot: "bg-amber-500", ring: "ring-amber-500/70" }, + block: { dot: "bg-destructive", ring: "ring-destructive/70" }, +}; + +/** Canonical display order for select items / menu options. */ +export const POLICY_ACTIONS_IN_ORDER: ReadonlyArray = [ + "approve", + "require_approval", + "block", +]; diff --git a/packages/react/src/pages/policies.tsx b/packages/react/src/pages/policies.tsx index be290d64e..ca7bfe694 100644 --- a/packages/react/src/pages/policies.tsx +++ b/packages/react/src/pages/policies.tsx @@ -15,6 +15,11 @@ import { policyWriteKeys } from "../api/reactivity-keys"; import { useScope } from "../hooks/use-scope"; import { badgeVariants } from "../components/badge"; import { cn } from "../lib/utils"; +import { + POLICY_ACTION_LABEL, + POLICY_ACTIONS_IN_ORDER, + POLICY_BADGE_VARIANT, +} from "../lib/policy-display"; import { Button } from "../components/button"; import { CardStack, @@ -58,25 +63,6 @@ const comparePolicy = (posA: string, idA: string, posB: string, idB: string): nu return 0; }; -// --------------------------------------------------------------------------- -// Action display -// --------------------------------------------------------------------------- - -const actionLabels: Record = { - approve: "Auto-approve", - require_approval: "Require approval", - block: "Block", -}; - -const actionVariants: Record< - ToolPolicyAction, - "default" | "secondary" | "outline" | "destructive" -> = { - approve: "secondary", - require_approval: "outline", - block: "destructive", -}; - // --------------------------------------------------------------------------- // Pattern matcher (mirrors `matchPattern` in @executor-js/sdk) — used for the // live "this rule matches N tools" preview without a server round-trip. @@ -160,9 +146,11 @@ function AddPolicyForm(props: { - {actionLabels.approve} - {actionLabels.require_approval} - {actionLabels.block} + {POLICY_ACTIONS_IN_ORDER.map((a) => ( + + {POLICY_ACTION_LABEL[a]} + + ))} @@ -207,18 +195,20 @@ function PolicyRow(props: { - {actionLabels[props.policy.action]} + {POLICY_ACTION_LABEL[props.policy.action]} - {actionLabels.approve} - {actionLabels.require_approval} - {actionLabels.block} + {POLICY_ACTIONS_IN_ORDER.map((a) => ( + + {POLICY_ACTION_LABEL[a]} + + ))} diff --git a/packages/react/src/pages/source-detail.tsx b/packages/react/src/pages/source-detail.tsx index 85acaa5ee..0ff193361 100644 --- a/packages/react/src/pages/source-detail.tsx +++ b/packages/react/src/pages/source-detail.tsx @@ -16,6 +16,7 @@ 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 type { SourcePlugin } from "../plugins/source-plugin"; import { Button } from "../components/button"; import { Badge } from "../components/badge"; @@ -34,6 +35,7 @@ export function SourceDetailPage(props: { const refreshTools = useAtomRefresh(sourceToolsAtom(namespace, scopeId)); const doRemove = useAtomSet(removeSource, { mode: "promise" }); const doRefresh = useAtomSet(refreshSource, { mode: "promise" }); + const policyActions = usePolicyActions(scopeId); const navigate = useNavigate(); // HMR: refresh source tools when the backend is hot-reloaded @@ -74,6 +76,16 @@ export function SourceDetailPage(props: { [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( @@ -85,7 +97,10 @@ export function SourceDetailPage(props: { readonly requiresApproval?: boolean; }) => ({ id: t.id, - name: t.name, + // 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), @@ -247,6 +262,9 @@ export function SourceDetailPage(props: { tools={sourceTools} selectedToolId={selectedToolId} onSelect={setSelectedToolId} + onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} + onClearPolicy={(pattern) => void policyActions.clear(pattern)} + policies={sortedPolicies} /> @@ -259,6 +277,8 @@ export function SourceDetailPage(props: { toolDescription={selectedTool.description} scopeId={scopeId} policy={selectedTool.policy} + onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)} + onClearPolicy={(pattern) => void policyActions.clear(pattern)} /> ) : ( 0} /> diff --git a/packages/react/src/pages/tools.tsx b/packages/react/src/pages/tools.tsx index 60d822301..ac0948ff9 100644 --- a/packages/react/src/pages/tools.tsx +++ b/packages/react/src/pages/tools.tsx @@ -1,79 +1,158 @@ +import { useMemo, useState } from "react"; +import { Link } from "@tanstack/react-router"; import { useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { toolsAtom } from "../api/atoms"; +import { effectivePolicyFromSorted } from "@executor-js/sdk"; + +import { policiesOptimisticAtom, toolsAtom } from "../api/atoms"; import { useScope } from "../hooks/use-scope"; -import { Badge } from "../components/badge"; +import { usePolicyActions } from "../hooks/use-policy-actions"; +import { ToolTree, type ToolSummary } from "../components/tool-tree"; +import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail"; +import { Button } from "../components/button"; +import { Skeleton } from "../components/skeleton"; export function ToolsPage() { const scopeId = useScope(); const tools = useAtomValue(toolsAtom(scopeId)); + const policies = useAtomValue(policiesOptimisticAtom(scopeId)); + const policyActions = usePolicyActions(scopeId); + + const [selectedToolId, setSelectedToolId] = useState(null); + + 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 summaries: 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 + // (`stripe_api.account.getAccount`), not the short `t.name` + // which strips the source prefix and would never match at + // resolution time. + name: t.id, + pluginKey: t.pluginId, + description: t.description, + policy: effectivePolicyFromSorted(t.id, sortedPolicies, t.requiresApproval), + }), + ); + }, [tools, sortedPolicies]); + + const selectedTool = useMemo( + () => summaries.find((t) => t.id === selectedToolId) ?? null, + [summaries, selectedToolId], + ); return ( -
-
- {/* Header */} -
-
-

- Tools -

-

- All registered tools across your connected sources. -

-
+
+ {/* Header bar */} +
+
+

Tools

+ {AsyncResult.isSuccess(tools) && ( + + {summaries.length} {summaries.length === 1 ? "tool" : "tools"} + + )} +
+
+
+
- {AsyncResult.match(tools, { - onInitial: () =>

Loading tools…

, - onFailure: () =>

Failed to load tools

, - onSuccess: ({ value }) => - value.length === 0 ? ( -
-
- - - -
-

No tools registered

-

+ {AsyncResult.match(tools, { + onInitial: () => , + onFailure: () =>

Failed to load tools
, + onSuccess: () => + summaries.length === 0 ? ( +
+
+

No tools registered

+

Add a source to start discovering tools.

- ) : ( -
- {value.map( - (t: { - readonly id: string; - readonly name: string; - readonly description?: string; - readonly sourceId: string; - }) => ( -
-
-

- {t.name} -

- {t.description && ( -

- {t.description} -

- )} -
- {t.sourceId} -
- ), +
+ ) : ( +
+
+ void policyActions.set(pattern, action)} + onClearPolicy={(pattern) => void policyActions.clear(pattern)} + policies={sortedPolicies} + /> +
+
+ {selectedTool ? ( + void policyActions.set(pattern, action)} + onClearPolicy={(pattern) => void policyActions.clear(pattern)} + /> + ) : ( + 0} /> )}
- ), - })} +
+ ), + })} +
+ ); +} + +function ToolsPageSkeleton() { + return ( +
+
+ + {Array.from({ length: 8 }).map((_, i) => ( +
+ + +
+ ))} +
+
+
+ + + +
+
+ + + + +
);