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).
Unified · Split
| @@ -16,9 +16,10 @@ import { useEffect, useMemo, useState } from "react"; · 15 unmodified lines | |||
| 16 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | 16 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 17 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 17 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 18 | import * as Exit from "effect/Exit"; | 18 | 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"; | ||
| 20 | 21 | ||
| 21 | import { connectionsAllAtom, integrationsAtom } from "@executor-js/react/api/atoms"; | 22 | import { connectionsAllAtom, integrationsAtom, toolsAllAtom } from "@executor-js/react/api/atoms"; |
| 22 | import { Button } from "@executor-js/react/components/button"; | 23 | import { Button } from "@executor-js/react/components/button"; |
| 23 | import { Input } from "@executor-js/react/components/input"; | 24 | import { Input } from "@executor-js/react/components/input"; |
| 24 | import { Textarea } from "@executor-js/react/components/textarea"; | 25 | import { Textarea } from "@executor-js/react/components/textarea"; |
| @@ -26,13 +27,12 @@ import { Switch } from "@executor-js/react/components/switch"; · 1 unmodified line | |||
| 26 | import { Label } from "@executor-js/react/components/label"; | 27 | import { Label } from "@executor-js/react/components/label"; |
| 27 | import { Badge } from "@executor-js/react/components/badge"; | 28 | import { Badge } from "@executor-js/react/components/badge"; |
| 28 | import { | 29 | import { |
| 29 | Select, | 30 | IntegrationFavicon, |
| 30 | SelectContent, | 31 | integrationInferredUrl, |
| 31 | SelectItem, | 32 | integrationPresetIconUrl, |
| 32 | SelectTrigger, | 33 | } from "@executor-js/react/components/integration-favicon"; |
| 33 | SelectValue, | 34 | import { ToolTree, type ToolSummary } from "@executor-js/react/components/tool-tree"; |
| 34 | } from "@executor-js/react/components/select"; | 35 | import { toPolicyPattern } from "@executor-js/react/lib/policy-pattern"; |
| 35 | import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon"; | ||
| 36 | import { copyToClipboard } from "@executor-js/react/lib/clipboard"; | 36 | import { copyToClipboard } from "@executor-js/react/lib/clipboard"; |
| 37 | import { cn } from "@executor-js/react/lib/utils"; | 37 | import { 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 | ||
| 68 | interface Integ { | 68 | interface 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 | ||
| 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 | |||
| 73 | const ACCESS_OPTIONS: ReadonlyArray<{ value: ToolkitAccess; label: string }> = [ | 83 | const 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 | ||
| 79 | const ACTION_LABEL: Record<ToolkitPolicyAction, string> = { | 89 | /** Toolkit rules match `integration.connection.tool` — undo ToolTree's 4-segment bridge. */ |
| 80 | approve: "Auto-approve", | 90 | const 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 | ||
| 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 | |||
| 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 | |||
| 112 | function ToolkitCard(props: { | 161 | function 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 | <Input | 641 | ), |
| 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 | <Select | 651 | 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 | |||
| 694 | export function ToolkitsPage() { | 800 | export 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 | /> |