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
16import { useAtomSet, useAtomValue } from "@effect/atom-react";16import { useAtomSet, useAtomValue } from "@effect/atom-react";
17import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";17import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
18import * as Exit from "effect/Exit";18import * as Exit from "effect/Exit";
19import { usePluginNavigate, usePluginRoute } from "@executor-js/sdk/client";19import { useIntegrationPlugins, usePluginNavigate, usePluginRoute } from "@executor-js/sdk/client";
20import { effectivePolicyFromSorted, PolicyId } from "@executor-js/sdk/shared";
20 21
21import { connectionsAllAtom, integrationsAtom } from "@executor-js/react/api/atoms";22import { connectionsAllAtom, integrationsAtom, toolsAllAtom } from "@executor-js/react/api/atoms";
22import { Button } from "@executor-js/react/components/button";23import { Button } from "@executor-js/react/components/button";
23import { Input } from "@executor-js/react/components/input";24import { Input } from "@executor-js/react/components/input";
24import { Textarea } from "@executor-js/react/components/textarea";25import { Textarea } from "@executor-js/react/components/textarea";
@@ -26,13 +27,12 @@ import { Switch } from "@executor-js/react/components/switch"; · 1 unmodified line
26import { Label } from "@executor-js/react/components/label";27import { Label } from "@executor-js/react/components/label";
27import { Badge } from "@executor-js/react/components/badge";28import { Badge } from "@executor-js/react/components/badge";
28import {29import {
29 Select,30 IntegrationFavicon,
30 SelectContent,31 integrationInferredUrl,
31 SelectItem,32 integrationPresetIconUrl,
32 SelectTrigger,33} from "@executor-js/react/components/integration-favicon";
33 SelectValue,34import { ToolTree, type ToolSummary } from "@executor-js/react/components/tool-tree";
34} from "@executor-js/react/components/select";35import { toPolicyPattern } from "@executor-js/react/lib/policy-pattern";
35import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon";
36import { copyToClipboard } from "@executor-js/react/lib/clipboard";36import { copyToClipboard } from "@executor-js/react/lib/clipboard";
37import { cn } from "@executor-js/react/lib/utils";37import { cn } from "@executor-js/react/lib/utils";
38 38
@@ -65,23 +65,72 @@ import { · 26 unmodified lines
65// read, so this file doesn't pin a specific API success-type name.65// read, so this file doesn't pin a specific API success-type name.
66// ---------------------------------------------------------------------------66// ---------------------------------------------------------------------------
67 67
68interface Integ {68interface IntegrationInfo {
69 readonly slug: string;69 readonly slug: string;
70 readonly name: string;70 readonly name: string;
71 readonly kind: string;
72 readonly displayUrl?: string;
71}73}
72 74
75type ToolRow = {
76 readonly integration: string;
77 readonly connection: string;
78 readonly name: string;
79 readonly description: string;
80 readonly requiresApproval?: boolean;
81};
82
73const ACCESS_OPTIONS: ReadonlyArray<{ value: ToolkitAccess; label: string }> = [83const ACCESS_OPTIONS: ReadonlyArray<{ value: ToolkitAccess; label: string }> = [
74 { value: "off", label: "Off" },84 { value: "off", label: "Off" },
75 { value: "read", label: "Read" },85 { value: "read", label: "Read" },
76 { value: "full", label: "Full" },86 { value: "full", label: "Full" },
77];87];
78 88
79const ACTION_LABEL: Record<ToolkitPolicyAction, string> = {89/** Toolkit rules match `integration.connection.tool` — undo ToolTree's 4-segment bridge. */
80 approve: "Auto-approve",90const fromToolTreePattern = (pattern: string): string => {
81 require_approval: "Needs approval",91 const match = pattern.match(/^([^.]+)\.\*\.\*\.(.+)$/);
82 block: "Block",92 if (match) return `${match[1]}.${match[2]}`;
93 return pattern;
83};94};
84 95
96const 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
106function 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
85// ---------------------------------------------------------------------------134// ---------------------------------------------------------------------------
86// Access segmented control (off / read / full)135// Access segmented control (off / read / full)
87// ---------------------------------------------------------------------------136// ---------------------------------------------------------------------------
@@ -112,6 +161,8 @@ function AccessSeg(props: { value: ToolkitAccess; onChange: (v: ToolkitAccess) = · 24 unmodified lines
112function ToolkitCard(props: {161function ToolkitCard(props: {
113 toolkit: ToolkitView;162 toolkit: ToolkitView;
114 integrationName: (slug: string) => string;163 integrationName: (slug: string) => string;
164 integrationBySlug: ReadonlyMap<string, IntegrationInfo>;
165 integrationPlugins: ReturnType<typeof useIntegrationPlugins>;
115 onOpen: () => void;166 onOpen: () => void;
116}) {167}) {
117 const active = props.toolkit.connections.filter((c) => c.access !== "off");168 const active = props.toolkit.connections.filter((c) => c.access !== "off");
@@ -138,7 +189,12 @@ function ToolkitCard(props: { · 20 unmodified lines
138 key={slug}189 key={slug}
139 className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-0.5 text-[12px]"190 className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-0.5 text-[12px]"
140 >191 >
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 />
142 {props.integrationName(slug)}198 {props.integrationName(slug)}
143 </span>199 </span>
144 ))200 ))
@@ -156,6 +212,8 @@ function ListSection(props: { · 11 unmodified lines
156 empty: string;212 empty: string;
157 toolkits: ReadonlyArray<ToolkitView>;213 toolkits: ReadonlyArray<ToolkitView>;
158 integrationName: (slug: string) => string;214 integrationName: (slug: string) => string;
215 integrationBySlug: ReadonlyMap<string, IntegrationInfo>;
216 integrationPlugins: ReturnType<typeof useIntegrationPlugins>;
159 onOpen: (id: string) => void;217 onOpen: (id: string) => void;
160 onCreate: () => void;218 onCreate: () => void;
161}) {219}) {
@@ -178,6 +236,8 @@ function ListSection(props: { · 16 unmodified lines
178 key={tk.id}236 key={tk.id}
179 toolkit={tk}237 toolkit={tk}
180 integrationName={props.integrationName}238 integrationName={props.integrationName}
239 integrationBySlug={props.integrationBySlug}
240 integrationPlugins={props.integrationPlugins}
181 onOpen={() => props.onOpen(tk.id)}241 onOpen={() => props.onOpen(tk.id)}
182 />242 />
183 ))}243 ))}
@@ -197,6 +257,8 @@ interface EditorProps { · 13 unmodified lines
197 readonly takenSlugs: ReadonlySet<string>;257 readonly takenSlugs: ReadonlySet<string>;
198 readonly connections: ReadonlyArray<Conn>;258 readonly connections: ReadonlyArray<Conn>;
199 readonly integrationName: (slug: string) => string;259 readonly integrationName: (slug: string) => string;
260 readonly integrationBySlug: ReadonlyMap<string, IntegrationInfo>;
261 readonly integrationPlugins: ReturnType<typeof useIntegrationPlugins>;
200 readonly onBack: () => void;262 readonly onBack: () => void;
201 readonly onCreated: () => void;263 readonly onCreated: () => void;
202 readonly onRemoved: () => void;264 readonly onRemoved: () => void;
@@ -243,15 +305,86 @@ function ToolkitEditor(props: EditorProps) { · 40 unmodified lines
243 setNotes((m) => ({ ...m, [key]: v }));305 setNotes((m) => ({ ...m, [key]: v }));
244 mark();306 mark();
245 };307 };
246 const setPolicy = (i: number, patch: Partial<ToolkitPolicy>) => {308 const upsertPolicy = (pattern: string, action: ToolkitPolicyAction) => {
247 setPolicies((ps) => ps.map((p, j) => (j === i ? { ...p, ...patch } : p)));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));
248 mark();320 mark();
249 };321 };
250 322
323 const toolsResult = useAtomValue(toolsAllAtom);
324
251 // The connections this toolkit may draw on: workspace = org-owned only;325 // The connections this toolkit may draw on: workspace = org-owned only;
252 // personal = org + the caller's own.326 // personal = org + the caller's own.
253 const tiers = useMemo(() => tiersForScope(props.connections, scope), [props.connections, scope]);327 const tiers = useMemo(() => tiersForScope(props.connections, scope), [props.connections, scope]);
254 328
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
255 const buildEntries = () => entriesFromAccess(props.connections, scope, access, notes);388 const buildEntries = () => entriesFromAccess(props.connections, scope, access, notes);
256 389
257 const cleanPolicies = (): ReadonlyArray<ToolkitPolicy> =>390 const cleanPolicies = (): ReadonlyArray<ToolkitPolicy> =>
@@ -414,7 +547,12 @@ function ToolkitEditor(props: EditorProps) { · 156 unmodified lines
414 groupByIntegration(tier.conns).map(([intSlug, conns]) => (547 groupByIntegration(tier.conns).map(([intSlug, conns]) => (
415 <div key={intSlug} className="px-4 pb-2 pt-1.5">548 <div key={intSlug} className="px-4 pb-2 pt-1.5">
416 <div className="flex items-center gap-2 pb-1">549 <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 />
418 <span className="text-[12.5px] font-semibold text-foreground">556 <span className="text-[12.5px] font-semibold text-foreground">
419 {props.integrationName(intSlug)}557 {props.integrationName(intSlug)}
420 </span>558 </span>
@@ -491,70 +629,33 @@ function ToolkitEditor(props: EditorProps) { · 70 unmodified lines
491 : "Workspace guardrails are off — this toolkit is governed only by its own rules below."}629 : "Workspace guardrails are off — this toolkit is governed only by its own rules below."}
492 </p>630 </p>
493 </div>631 </div>
494 <div className="border-t border-border px-4 py-2.5">632 <div className="border-t border-border">
495 <div className="flex items-center gap-2 pb-1">633 {!hasActiveConnections ? (
496 <span className="text-[12.5px] font-semibold text-foreground">Toolkit rules</span>634 <p className="px-4 py-3 text-[12px] text-muted-foreground">
497 <span className="ml-auto text-[11px] text-muted-foreground">635 Add a connection above to set tool policies.
498 {policies.length} {policies.length === 1 ? "rule" : "rules"}636 </p>
499 </span>637 ) : (
500 </div>638 AsyncResult.match(toolsResult, {
501 {policies.map((p, i) => (639 onInitial: () => (
502 <div key={i} className="flex flex-wrap items-center gap-2 py-1.5">640 <p className="px-4 py-3 text-[12px] text-muted-foreground">Loading tools…</p>
503 <Input641 ),
504 value={p.pattern}642 onFailure: () => (
505 onChange={(e) =>643 <p className="px-4 py-3 text-[12px] text-destructive">Failed to load tools</p>
506 setPolicy(i, {644 ),
507 pattern: (e.target as HTMLInputElement).value,645 onSuccess: () => (
508 })646 <div className="h-80 min-h-0">
509 }647 <ToolTree
510 placeholder="slack.*.post_message"648 tools={toolSummaries}
511 className="h-8 min-w-[190px] flex-1 font-mono text-[12px]"649 selectedToolId={null}
512 />650 onSelect={() => {}}
513 <Select651 policies={treePolicies}
514 value={p.action}652 onSetPolicy={upsertPolicy}
515 onValueChange={(v) => setPolicy(i, { action: v as ToolkitPolicyAction })}653 onClearPolicy={removePolicy}
516 >654 />
517 <SelectTrigger className="h-8 w-[150px] text-[12px]">655 </div>
518 <SelectValue />656 ),
519 </SelectTrigger>657 })
520 <SelectContent>658 )}
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>
558 </div>659 </div>
559 </div>660 </div>
560 661
@@ -629,7 +730,12 @@ function ToolkitEditor(props: EditorProps) { · 68 unmodified lines
629 {previewGroups.map(([intSlug, conns]) => (730 {previewGroups.map(([intSlug, conns]) => (
630 <div key={intSlug}>731 <div key={intSlug}>
631 <div className="flex items-center gap-2">732 <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 />
633 <span className="text-[12.5px] font-semibold text-foreground">739 <span className="text-[12.5px] font-semibold text-foreground">
634 {props.integrationName(intSlug)}740 {props.integrationName(intSlug)}
635 </span>741 </span>
@@ -694,6 +800,7 @@ function viewFromSubpath(subpath: string): View { · 58 unmodified lines
694export function ToolkitsPage() {800export function ToolkitsPage() {
695 const { subpath } = usePluginRoute();801 const { subpath } = usePluginRoute();
696 const navigate = usePluginNavigate();802 const navigate = usePluginNavigate();
803 const integrationPlugins = useIntegrationPlugins();
697 const view = viewFromSubpath(subpath);804 const view = viewFromSubpath(subpath);
698 const toolkitsResult = useAtomValue(toolkitsAtom);805 const toolkitsResult = useAtomValue(toolkitsAtom);
699 const connectionsResult = useAtomValue(connectionsAllAtom);806 const connectionsResult = useAtomValue(connectionsAllAtom);
@@ -716,18 +823,22 @@ export function ToolkitsPage() { · 16 unmodified lines
716 },823 },
717 );824 );
718 const integrations = AsyncResult.match(825 const integrations = AsyncResult.match(
719 integrationsResult as AsyncResult.AsyncResult<ReadonlyArray<Integ>, unknown>,826 integrationsResult as AsyncResult.AsyncResult<ReadonlyArray<IntegrationInfo>, unknown>,
720 {827 {
721 onInitial: () => [] as ReadonlyArray<Integ>,828 onInitial: () => [] as ReadonlyArray<IntegrationInfo>,
722 onFailure: () => [] as ReadonlyArray<Integ>,829 onFailure: () => [] as ReadonlyArray<IntegrationInfo>,
723 onSuccess: ({ value }) => value,830 onSuccess: ({ value }) => value,
724 },831 },
725 );832 );
726 833
834 const integrationBySlug = useMemo(
835 () => new Map(integrations.map((i) => [i.slug, i])),
836 [integrations],
837 );
838
727 const integrationName = useMemo(() => {839 const integrationName = useMemo(() => {
728 const bySlug = new Map(integrations.map((i) => [i.slug, i.name]));840 return (slug: string) => integrationBySlug.get(slug)?.name ?? slug;
729 return (slug: string) => bySlug.get(slug) ?? slug;841 }, [integrationBySlug]);
730 }, [integrations]);
731 842
732 const takenSlugs = useMemo(() => new Set((toolkits ?? []).map((t) => t.slug)), [toolkits]);843 const takenSlugs = useMemo(() => new Set((toolkits ?? []).map((t) => t.slug)), [toolkits]);
733 844
@@ -750,6 +861,8 @@ export function ToolkitsPage() { · 16 unmodified lines
750 takenSlugs={takenSlugs}861 takenSlugs={takenSlugs}
751 connections={connections}862 connections={connections}
752 integrationName={integrationName}863 integrationName={integrationName}
864 integrationBySlug={integrationBySlug}
865 integrationPlugins={integrationPlugins}
753 onBack={() => navigate("")}866 onBack={() => navigate("")}
754 onCreated={() => navigate("")}867 onCreated={() => navigate("")}
755 onRemoved={() => navigate("")}868 onRemoved={() => navigate("")}
@@ -777,6 +890,8 @@ export function ToolkitsPage() { · 21 unmodified lines
777 empty="No workspace toolkits yet — these draw on shared workspace connections."890 empty="No workspace toolkits yet — these draw on shared workspace connections."
778 toolkits={workspace}891 toolkits={workspace}
779 integrationName={integrationName}892 integrationName={integrationName}
893 integrationBySlug={integrationBySlug}
894 integrationPlugins={integrationPlugins}
780 onOpen={(id) => navigate(id)}895 onOpen={(id) => navigate(id)}
781 onCreate={() => navigate("new/workspace")}896 onCreate={() => navigate("new/workspace")}
782 />897 />
@@ -785,6 +900,8 @@ export function ToolkitsPage() { · 2 unmodified lines
785 empty="No personal toolkits yet — these can use workspace and personal connections."900 empty="No personal toolkits yet — these can use workspace and personal connections."
786 toolkits={personal}901 toolkits={personal}
787 integrationName={integrationName}902 integrationName={integrationName}
903 integrationBySlug={integrationBySlug}
904 integrationPlugins={integrationPlugins}
788 onOpen={(id) => navigate(id)}905 onOpen={(id) => navigate(id)}
789 onCreate={() => navigate("new/personal")}906 onCreate={() => navigate("new/personal")}
790 />907 />