@anchrd/intel-ui 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @anchrd/intel-ui
2
2
 
3
3
  The Intel browser application: Knowledge, Flows and Tools for the
4
- [`@anchrd/intel`](https://www.npmjs.com/package/@anchrd/intel) server.
4
+ [`@anchrd/intel-api`](https://www.npmjs.com/package/@anchrd/intel-api) server.
5
5
 
6
6
  This package ships **source, not a bundle**. You compile it yourself so your own theme, logo and
7
7
  languages are applied at build time and the output belongs to you — no customer build artifact ever
@@ -9,10 +9,10 @@ lives in `node_modules`.
9
9
 
10
10
  ## Install
11
11
 
12
- Install it next to `@anchrd/intel`, then let the CLI build it:
12
+ Install it next to `@anchrd/intel-api`, then let the CLI build it:
13
13
 
14
14
  ```bash
15
- npm install @anchrd/intel @anchrd/intel-ui
15
+ npm install @anchrd/intel-api @anchrd/intel-ui
16
16
  npx intel build
17
17
  ```
18
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -28,7 +28,7 @@
28
28
  "typecheck": "tsc --noEmit"
29
29
  },
30
30
  "dependencies": {
31
- "@anchrd/intel-contract": "^0.1.1",
31
+ "@anchrd/intel-contract": "^0.2.0",
32
32
  "@blocknote/core": "^0.52.1",
33
33
  "@blocknote/react": "^0.52.1",
34
34
  "@blocknote/shadcn": "^0.52.1",
@@ -4,9 +4,7 @@ import {
4
4
  CreateFlowInput,
5
5
  CreateKnowledgeLinkInput,
6
6
  CreateKnowledgeNodeInput,
7
- CreateToolSourceInput,
8
7
  DeleteKnowledgeLinkInput,
9
- DiscoverToolSourceInput,
10
8
  Flow,
11
9
  FlowDocument,
12
10
  FlowList,
@@ -36,8 +34,6 @@ import {
36
34
  StartFlowRunInput,
37
35
  TestToolInput,
38
36
  ToolCatalog,
39
- ToolSource,
40
- ToolSourceList,
41
37
  ToolTestResult,
42
38
  UpdateKnowledgeNodeInput,
43
39
  } from "@anchrd/intel-contract";
@@ -49,6 +45,10 @@ const DeleteResult = z.strictObject({ deleted: z.boolean() });
49
45
  // Attachment uploads and downloads share this budget, so it is generous rather than snappy.
50
46
  const RequestTimeoutMs = 60_000;
51
47
 
48
+ // Deeper nesting than this is not a tree anyone navigates; the limit exists to bound the request
49
+ // fan-out on first paint, not to express a product rule.
50
+ const MaxTreeDepth = 12;
51
+
52
52
  // The Worker serves the login route, so the path belongs to the data layer rather than to any view.
53
53
  export function loginPath(returnTo?: string): string {
54
54
  return returnTo ? `/auth/login?returnTo=${encodeURIComponent(returnTo)}` : "/auth/login";
@@ -115,12 +115,23 @@ export function createIntelDataProvider(
115
115
  return await request(`/knowledge?${params}`, KnowledgeNodeList);
116
116
  }
117
117
 
118
- async function loadChildren(parentId: string | null): Promise<KnowledgeTreeNode[]> {
118
+ // One request per folder, so a deep tree is an N+1 on the first paint. The depth limit bounds that
119
+ // and, more importantly, makes a cyclic parentId impossible to hang on: without it a cycle would
120
+ // recurse until the tab dies. `seen` stops a cycle at the first repeat rather than at the limit.
121
+ async function loadChildren(
122
+ parentId: string | null,
123
+ depth = 0,
124
+ seen: ReadonlySet<string> = new Set(),
125
+ ): Promise<KnowledgeTreeNode[]> {
126
+ if (depth >= MaxTreeDepth) return [];
119
127
  const { items } = await listKnowledge({ parentId });
120
128
  return await Promise.all(
121
129
  items.map(async (node) => ({
122
130
  ...node,
123
- children: node.kind === "folder" ? await loadChildren(node.id) : [],
131
+ children:
132
+ node.kind === "folder" && !seen.has(node.id)
133
+ ? await loadChildren(node.id, depth + 1, new Set([...seen, node.id]))
134
+ : [],
124
135
  })),
125
136
  );
126
137
  }
@@ -294,26 +305,6 @@ export function createIntelDataProvider(
294
305
  body: JSON.stringify(parsed),
295
306
  });
296
307
  },
297
- async listToolSources() {
298
- return await request("/tool-sources", ToolSourceList);
299
- },
300
- async createToolSource(input) {
301
- return await request("/tool-sources", ToolSource, {
302
- method: "POST",
303
- body: JSON.stringify(CreateToolSourceInput.parse(input)),
304
- });
305
- },
306
- async discoverToolSource(input) {
307
- const parsed = DiscoverToolSourceInput.parse(input);
308
- return await request(
309
- `/tool-sources/${encodeURIComponent(parsed.sourceId)}/discover`,
310
- ToolCatalog,
311
- {
312
- method: "POST",
313
- body: JSON.stringify(parsed),
314
- },
315
- );
316
- },
317
308
  async listTools() {
318
309
  return await request("/tools", ToolCatalog);
319
310
  },
@@ -324,8 +315,8 @@ export function createIntelDataProvider(
324
315
  });
325
316
  },
326
317
 
327
- toolSourceConnectUrl(sourceId, returnTo = "/tools") {
328
- return `${baseUrl}/auth/connect/${encodeURIComponent(sourceId)}?returnTo=${encodeURIComponent(returnTo)}`;
318
+ portalConnectUrl(returnTo = "/tools") {
319
+ return `${baseUrl}/auth/connect?returnTo=${encodeURIComponent(returnTo)}`;
329
320
  },
330
321
  async logout() {
331
322
  const response = await doFetch(`${baseUrl}/auth/logout`, {
@@ -4,9 +4,7 @@ import type {
4
4
  CreateFlowInput,
5
5
  CreateKnowledgeLinkInput,
6
6
  CreateKnowledgeNodeInput,
7
- CreateToolSourceInput,
8
7
  DeleteKnowledgeLinkInput,
9
- DiscoverToolSourceInput,
10
8
  Flow,
11
9
  FlowDocument,
12
10
  FlowList,
@@ -35,8 +33,6 @@ import type {
35
33
  StartFlowRunInput,
36
34
  TestToolInput,
37
35
  ToolCatalog,
38
- ToolSource,
39
- ToolSourceList,
40
36
  ToolTestResult,
41
37
  UpdateKnowledgeNodeInput,
42
38
  } from "@anchrd/intel-contract";
@@ -57,7 +53,7 @@ export interface IntelDataProvider {
57
53
  saveKnowledge(input: SaveKnowledgeVersionInput): Promise<KnowledgeDocument>;
58
54
  saveKnowledgeAttachment(input: SaveKnowledgeAttachmentInput): Promise<KnowledgeDocument>;
59
55
  getKnowledgeAttachment(nodeId: string): Promise<Blob>;
60
- toolSourceConnectUrl(sourceId: string, returnTo?: string): string;
56
+ portalConnectUrl(returnTo?: string): string;
61
57
  listKnowledgeVersions(nodeId: string): Promise<KnowledgeVersionList>;
62
58
  updateKnowledge(input: UpdateKnowledgeNodeInput): Promise<KnowledgeNode>;
63
59
  archiveKnowledge(input: ArchiveKnowledgeNodeInput): Promise<KnowledgeNode>;
@@ -76,9 +72,6 @@ export interface IntelDataProvider {
76
72
  startFlow(input: StartFlowRunInput): Promise<FlowRunStep>;
77
73
  getFlowRun(runId: string): Promise<FlowRunStep>;
78
74
  completeFlowStep(input: CompleteFlowRunStepInput): Promise<FlowRunStep>;
79
- listToolSources(): Promise<ToolSourceList>;
80
- createToolSource(input: CreateToolSourceInput): Promise<ToolSource>;
81
- discoverToolSource(input: DiscoverToolSourceInput): Promise<ToolCatalog>;
82
75
  listTools(): Promise<ToolCatalog>;
83
76
  testTool(input: TestToolInput): Promise<ToolTestResult>;
84
77
  logout(): Promise<void>;
@@ -198,7 +198,6 @@ function newNode(
198
198
  ...common,
199
199
  kind,
200
200
  configuration: {
201
- sourceId: "select-a-source",
202
201
  toolName: "select-a-tool",
203
202
  fingerprint: null,
204
203
  arguments: {},
@@ -599,10 +598,7 @@ function NodeInspector({
599
598
  }: {
600
599
  node: CanvasNode | null;
601
600
  update(fn: (node: FlowNode) => FlowNode): void;
602
- tools: Array<{
603
- source: { id: string; name: string };
604
- capability: { name: string; title: string | null; fingerprint: string };
605
- }>;
601
+ tools: Array<{ name: string; title: string | null; fingerprint: string }>;
606
602
  knowledge: KnowledgeTreeNode[];
607
603
  }) {
608
604
  const { i18n } = useIntelRouterContext();
@@ -726,23 +722,18 @@ function NodeInspector({
726
722
  <label className="block text-sm font-medium">
727
723
  {i18n.t("flows.tool")}
728
724
  <select
729
- value={`${contract.configuration.sourceId}:${contract.configuration.toolName}`}
725
+ value={contract.configuration.toolName}
730
726
  onChange={(event) => {
731
- const [sourceId, ...name] = event.target.value.split(":");
732
- const toolName = name.join(":");
727
+ const toolName = event.target.value;
733
728
  update((value) =>
734
- value.kind === "tool" && sourceId
729
+ value.kind === "tool"
735
730
  ? {
736
731
  ...value,
737
732
  configuration: {
738
733
  ...value.configuration,
739
- sourceId,
740
734
  toolName,
741
735
  fingerprint:
742
- tools.find(
743
- (entry) =>
744
- entry.source.id === sourceId && entry.capability.name === toolName,
745
- )?.capability.fingerprint ?? null,
736
+ tools.find((entry) => entry.name === toolName)?.fingerprint ?? null,
746
737
  },
747
738
  }
748
739
  : value,
@@ -750,13 +741,10 @@ function NodeInspector({
750
741
  }}
751
742
  className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
752
743
  >
753
- <option value="select-a-source:select-a-tool">{i18n.t("flows.selectTool")}</option>
744
+ <option value="select-a-tool">{i18n.t("flows.selectTool")}</option>
754
745
  {tools.map((entry) => (
755
- <option
756
- key={`${entry.source.id}:${entry.capability.name}`}
757
- value={`${entry.source.id}:${entry.capability.name}`}
758
- >
759
- {entry.source.name} · {entry.capability.title ?? entry.capability.name}
746
+ <option key={entry.name} value={entry.name}>
747
+ {entry.title ?? entry.name}
760
748
  </option>
761
749
  ))}
762
750
  </select>
package/src/i18n/en.json CHANGED
@@ -57,30 +57,23 @@
57
57
  "knowledge.saveConflict": "This document changed elsewhere. Reload it before saving again.",
58
58
  "knowledge.saveError": "This document could not be saved. Reload and try again.",
59
59
  "tools.title": "Company tools",
60
- "tools.description": "Governed MCP capabilities available to people and AI.",
61
- "tools.empty": "No MCP source registered",
62
- "tools.emptyHelp": "Register the aggregated Cloudflare Portal endpoint. Intel discovers its tools while Gate remains the credential and permission boundary.",
63
- "tools.addSource": "Add MCP source",
64
- "tools.endpoint": "Streamable HTTP endpoint",
65
- "tools.connectionHandle": "Gate connection handle",
66
- "tools.connect": "Connect with Gate",
67
- "tools.sourceHelp": "Intel registers the handle/connect interface in Gate. Grant it to the right roles and keep the credential in Gate.",
68
- "tools.discover": "Discover tools",
69
- "tools.notDiscovered": "No tools cached yet. Connect the source in Gate, then run discovery.",
60
+ "tools.description": "The MCP tools you can reach through the company portal.",
61
+ "tools.connect": "Connect the portal",
62
+ "tools.reconnect": "Reconnect",
63
+ "tools.disconnected": "Portal not connected",
64
+ "tools.disconnectedHelp": "Sign in to the company MCP portal once. The portal decides which servers you may use and holds their credentials; Intel never sees them.",
65
+ "tools.empty": "No tools available to you",
66
+ "tools.emptyHelp": "The portal returned no tools for your account. An administrator configures which servers it offers.",
70
67
  "tools.testTitle": "Tool playground",
71
- "tools.select": "Select a discovered tool to inspect and test it.",
68
+ "tools.select": "Select a tool to inspect and test it.",
72
69
  "tools.arguments": "Arguments (JSON object)",
73
70
  "tools.runTest": "Run controlled test",
74
71
  "tools.invalidJson": "Arguments must be a valid JSON object.",
75
- "tools.destructive": "This source marks the tool as potentially destructive. Review the arguments before running it.",
76
- "tools.connectionRequired": "Your Gate connection is missing, forbidden, or invalid.",
72
+ "tools.destructive": "The portal marks this tool as potentially destructive. Review the arguments before running it.",
73
+ "tools.destructiveShort": "Destructive",
77
74
  "tools.testUnsafe": "The playground only runs tools explicitly marked read-only and non-destructive.",
78
- "tools.operationFailed": "The MCP operation failed. Check the source and try again.",
79
- "tools.connectFailed": "The MCP source could not be connected with your current access.",
80
- "tools.status.connected": "Connected",
81
- "tools.status.missing": "Not connected",
82
- "tools.status.forbidden": "No access",
83
- "tools.status.invalid": "Invalid",
75
+ "tools.operationFailed": "The tool list could not be loaded. Reconnect the portal and try again.",
76
+ "tools.connectFailed": "The portal could not be connected with your current access.",
84
77
  "flows.title": "Company flows",
85
78
  "flows.description": "Model-agnostic processes that connect instructions, Knowledge, tools, and people.",
86
79
  "flows.new": "New flow",
@@ -1,56 +1,17 @@
1
- import type { ToolCatalogEntry } from "@anchrd/intel-contract";
2
- import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
- import {
4
- AlertTriangle,
5
- CheckCircle2,
6
- CircleOff,
7
- KeyRound,
8
- LogIn,
9
- Play,
10
- Plus,
11
- RefreshCw,
12
- ShieldAlert,
13
- Wrench,
14
- } from "lucide-react";
15
- import { useMemo, useState } from "react";
16
- import { Modal } from "@/modal/modal.tsx";
1
+ import type { ToolCapability } from "@anchrd/intel-contract";
2
+ import { useMutation, useQuery } from "@tanstack/react-query";
3
+ import { AlertTriangle, LogIn, Play, ShieldAlert, Wrench } from "lucide-react";
4
+ import { useState } from "react";
17
5
  import { useIntelRouterContext } from "@/router/router-context.ts";
18
6
 
19
- const statusIcon = {
20
- connected: CheckCircle2,
21
- missing: KeyRound,
22
- forbidden: ShieldAlert,
23
- invalid: CircleOff,
24
- } as const;
25
-
26
7
  export function Tools() {
27
8
  const { data, i18n } = useIntelRouterContext();
28
- const queryClient = useQueryClient();
29
- const sources = useQuery({ queryKey: ["tool-sources"], queryFn: () => data.listToolSources() });
30
9
  const catalog = useQuery({ queryKey: ["tools"], queryFn: () => data.listTools() });
31
- const [registering, setRegistering] = useState(false);
32
- const [selected, setSelected] = useState<ToolCatalogEntry | null>(null);
10
+ const [selected, setSelected] = useState<ToolCapability | null>(null);
33
11
  const connectError =
34
12
  typeof window === "undefined"
35
13
  ? null
36
14
  : new URLSearchParams(window.location.search).get("connectError");
37
- const grouped = useMemo(() => {
38
- const entries = catalog.data?.items ?? [];
39
- return (sources.data?.items ?? []).map((source) => ({
40
- source,
41
- entries: entries.filter((entry) => entry.source.id === source.id),
42
- }));
43
- }, [catalog.data, sources.data]);
44
- const discover = useMutation({
45
- mutationFn: (sourceId: string) =>
46
- data.discoverToolSource({ sourceId, idempotencyKey: crypto.randomUUID() }),
47
- onSuccess: async () => {
48
- await Promise.all([
49
- queryClient.invalidateQueries({ queryKey: ["tool-sources"] }),
50
- queryClient.invalidateQueries({ queryKey: ["tools"] }),
51
- ]);
52
- },
53
- });
54
15
 
55
16
  return (
56
17
  <div className="min-h-screen">
@@ -59,26 +20,19 @@ export function Tools() {
59
20
  <h1 className="text-xl font-semibold tracking-tight">{i18n.t("tools.title")}</h1>
60
21
  <p className="mt-1 text-sm text-muted-foreground">{i18n.t("tools.description")}</p>
61
22
  </div>
62
- <button
63
- type="button"
64
- onClick={() => setRegistering(true)}
65
- className="inline-flex items-center gap-2 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring"
66
- >
67
- <Plus aria-hidden="true" className="size-4" />
68
- {i18n.t("tools.addSource")}
69
- </button>
23
+ {catalog.data?.portalConnected && (
24
+ <a
25
+ href={data.portalConnectUrl("/tools")}
26
+ className="inline-flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
27
+ >
28
+ <LogIn aria-hidden="true" className="size-4" />
29
+ {i18n.t("tools.reconnect")}
30
+ </a>
31
+ )}
70
32
  </header>
71
33
 
72
34
  <div className="grid gap-5 p-8 xl:grid-cols-[minmax(0,1fr)_24rem]">
73
35
  <div className="space-y-5">
74
- {discover.isError && (
75
- <p
76
- role="alert"
77
- className="rounded-md border border-destructive/30 p-3 text-sm text-destructive"
78
- >
79
- {i18n.t("tools.operationFailed")}
80
- </p>
81
- )}
82
36
  {connectError && (
83
37
  <p
84
38
  role="alert"
@@ -87,9 +41,28 @@ export function Tools() {
87
41
  {i18n.t("tools.connectFailed")}
88
42
  </p>
89
43
  )}
90
- {sources.isPending || catalog.isPending ? (
44
+ {catalog.isPending ? (
91
45
  <p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
92
- ) : grouped.length === 0 ? (
46
+ ) : catalog.isError ? (
47
+ <p role="alert" className="text-sm text-destructive">
48
+ {i18n.t("tools.operationFailed")}
49
+ </p>
50
+ ) : !catalog.data?.portalConnected ? (
51
+ <section className="rounded-xl border border-dashed bg-card p-12 text-center">
52
+ <Wrench aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />
53
+ <h2 className="mt-4 font-semibold">{i18n.t("tools.disconnected")}</h2>
54
+ <p className="mx-auto mt-2 max-w-lg text-sm text-muted-foreground">
55
+ {i18n.t("tools.disconnectedHelp")}
56
+ </p>
57
+ <a
58
+ href={data.portalConnectUrl("/tools")}
59
+ className="mt-5 inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring"
60
+ >
61
+ <LogIn aria-hidden="true" className="size-4" />
62
+ {i18n.t("tools.connect")}
63
+ </a>
64
+ </section>
65
+ ) : catalog.data.items.length === 0 ? (
93
66
  <section className="rounded-xl border border-dashed bg-card p-12 text-center">
94
67
  <Wrench aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />
95
68
  <h2 className="mt-4 font-semibold">{i18n.t("tools.empty")}</h2>
@@ -98,120 +71,81 @@ export function Tools() {
98
71
  </p>
99
72
  </section>
100
73
  ) : (
101
- grouped.map(({ source, entries }) => (
102
- <section
103
- key={source.id}
104
- className="overflow-hidden rounded-xl border bg-card shadow-sm"
105
- >
106
- <div className="flex items-start justify-between gap-5 border-b bg-muted/20 px-5 py-4">
107
- <div className="min-w-0">
108
- <h2 className="font-semibold">{source.name}</h2>
109
- <p className="mt-1 truncate text-xs text-muted-foreground">{source.url}</p>
110
- <p className="mt-2 text-xs text-muted-foreground">
111
- {i18n.t("tools.connectionHandle")}: {source.connectionHandle}
112
- </p>
113
- </div>
114
- <div className="flex shrink-0 items-center gap-2">
115
- <a
116
- href={data.toolSourceConnectUrl(source.id, "/tools")}
117
- className="inline-flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-xs font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
118
- >
119
- <LogIn aria-hidden="true" className="size-3.5" />
120
- {i18n.t("tools.connect")}
121
- </a>
74
+ <section className="overflow-hidden rounded-xl border bg-card shadow-sm">
75
+ <ul className="divide-y">
76
+ {catalog.data.items.map((capability) => (
77
+ <li key={capability.name}>
122
78
  <button
123
79
  type="button"
124
- disabled={discover.isPending && discover.variables === source.id}
125
- onClick={() => discover.mutate(source.id)}
126
- className="inline-flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-xs font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
80
+ onClick={() => setSelected(capability)}
81
+ className="flex w-full items-start justify-between gap-5 px-5 py-4 text-left outline-none hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
127
82
  >
128
- <RefreshCw aria-hidden="true" className="size-3.5" />
129
- {i18n.t("tools.discover")}
83
+ <span className="min-w-0">
84
+ <span className="block text-sm font-medium">
85
+ {capability.title ?? capability.name}
86
+ </span>
87
+ <span className="mt-1 block truncate font-mono text-xs text-muted-foreground">
88
+ {capability.name}
89
+ </span>
90
+ {capability.description && (
91
+ <span className="mt-2 line-clamp-2 block text-xs text-muted-foreground">
92
+ {capability.description}
93
+ </span>
94
+ )}
95
+ </span>
96
+ {capability.annotations.destructiveHint && (
97
+ <span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-destructive/10 px-2 py-1 text-xs text-destructive">
98
+ <AlertTriangle aria-hidden="true" className="size-3.5" />
99
+ {i18n.t("tools.destructiveShort")}
100
+ </span>
101
+ )}
130
102
  </button>
131
- </div>
132
- </div>
133
- {entries.length === 0 ? (
134
- <p className="p-5 text-sm text-muted-foreground">
135
- {i18n.t("tools.notDiscovered")}
136
- </p>
137
- ) : (
138
- <ul className="divide-y">
139
- {entries.map((entry) => {
140
- const Status = statusIcon[entry.connectionStatus];
141
- return (
142
- <li key={entry.capability.name}>
143
- <button
144
- type="button"
145
- onClick={() => setSelected(entry)}
146
- className="flex w-full items-start justify-between gap-5 px-5 py-4 text-left outline-none hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
147
- >
148
- <span className="min-w-0">
149
- <span className="block text-sm font-medium">
150
- {entry.capability.title ?? entry.capability.name}
151
- </span>
152
- <span className="mt-1 block truncate font-mono text-xs text-muted-foreground">
153
- {entry.capability.name}
154
- </span>
155
- {entry.capability.description && (
156
- <span className="mt-2 line-clamp-2 block text-xs text-muted-foreground">
157
- {entry.capability.description}
158
- </span>
159
- )}
160
- </span>
161
- <span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-muted px-2 py-1 text-xs text-muted-foreground">
162
- <Status aria-hidden="true" className="size-3.5" />
163
- {i18n.t(`tools.status.${entry.connectionStatus}`)}
164
- </span>
165
- </button>
166
- </li>
167
- );
168
- })}
169
- </ul>
170
- )}
171
- </section>
172
- ))
103
+ </li>
104
+ ))}
105
+ </ul>
106
+ </section>
173
107
  )}
174
108
  </div>
175
- <ToolTester entry={selected} />
109
+ <ToolTester capability={selected} />
176
110
  </div>
177
- {registering && <RegisterSource close={() => setRegistering(false)} />}
178
111
  </div>
179
112
  );
180
113
  }
181
114
 
182
- function ToolTester({ entry }: { entry: ToolCatalogEntry | null }) {
115
+ function ToolTester({ capability }: { capability: ToolCapability | null }) {
183
116
  const { data, i18n } = useIntelRouterContext();
184
117
  const [argumentsText, setArgumentsText] = useState("{}");
185
118
  const [parseError, setParseError] = useState(false);
186
119
  const safeToTest =
187
- entry?.capability.annotations.readOnlyHint === true &&
188
- entry.capability.annotations.destructiveHint !== true;
120
+ capability?.annotations.readOnlyHint === true &&
121
+ capability.annotations.destructiveHint !== true;
189
122
  const test = useMutation({
190
123
  mutationFn: async () => {
191
124
  const parsed: unknown = JSON.parse(argumentsText);
192
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("object");
125
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
126
+ throw new SyntaxError("Arguments must be a JSON object");
127
+ }
193
128
  return await data.testTool({
194
- sourceId: entry?.source.id ?? "",
195
- name: entry?.capability.name ?? "",
129
+ name: capability?.name ?? "",
196
130
  arguments: parsed as Record<string, unknown>,
197
131
  });
198
132
  },
199
133
  onMutate: () => setParseError(false),
200
- onError: (error) => setParseError(error instanceof SyntaxError || error.message === "object"),
134
+ onError: (error) => setParseError(error instanceof SyntaxError),
201
135
  });
202
136
 
203
137
  return (
204
138
  <aside className="sticky top-5 h-fit rounded-xl border bg-card p-5 shadow-sm">
205
139
  <h2 className="font-semibold">{i18n.t("tools.testTitle")}</h2>
206
- {!entry ? (
140
+ {!capability ? (
207
141
  <p className="mt-3 text-sm text-muted-foreground">{i18n.t("tools.select")}</p>
208
142
  ) : (
209
143
  <div className="mt-4 space-y-4">
210
144
  <div>
211
- <p className="text-sm font-medium">{entry.capability.title ?? entry.capability.name}</p>
212
- <p className="mt-1 font-mono text-xs text-muted-foreground">{entry.capability.name}</p>
145
+ <p className="text-sm font-medium">{capability.title ?? capability.name}</p>
146
+ <p className="mt-1 font-mono text-xs text-muted-foreground">{capability.name}</p>
213
147
  </div>
214
- {entry.capability.annotations.destructiveHint && (
148
+ {capability.annotations.destructiveHint && (
215
149
  <p className="flex gap-2 rounded-md bg-destructive/10 p-3 text-xs text-destructive">
216
150
  <AlertTriangle aria-hidden="true" className="size-4 shrink-0" />
217
151
  {i18n.t("tools.destructive")}
@@ -223,11 +157,6 @@ function ToolTester({ entry }: { entry: ToolCatalogEntry | null }) {
223
157
  {i18n.t("tools.testUnsafe")}
224
158
  </p>
225
159
  )}
226
- {entry.connectionStatus !== "connected" && (
227
- <p className="rounded-md bg-muted p-3 text-xs text-muted-foreground">
228
- {i18n.t("tools.connectionRequired")}
229
- </p>
230
- )}
231
160
  <label className="block text-xs font-medium">
232
161
  {i18n.t("tools.arguments")}
233
162
  <textarea
@@ -241,7 +170,7 @@ function ToolTester({ entry }: { entry: ToolCatalogEntry | null }) {
241
170
  {parseError && <p className="text-xs text-destructive">{i18n.t("tools.invalidJson")}</p>}
242
171
  <button
243
172
  type="button"
244
- disabled={test.isPending || entry.connectionStatus !== "connected" || !safeToTest}
173
+ disabled={test.isPending || !safeToTest}
245
174
  onClick={() => test.mutate()}
246
175
  className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
247
176
  >
@@ -261,86 +190,3 @@ function ToolTester({ entry }: { entry: ToolCatalogEntry | null }) {
261
190
  </aside>
262
191
  );
263
192
  }
264
-
265
- function RegisterSource({ close }: { close(): void }) {
266
- const { data, i18n } = useIntelRouterContext();
267
- const queryClient = useQueryClient();
268
- const [name, setName] = useState("");
269
- const [url, setUrl] = useState("");
270
- const [connectionHandle, setConnectionHandle] = useState("intel-tools");
271
- const create = useMutation({
272
- mutationFn: () =>
273
- data.createToolSource({
274
- name,
275
- url,
276
- connectionHandle,
277
- idempotencyKey: crypto.randomUUID(),
278
- }),
279
- onSuccess: async () => {
280
- await queryClient.invalidateQueries({ queryKey: ["tool-sources"] });
281
- close();
282
- },
283
- });
284
- return (
285
- <Modal title={i18n.t("tools.addSource")} close={close} className="max-w-lg">
286
- <form
287
- className="space-y-4"
288
- onSubmit={(event) => {
289
- event.preventDefault();
290
- if (create.isPending) return;
291
- create.mutate();
292
- }}
293
- >
294
- <Field label={i18n.t("common.title")} value={name} setValue={setName} />
295
- <Field label={i18n.t("tools.endpoint")} value={url} setValue={setUrl} type="url" />
296
- <Field
297
- label={i18n.t("tools.connectionHandle")}
298
- value={connectionHandle}
299
- setValue={setConnectionHandle}
300
- pattern="[a-z0-9][a-z0-9-]{1,62}"
301
- />
302
- <p className="text-xs text-muted-foreground">{i18n.t("tools.sourceHelp")}</p>
303
- {create.isError && (
304
- <p role="alert" className="text-sm text-destructive">
305
- {i18n.t("tools.operationFailed")}
306
- </p>
307
- )}
308
- <button
309
- type="submit"
310
- disabled={create.isPending}
311
- className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
312
- >
313
- {create.isPending ? i18n.t("common.saving") : i18n.t("common.create")}
314
- </button>
315
- </form>
316
- </Modal>
317
- );
318
- }
319
-
320
- function Field({
321
- label,
322
- value,
323
- setValue,
324
- type = "text",
325
- pattern,
326
- }: {
327
- label: string;
328
- value: string;
329
- setValue(value: string): void;
330
- type?: string;
331
- pattern?: string;
332
- }) {
333
- return (
334
- <label className="block text-sm font-medium">
335
- {label}
336
- <input
337
- required
338
- type={type}
339
- pattern={pattern}
340
- value={value}
341
- onChange={(event) => setValue(event.target.value)}
342
- className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
343
- />
344
- </label>
345
- );
346
- }