History

feat(toolkits): tool-list policy editor + correct integration icons

Policy editor now lists the toolkit's actual tools (grouped integration -> connection -> tool) via the shared ToolTree, with a per-tool approve/require_approval/block menu, instead of free-text glob inputs. Setting an action stores a 3-segment integration.connection.tool pattern (matching the toolkit runtime matcher); ToolTree's 4-segment display form is bridged in/out. Integration icons across the page (cards, Access list, preview rail) now use the canonical IntegrationFavicon + integrationPresetIconUrl/integrationInferredUrl + useIntegrationPlugins pattern, so real favicons render instead of the generic box (the old sourceId-only call resolved only for the executor built-in).

Rhys Sullivan committed 4 months ago

Commit 07a31df1 parent 4c01bd61 file · +206 −89Browse files

Unified · Split

packages/plugins/toolkits/src/react/ToolkitsPage.tsx+206−89
@@ -16,9 +16,10 @@ import { useEffect, useMemo, useState } from "react"; · 15 unmodified lines
1616 import { useAtomSet, useAtomValue } from "@effect/atom-react";
1717 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
1818 import * as Exit from "effect/Exit";
19−import { usePluginNavigate, usePluginRoute } from "@executor-js/sdk/client";
19+import { useIntegrationPlugins, usePluginNavigate, usePluginRoute } from "@executor-js/sdk/client";
20+import { effectivePolicyFromSorted, PolicyId } from "@executor-js/sdk/shared";
2021
21−import { connectionsAllAtom, integrationsAtom } from "@executor-js/react/api/atoms";
22+import { connectionsAllAtom, integrationsAtom, toolsAllAtom } from "@executor-js/react/api/atoms";
2223 import { Button } from "@executor-js/react/components/button";
2324 import { Input } from "@executor-js/react/components/input";
2425 import { Textarea } from "@executor-js/react/components/textarea";
@@ -26,13 +27,12 @@ import { Switch } from "@executor-js/react/components/switch"; · 1 unmodified line
2627 import { Label } from "@executor-js/react/components/label";
2728 import { Badge } from "@executor-js/react/components/badge";
2829 import {
29− Select,
30− SelectContent,
31− SelectItem,
32− SelectTrigger,
33− SelectValue,
34−} from "@executor-js/react/components/select";
35−import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon";
30+ IntegrationFavicon,
31+ integrationInferredUrl,
32+ integrationPresetIconUrl,
33+} from "@executor-js/react/components/integration-favicon";
34+import { ToolTree, type ToolSummary } from "@executor-js/react/components/tool-tree";
35+import { toPolicyPattern } from "@executor-js/react/lib/policy-pattern";
3636 import { copyToClipboard } from "@executor-js/react/lib/clipboard";
3737 import { cn } from "@executor-js/react/lib/utils";
3838
@@ -65,23 +65,72 @@ import { · 26 unmodified lines
6565 // read, so this file doesn't pin a specific API success-type name.
6666 // ---------------------------------------------------------------------------
6767
68−interface Integ {
68+interface IntegrationInfo {
6969 readonly slug: string;
7070 readonly name: string;
71+ readonly kind: string;
72+ readonly displayUrl?: string;
7173 }
7274
75+type ToolRow = {
76+ readonly integration: string;
77+ readonly connection: string;
78+ readonly name: string;
79+ readonly description: string;
80+ readonly requiresApproval?: boolean;
81+};
82+
7383 const ACCESS_OPTIONS: ReadonlyArray<{ value: ToolkitAccess; label: string }> = [
7484 { value: "off", label: "Off" },
7585 { value: "read", label: "Read" },
7686 { value: "full", label: "Full" },
7787 ];
7888
79−const ACTION_LABEL: Record<ToolkitPolicyAction, string> = {
80− approve: "Auto-approve",
81− require_approval: "Needs approval",
82− block: "Block",
89+/** Toolkit rules match `integration.connection.tool` — undo ToolTree's 4-segment bridge. */
90+const fromToolTreePattern = (pattern: string): string => {
91+ const match = pattern.match(/^([^.]+)\.\*\.\*\.(.+)$/);
92+ if (match) return `${match[1]}.${match[2]}`;
93+ return pattern;
8394 };
8495
96+const sortToolkitPolicies = (
97+ policies: ReadonlyArray<ToolkitPolicy>,
98+): ReadonlyArray<ToolkitPolicy> =>
99+ [...policies].sort((a, b) => {
100+ const aWild = a.pattern.endsWith("*");
101+ const bWild = b.pattern.endsWith("*");
102+ if (aWild !== bWild) return aWild ? 1 : -1;
103+ return b.pattern.length - a.pattern.length;
104+ });
105+
106+function IntegrationIcon(props: {
107+ readonly slug: string;
108+ readonly size: number;
109+ readonly integration: IntegrationInfo | undefined;
110+ readonly integrationPlugins: ReturnType<typeof useIntegrationPlugins>;
111+}) {
112+ const name = props.integration?.name ?? props.slug;
113+ return (
114+ <IntegrationFavicon
115+ icon={integrationPresetIconUrl(
116+ {
117+ id: props.slug,
118+ kind: props.integration?.kind ?? "",
119+ name,
120+ url: props.integration?.displayUrl,
121+ },
122+ props.integrationPlugins,
123+ )}
124+ url={
125+ props.integration?.displayUrl ??
126+ integrationInferredUrl({ id: props.slug, name }) ??
127+ undefined
128+ }
129+ size={props.size}
130+ />
131+ );
132+}
133+
85134 // ---------------------------------------------------------------------------
86135 // Access segmented control (off / read / full)
87136 // ---------------------------------------------------------------------------
@@ -112,6 +161,8 @@ function AccessSeg(props: { value: ToolkitAccess; onChange: (v: ToolkitAccess) = · 24 unmodified lines
112161 function ToolkitCard(props: {
113162 toolkit: ToolkitView;
114163 integrationName: (slug: string) => string;
164+ integrationBySlug: ReadonlyMap<string, IntegrationInfo>;
165+ integrationPlugins: ReturnType<typeof useIntegrationPlugins>;
115166 onOpen: () => void;
116167 }) {
117168 const active = props.toolkit.connections.filter((c) => c.access !== "off");
@@ -138,7 +189,12 @@ function ToolkitCard(props: { · 20 unmodified lines
138189 key={slug}
139190 className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-0.5 text-[12px]"
140191 >
141− <IntegrationFavicon sourceId={slug} size={14} />
192+ <IntegrationIcon
193+ slug={slug}
194+ size={14}
195+ integration={props.integrationBySlug.get(slug)}
196+ integrationPlugins={props.integrationPlugins}
197+ />
142198 {props.integrationName(slug)}
143199 </span>
144200 ))
@@ -156,6 +212,8 @@ function ListSection(props: { · 11 unmodified lines
156212 empty: string;
157213 toolkits: ReadonlyArray<ToolkitView>;
158214 integrationName: (slug: string) => string;
215+ integrationBySlug: ReadonlyMap<string, IntegrationInfo>;
216+ integrationPlugins: ReturnType<typeof useIntegrationPlugins>;
159217 onOpen: (id: string) => void;
160218 onCreate: () => void;
161219 }) {
@@ -178,6 +236,8 @@ function ListSection(props: { · 16 unmodified lines
178236 key={tk.id}
179237 toolkit={tk}
180238 integrationName={props.integrationName}
239+ integrationBySlug={props.integrationBySlug}
240+ integrationPlugins={props.integrationPlugins}
181241 onOpen={() => props.onOpen(tk.id)}
182242 />
183243 ))}
@@ -197,6 +257,8 @@ interface EditorProps { · 13 unmodified lines
197257 readonly takenSlugs: ReadonlySet<string>;
198258 readonly connections: ReadonlyArray<Conn>;
199259 readonly integrationName: (slug: string) => string;
260+ readonly integrationBySlug: ReadonlyMap<string, IntegrationInfo>;
261+ readonly integrationPlugins: ReturnType<typeof useIntegrationPlugins>;
200262 readonly onBack: () => void;
201263 readonly onCreated: () => void;
202264 readonly onRemoved: () => void;
@@ -243,15 +305,86 @@ function ToolkitEditor(props: EditorProps) { · 40 unmodified lines
243305 setNotes((m) => ({ ...m, [key]: v }));
244306 mark();
245307 };
246− const setPolicy = (i: number, patch: Partial<ToolkitPolicy>) => {
247− setPolicies((ps) => ps.map((p, j) => (j === i ? { ...p, ...patch } : p)));
308+ const upsertPolicy = (pattern: string, action: ToolkitPolicyAction) => {
309+ const toolkitPattern = fromToolTreePattern(pattern);
310+ setPolicies((ps) => {
311+ const i = ps.findIndex((p) => p.pattern === toolkitPattern);
312+ if (i >= 0) return ps.map((p, j) => (j === i ? { ...p, action } : p));
313+ return [...ps, { pattern: toolkitPattern, action }];
314+ });
315+ mark();
316+ };
317+ const removePolicy = (pattern: string) => {
318+ const toolkitPattern = fromToolTreePattern(pattern);
319+ setPolicies((ps) => ps.filter((p) => p.pattern !== toolkitPattern));
248320 mark();
249321 };
250322
323+ const toolsResult = useAtomValue(toolsAllAtom);
324+
251325 // The connections this toolkit may draw on: workspace = org-owned only;
252326 // personal = org + the caller's own.
253327 const tiers = useMemo(() => tiersForScope(props.connections, scope), [props.connections, scope]);
254328
329+ const activeScopes = useMemo(() => {
330+ const pinned = new Map<string, Set<string>>();
331+ const wildcards = new Set<string>();
332+ for (const [key, a] of Object.entries(access)) {
333+ if (a === "off") continue;
334+ const space = key.indexOf(" ");
335+ const integration = key.slice(0, space);
336+ const connection = key.slice(space + 1);
337+ if (connection === "*") {
338+ wildcards.add(integration);
339+ } else {
340+ const conns = pinned.get(integration) ?? new Set<string>();
341+ conns.add(connection);
342+ pinned.set(integration, conns);
343+ }
344+ }
345+ return { pinned, wildcards };
346+ }, [access]);
347+
348+ const hasActiveConnections = useMemo(
349+ () => activeScopes.wildcards.size > 0 || activeScopes.pinned.size > 0,
350+ [activeScopes],
351+ );
352+
353+ const sortedPolicies = useMemo(() => sortToolkitPolicies(policies), [policies]);
354+ const treePolicies = useMemo(
355+ () =>
356+ sortedPolicies.map((p) => ({
357+ pattern: toPolicyPattern(p.pattern),
358+ action: p.action,
359+ })),
360+ [sortedPolicies],
361+ );
362+
363+ const toolSummaries: ReadonlyArray<ToolSummary> = useMemo(() => {
364+ if (!AsyncResult.isSuccess(toolsResult)) return [];
365+ const matchesTool = (t: ToolRow) => {
366+ if (activeScopes.wildcards.has(t.integration)) return true;
367+ const conns = activeScopes.pinned.get(t.integration);
368+ return conns?.has(t.connection) ?? false;
369+ };
370+ const rows: ToolSummary[] = [];
371+ for (const tool of toolsResult.value as readonly ToolRow[]) {
372+ if (!matchesTool(tool)) continue;
373+ const id = `${tool.integration}.${tool.connection}.${tool.name}`;
374+ rows.push({
375+ id,
376+ name: id,
377+ description: tool.description,
378+ policy: effectivePolicyFromSorted(
379+ id,
380+ sortedPolicies.map((p) => ({ ...p, id: PolicyId.make(p.pattern) })),
381+ tool.requiresApproval,
382+ ),
383+ });
384+ }
385+ return rows;
386+ }, [toolsResult, activeScopes, sortedPolicies]);
387+
255388 const buildEntries = () => entriesFromAccess(props.connections, scope, access, notes);
256389
257390 const cleanPolicies = (): ReadonlyArray<ToolkitPolicy> =>
@@ -414,7 +547,12 @@ function ToolkitEditor(props: EditorProps) { · 156 unmodified lines
414547 groupByIntegration(tier.conns).map(([intSlug, conns]) => (
415548 <div key={intSlug} className="px-4 pb-2 pt-1.5">
416549 <div className="flex items-center gap-2 pb-1">
417− <IntegrationFavicon sourceId={intSlug} size={16} />
550+ <IntegrationIcon
551+ slug={intSlug}
552+ size={16}
553+ integration={props.integrationBySlug.get(intSlug)}
554+ integrationPlugins={props.integrationPlugins}
555+ />
418556 <span className="text-[12.5px] font-semibold text-foreground">
419557 {props.integrationName(intSlug)}
420558 </span>
@@ -491,70 +629,33 @@ function ToolkitEditor(props: EditorProps) { · 70 unmodified lines
491629 : "Workspace guardrails are off — this toolkit is governed only by its own rules below."}
492630 </p>
493631 </div>
494− <div className="border-t border-border px-4 py-2.5">
495− <div className="flex items-center gap-2 pb-1">
496− <span className="text-[12.5px] font-semibold text-foreground">Toolkit rules</span>
497− <span className="ml-auto text-[11px] text-muted-foreground">
498− {policies.length} {policies.length === 1 ? "rule" : "rules"}
499− </span>
500− </div>
501− {policies.map((p, i) => (
502− <div key={i} className="flex flex-wrap items-center gap-2 py-1.5">
503− <Input
504− value={p.pattern}
505− onChange={(e) =>
506− setPolicy(i, {
507− pattern: (e.target as HTMLInputElement).value,
508− })
509− }
510− placeholder="slack.*.post_message"
511− className="h-8 min-w-[190px] flex-1 font-mono text-[12px]"
512− />
513− <Select
514− value={p.action}
515− onValueChange={(v) => setPolicy(i, { action: v as ToolkitPolicyAction })}
516− >
517− <SelectTrigger className="h-8 w-[150px] text-[12px]">
518− <SelectValue />
519− </SelectTrigger>
520− <SelectContent>
521− <SelectItem value="approve">{ACTION_LABEL.approve}</SelectItem>
522− <SelectItem value="require_approval">
523− {ACTION_LABEL.require_approval}
524− </SelectItem>
525− <SelectItem value="block">{ACTION_LABEL.block}</SelectItem>
526− </SelectContent>
527− </Select>
528− <Button
529− type="button"
530− size="sm"
531− variant="ghost"
532− className="h-8 px-2 text-destructive/70 hover:text-destructive"
533− onClick={() => {
534− setPolicies((ps) => ps.filter((_, j) => j !== i));
535− mark();
536− }}
537− >
538− ✕
539− </Button>
540− </div>
541− ))}
542− <div className="flex items-center gap-3 pt-1">
543− <Button
544− type="button"
545− size="sm"
546− variant="ghost"
547− onClick={() => {
548− setPolicies((ps) => [...ps, { pattern: "", action: "approve" }]);
549− mark();
550− }}
551− >
552− + Add rule
553− </Button>
554− <span className="text-[11px] text-muted-foreground">
555− Block hides matching tools entirely · approve / needs-approval set gating.
556− </span>
557− </div>
632+ <div className="border-t border-border">
633+ {!hasActiveConnections ? (
634+ <p className="px-4 py-3 text-[12px] text-muted-foreground">
635+ Add a connection above to set tool policies.
636+ </p>
637+ ) : (
638+ AsyncResult.match(toolsResult, {
639+ onInitial: () => (
640+ <p className="px-4 py-3 text-[12px] text-muted-foreground">Loading tools…</p>
641+ ),
642+ onFailure: () => (
643+ <p className="px-4 py-3 text-[12px] text-destructive">Failed to load tools</p>
644+ ),
645+ onSuccess: () => (
646+ <div className="h-80 min-h-0">
647+ <ToolTree
648+ tools={toolSummaries}
649+ selectedToolId={null}
650+ onSelect={() => {}}
651+ policies={treePolicies}
652+ onSetPolicy={upsertPolicy}
653+ onClearPolicy={removePolicy}
654+ />
655+ </div>
656+ ),
657+ })
658+ )}
558659 </div>
559660 </div>
560661
@@ -629,7 +730,12 @@ function ToolkitEditor(props: EditorProps) { · 68 unmodified lines
629730 {previewGroups.map(([intSlug, conns]) => (
630731 <div key={intSlug}>
631732 <div className="flex items-center gap-2">
632− <IntegrationFavicon sourceId={intSlug} size={15} />
733+ <IntegrationIcon
734+ slug={intSlug}
735+ size={15}
736+ integration={props.integrationBySlug.get(intSlug)}
737+ integrationPlugins={props.integrationPlugins}
738+ />
633739 <span className="text-[12.5px] font-semibold text-foreground">
634740 {props.integrationName(intSlug)}
635741 </span>
@@ -694,6 +800,7 @@ function viewFromSubpath(subpath: string): View { · 58 unmodified lines
694800 export function ToolkitsPage() {
695801 const { subpath } = usePluginRoute();
696802 const navigate = usePluginNavigate();
803+ const integrationPlugins = useIntegrationPlugins();
697804 const view = viewFromSubpath(subpath);
698805 const toolkitsResult = useAtomValue(toolkitsAtom);
699806 const connectionsResult = useAtomValue(connectionsAllAtom);
@@ -716,18 +823,22 @@ export function ToolkitsPage() { · 16 unmodified lines
716823 },
717824 );
718825 const integrations = AsyncResult.match(
719− integrationsResult as AsyncResult.AsyncResult<ReadonlyArray<Integ>, unknown>,
826+ integrationsResult as AsyncResult.AsyncResult<ReadonlyArray<IntegrationInfo>, unknown>,
720827 {
721− onInitial: () => [] as ReadonlyArray<Integ>,
722− onFailure: () => [] as ReadonlyArray<Integ>,
828+ onInitial: () => [] as ReadonlyArray<IntegrationInfo>,
829+ onFailure: () => [] as ReadonlyArray<IntegrationInfo>,
723830 onSuccess: ({ value }) => value,
724831 },
725832 );
726833
834+ const integrationBySlug = useMemo(
835+ () => new Map(integrations.map((i) => [i.slug, i])),
836+ [integrations],
837+ );
838+
727839 const integrationName = useMemo(() => {
728− const bySlug = new Map(integrations.map((i) => [i.slug, i.name]));
729− return (slug: string) => bySlug.get(slug) ?? slug;
730− }, [integrations]);
840+ return (slug: string) => integrationBySlug.get(slug)?.name ?? slug;
841+ }, [integrationBySlug]);
731842
732843 const takenSlugs = useMemo(() => new Set((toolkits ?? []).map((t) => t.slug)), [toolkits]);
733844
@@ -750,6 +861,8 @@ export function ToolkitsPage() { · 16 unmodified lines
750861 takenSlugs={takenSlugs}
751862 connections={connections}
752863 integrationName={integrationName}
864+ integrationBySlug={integrationBySlug}
865+ integrationPlugins={integrationPlugins}
753866 onBack={() => navigate("")}
754867 onCreated={() => navigate("")}
755868 onRemoved={() => navigate("")}
@@ -777,6 +890,8 @@ export function ToolkitsPage() { · 21 unmodified lines
777890 empty="No workspace toolkits yet — these draw on shared workspace connections."
778891 toolkits={workspace}
779892 integrationName={integrationName}
893+ integrationBySlug={integrationBySlug}
894+ integrationPlugins={integrationPlugins}
780895 onOpen={(id) => navigate(id)}
781896 onCreate={() => navigate("new/workspace")}
782897 />
@@ -785,6 +900,8 @@ export function ToolkitsPage() { · 2 unmodified lines
785900 empty="No personal toolkits yet — these can use workspace and personal connections."
786901 toolkits={personal}
787902 integrationName={integrationName}
903+ integrationBySlug={integrationBySlug}
904+ integrationPlugins={integrationPlugins}
788905 onOpen={(id) => navigate(id)}
789906 onCreate={() => navigate("new/personal")}
790907 />