@anchrd/intel-ui 0.9.0 → 0.11.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -29,7 +29,7 @@
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "dependencies": {
32
- "@anchrd/intel-contract": "^0.5.0",
32
+ "@anchrd/intel-contract": "^0.7.0",
33
33
  "@assistant-ui/react": "^0.15.4",
34
34
  "@assistant-ui/react-ai-sdk": "^1.4.4",
35
35
  "@blocknote/core": "^0.52.1",
@@ -1,4 +1,4 @@
1
- import type { AgentDefinition, NodeAgent } from "@anchrd/intel-contract";
1
+ import type { AgentDefinition, AgentDefinitionInput, NodeAgent } from "@anchrd/intel-contract";
2
2
  import {
3
3
  type UseMutationResult,
4
4
  type UseQueryResult,
@@ -14,7 +14,25 @@ export const agentKey = (nodeId: string) => ["agent", nodeId] as const;
14
14
  export interface AgentDefinitionHandle {
15
15
  query: UseQueryResult<NodeAgent>;
16
16
  definition: AgentDefinition | null;
17
- save: UseMutationResult<NodeAgent, Error, AgentDefinition>;
17
+ save: UseMutationResult<NodeAgent, Error, AgentDefinitionInput>;
18
+ }
19
+
20
+ /**
21
+ * The definition as it was read, turned back into something this screen may ASK for (D30).
22
+ *
23
+ * ⚠️ It exists to drop exactly one field: `tools.delegatedBy`. Whose portal connection an agent
24
+ * acts on is Intel's to write, out of the session, and the input shape has no place for it — so a
25
+ * section that edits schedules must not carry it along just because it was in what it read. Every
26
+ * section spreads this rather than the definition itself, which is why none of them has to invent a
27
+ * user id for an agent that has no tools yet.
28
+ */
29
+ export function asDraft(definition: AgentDefinition): AgentDefinitionInput {
30
+ return {
31
+ references: definition.references,
32
+ schedules: definition.schedules,
33
+ model: definition.model,
34
+ tools: definition.tools === null ? null : { servers: definition.tools.servers },
35
+ };
18
36
  }
19
37
 
20
38
  /**
@@ -31,7 +49,7 @@ export function useAgentDefinition(nodeId: string): AgentDefinitionHandle {
31
49
  const queryClient = useQueryClient();
32
50
  const query = useQuery({ queryKey: agentKey(nodeId), queryFn: () => data.getAgent(nodeId) });
33
51
  const save = useMutation({
34
- mutationFn: async (definition: AgentDefinition) =>
52
+ mutationFn: async (definition: AgentDefinitionInput) =>
35
53
  await data.saveAgentDefinition({
36
54
  nodeId,
37
55
  baseVersionId: query.data?.version?.id ?? null,
@@ -42,6 +60,11 @@ export function useAgentDefinition(nodeId: string): AgentDefinitionHandle {
42
60
  // next save needs that id as its `baseVersionId`, and a round trip would leave a window in
43
61
  // which it is still the old one.
44
62
  onSuccess: (saved) => queryClient.setQueryData(agentKey(nodeId), saved),
63
+ // ⚠️ A failed save may still have MOVED the version (#214): `agent_schedules_not_armed` means
64
+ // the definition was written and only the runtime's alarm was not set, and the repair it names
65
+ // is saving again. With the old `baseVersionId` still cached that second save would answer 409
66
+ // instead — so the version this screen writes against is refetched whenever a save fails.
67
+ onError: () => queryClient.invalidateQueries({ queryKey: agentKey(nodeId) }),
45
68
  });
46
69
  return { query, definition: query.data?.definition ?? null, save };
47
70
  }
@@ -5,10 +5,10 @@ import {
5
5
  type AgentSchedule,
6
6
  type Node,
7
7
  } from "@anchrd/intel-contract";
8
- import { useQuery } from "@tanstack/react-query";
9
- import { Check, Copy, MessageSquare, Plus, Trash2, Wrench } from "lucide-react";
8
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
9
+ import { Check, Copy, Info, KeyRound, MessageSquare, Plus, Trash2, Wrench } from "lucide-react";
10
10
  import { useState } from "react";
11
- import type { AgentDefinitionHandle } from "@/agent/agent-definition/agent-definition.ts";
11
+ import { type AgentDefinitionHandle, asDraft } from "@/agent/agent-definition/agent-definition.ts";
12
12
  import { useEntryTitle } from "@/agent/agent-entry-title/agent-entry-title.ts";
13
13
  import { modelKey, parseModelKey, selectableModels } from "@/agent/agent-models/agent-models.ts";
14
14
  import {
@@ -18,11 +18,15 @@ import {
18
18
  SelectTrigger,
19
19
  SelectValue,
20
20
  } from "@/components/ui/select";
21
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
21
22
  import { agentMcpAddress } from "@/data/agent-runtime/agent-runtime.ts";
22
23
  import { EntryPicker, type PickerKind } from "@/entry-picker/entry-picker.tsx";
23
24
  import { Modal } from "@/modal/modal.tsx";
24
25
  import { useIntelRouterContext } from "@/router/router-context.ts";
25
26
 
27
+ // No dividers between sections — the space is the separation (#204). The explanation sits behind
28
+ // the icon rather than under the title, so the page shows what the agent IS and keeps the prose
29
+ // for whoever asks.
26
30
  function Section({
27
31
  title,
28
32
  hint,
@@ -33,22 +37,49 @@ function Section({
33
37
  children: React.ReactNode;
34
38
  }) {
35
39
  return (
36
- <section aria-label={title} className="border-b px-6 py-5 last:border-b-0">
37
- <h3 className="text-sm font-semibold">{title}</h3>
38
- {hint ? <p className="mt-1 max-w-2xl text-xs text-muted-foreground">{hint}</p> : null}
40
+ <section aria-label={title} className="px-6 py-7">
41
+ <div className="flex items-center gap-1.5">
42
+ <h3 className="text-sm font-semibold">{title}</h3>
43
+ {hint ? <SectionHint hint={hint} /> : null}
44
+ </div>
39
45
  <div className="mt-3">{children}</div>
40
46
  </section>
41
47
  );
42
48
  }
43
49
 
50
+ // ⚠️ The hint is the trigger's accessible name, not only the tooltip's content: Radix describes the
51
+ // trigger by the content only while it is open, and a listener who tabs past a nameless icon button
52
+ // would never learn there is anything behind it.
53
+ function SectionHint({ hint }: { hint: string }) {
54
+ return (
55
+ <TooltipProvider delayDuration={300}>
56
+ <Tooltip>
57
+ <TooltipTrigger
58
+ type="button"
59
+ aria-label={hint}
60
+ className="rounded-full text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
61
+ >
62
+ <Info aria-hidden="true" className="size-3.5" />
63
+ </TooltipTrigger>
64
+ <TooltipContent className="max-w-xs">{hint}</TooltipContent>
65
+ </Tooltip>
66
+ </TooltipProvider>
67
+ );
68
+ }
69
+
44
70
  /**
45
71
  * Everything an agent is, on one page: how to reach it, what it reads, what it may call, when it
46
- * acts on its own, and which model does the thinking.
72
+ * acts on its own, which model does the thinking, and who it is in Gate.
47
73
  *
48
- * ⚠️ Four of the five sections write, and all four write the SAME way — a whole new definition
49
- * version through `save` (see `agent-definition.ts`). Tools is the exception and stays read-only on
50
- * purpose: the portal decides who reaches which tool, Intel stores none of it, and a control here
51
- * would be a promise this product is not allowed to keep (ADR-0003).
74
+ * ⚠️ Four of the six sections write, and they all write the SAME way — a whole new definition
75
+ * version through `save` (see `agent-definition.ts`). Tools joined them with D30: what it writes is
76
+ * a selection of whole MCP servers out of the signed-in person's own portal connection, never a
77
+ * mirrored permission, so ADR-0003 still holds the catalog behind a chosen server stays a live
78
+ * `tools/list`.
79
+ *
80
+ * ⚠️ Contact and Identity are the two that do not. Identity acts in Gate and in the agent runtime
81
+ * rather than on the document, and a key has no business being in a definition — a definition is
82
+ * versioned, shared and read into model context (ADR-0005 §4).
52
83
  */
53
84
  export function AgentProfile({ node, agent }: { node: Node; agent: AgentDefinitionHandle }) {
54
85
  const { i18n } = useIntelRouterContext();
@@ -87,13 +118,97 @@ export function AgentProfile({ node, agent }: { node: Node; agent: AgentDefiniti
87
118
  ) : null}
88
119
  <ContactSection node={node} />
89
120
  <KnowledgeSection definition={definition} agent={agent} />
90
- <ToolsSection />
121
+ <ToolsSection definition={definition} agent={agent} />
91
122
  <ScheduleSection definition={definition} agent={agent} />
92
123
  <ModelSection definition={definition} agent={agent} />
124
+ <IdentitySection node={node} agent={agent} />
93
125
  </div>
94
126
  );
95
127
  }
96
128
 
129
+ /**
130
+ * Who the agent IS to the rest of the installation, and the one button that repairs it (D29, #207).
131
+ *
132
+ * ⚠️ The application ID is shown and the key is not, because only one of the two is a name. The key
133
+ * exists in Gate for the length of one call and goes straight into the agent runtime; there is
134
+ * nothing for this page to reveal, copy or store, and that is what makes an agent created here able
135
+ * to run without anybody opening a terminal (#200).
136
+ *
137
+ * ⚠️ Last on the page on purpose. It is the section a reader needs on the day something is wrong,
138
+ * not while they are describing what the agent should do.
139
+ */
140
+ function IdentitySection({ node, agent }: { node: Node; agent: AgentDefinitionHandle }) {
141
+ const { data, i18n } = useIntelRouterContext();
142
+ const queryClient = useQueryClient();
143
+ const [confirming, setConfirming] = useState(false);
144
+ const applicationId = agent.query.data?.applicationId ?? null;
145
+
146
+ const rotate = useMutation({
147
+ mutationFn: async () => await data.rotateAgentKey(node.id),
148
+ onSuccess: () => {
149
+ setConfirming(false);
150
+ // The run that failed with `agent_key_missing` is the reason somebody pressed this, so the
151
+ // page's own state is re-read rather than left showing the refusal that sent them here.
152
+ void queryClient.invalidateQueries({ queryKey: ["agent-state", node.id] });
153
+ },
154
+ });
155
+
156
+ return (
157
+ <Section title={i18n.t("agent.identity")} hint={i18n.t("agent.identityHint")}>
158
+ <div className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3">
159
+ <KeyRound aria-hidden="true" className="size-4 shrink-0 text-primary" />
160
+ <span className="min-w-0 flex-1">
161
+ <span className="block text-sm font-medium">{i18n.t("agent.principal")}</span>
162
+ <code className="mt-0.5 block truncate text-xs text-muted-foreground">
163
+ {applicationId ?? i18n.t("agent.principalNone")}
164
+ </code>
165
+ </span>
166
+ <button
167
+ type="button"
168
+ onClick={() => setConfirming(true)}
169
+ disabled={applicationId === null || rotate.isPending}
170
+ aria-busy={rotate.isPending}
171
+ {...(applicationId === null ? { "aria-describedby": "agent-rotate-reason" } : {})}
172
+ className="inline-flex shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
173
+ >
174
+ {i18n.t("agent.rotateKey")}
175
+ </button>
176
+ </div>
177
+ {applicationId === null ? (
178
+ <p id="agent-rotate-reason" className="mt-3 text-xs text-muted-foreground">
179
+ {i18n.t("agent.principalNoneHint")}
180
+ </p>
181
+ ) : null}
182
+ {rotate.isError ? (
183
+ <p role="alert" className="mt-3 text-xs text-destructive">
184
+ {i18n.t("agent.rotateKeyFailed")}
185
+ </p>
186
+ ) : null}
187
+ {rotate.isSuccess ? (
188
+ <p role="status" className="mt-3 text-xs text-muted-foreground">
189
+ {i18n.t("agent.rotateKeyDone")}
190
+ </p>
191
+ ) : null}
192
+ {confirming ? (
193
+ <Modal title={i18n.t("agent.rotateKey")} close={() => setConfirming(false)}>
194
+ {/* ⚠️ Confirmed rather than done on the first click. The previous key stops working the
195
+ moment this runs, so a misclick takes a working agent down until the call finishes. */}
196
+ <p className="text-sm text-muted-foreground">{i18n.t("agent.rotateKeyConfirm")}</p>
197
+ <button
198
+ type="button"
199
+ onClick={() => rotate.mutate()}
200
+ disabled={rotate.isPending}
201
+ aria-busy={rotate.isPending}
202
+ className="mt-4 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"
203
+ >
204
+ {i18n.t("agent.rotateKey")}
205
+ </button>
206
+ </Modal>
207
+ ) : null}
208
+ </Section>
209
+ );
210
+ }
211
+
97
212
  /**
98
213
  * How to reach this agent.
99
214
  *
@@ -151,20 +266,31 @@ function ContactSection({ node }: { node: Node }) {
151
266
  </button>
152
267
  </li>
153
268
  </ul>
154
- {/* ⚠️ Disabled, and the reason is written rather than hovered. A mail contact is deployment
155
- configuration (`AGENT_MAILBOXES`) and deliberately not part of the definition — ADR-0005
156
- §4 — so a working button here would write a field the runtime never reads. */}
269
+ {/* ⚠️ Disabled, with the reason behind a hover rather than on the page (#204). A mail contact
270
+ is deployment configuration (`AGENT_MAILBOXES`) and deliberately not part of the definition
271
+ — ADR-0005 §4 — so a working button here would write a field the runtime never reads. The
272
+ span carries the tooltip because a disabled button swallows the pointer events the trigger
273
+ listens for; the sr-only copy keeps the reason where `aria-describedby` points. */}
157
274
  <div className="mt-3">
158
- <button
159
- type="button"
160
- disabled
161
- aria-describedby="agent-contact-add-reason"
162
- className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm disabled:opacity-50"
163
- >
164
- <Plus aria-hidden="true" className="size-4" />
165
- {i18n.t("agent.contactAdd")}
166
- </button>
167
- <p id="agent-contact-add-reason" className="mt-1.5 text-xs text-muted-foreground">
275
+ <TooltipProvider delayDuration={300}>
276
+ <Tooltip>
277
+ <TooltipTrigger asChild>
278
+ <span className="inline-flex">
279
+ <button
280
+ type="button"
281
+ disabled
282
+ aria-describedby="agent-contact-add-reason"
283
+ className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm disabled:opacity-50"
284
+ >
285
+ <Plus aria-hidden="true" className="size-4" />
286
+ {i18n.t("agent.contactAdd")}
287
+ </button>
288
+ </span>
289
+ </TooltipTrigger>
290
+ <TooltipContent className="max-w-xs">{i18n.t("agent.contactAddReason")}</TooltipContent>
291
+ </Tooltip>
292
+ </TooltipProvider>
293
+ <p id="agent-contact-add-reason" className="sr-only">
168
294
  {i18n.t("agent.contactAddReason")}
169
295
  </p>
170
296
  </div>
@@ -191,14 +317,13 @@ function KnowledgeSection({
191
317
  const [adding, setAdding] = useState(false);
192
318
 
193
319
  function write(references: AgentReference[]) {
194
- agent.save.mutate({ ...definition, references });
320
+ agent.save.mutate({ ...asDraft(definition), references });
195
321
  }
196
322
 
197
323
  return (
198
324
  <Section title={i18n.t("agent.knows")} hint={i18n.t("agent.knowsHint")}>
199
- {definition.references.length === 0 ? (
200
- <p className="text-sm text-muted-foreground">{i18n.t("agent.knowsEmpty")}</p>
201
- ) : (
325
+ {/* An empty list shows nothing at all — the add button says what belongs here (#204). */}
326
+ {definition.references.length === 0 ? null : (
202
327
  <ul className="space-y-2">
203
328
  {/* ⚠️ The position IS the identity here: `references` is an ordered array with no ids,
204
329
  the contract permits the same folder twice, and every edit below addresses a row by
@@ -312,39 +437,130 @@ function EntryTitle({ entryId, flow = false }: { entryId: string; flow?: boolean
312
437
  }
313
438
 
314
439
  /**
315
- * What the agent may call.
440
+ * What the agent may call: whole MCP servers, given from the signed-in person's own portal
441
+ * connection (D30).
442
+ *
443
+ * ⚠️ What is saved is a SELECTION, never a catalog. The tools behind a server stay a live
444
+ * `tools/list` made with the delegator's token at the moment the agent runs (ADR-0003), so a server
445
+ * listed here is a name, not a promise — losing it in the portal takes it away from the agent with
446
+ * nothing to edit.
316
447
  *
317
- * ⚠️ Read-only, and read live. Intel stores no tool permissions the catalog is one `tools/list`
318
- * with the signed-in user's own portal token (ADR-0003), and this section shows exactly what the
319
- * Tools screen shows because it asks the same question through the same provider method. Mirroring
320
- * the catalog into the definition is the one thing this product must not do.
448
+ * ⚠️ The notice is not decoration, and it stands whether or not anything is listed yet. A shared
449
+ * agent runs on the DELEGATOR's connection whoever starts it, which is a real widening of who can
450
+ * act as this person — so it belongs where somebody reads it BEFORE clicking `+ Add tool`, not as a
451
+ * footnote that appears once the first server is already given away. The empty list itself stays
452
+ * wordless, the way the other sections' do (#204): the button says what belongs here.
321
453
  */
322
- function ToolsSection() {
454
+ function ToolsSection({
455
+ definition,
456
+ agent,
457
+ }: {
458
+ definition: AgentDefinition;
459
+ agent: AgentDefinitionHandle;
460
+ }) {
323
461
  const { data, i18n } = useIntelRouterContext();
324
- const catalog = useQuery({ queryKey: ["tools"], queryFn: () => data.listTools() });
462
+ const [adding, setAdding] = useState(false);
463
+ const servers = useQuery({ queryKey: ["tool-servers"], queryFn: () => data.listToolServers() });
464
+ const selected = definition.tools?.servers ?? [];
465
+
466
+ // ⚠️ Servers, and nothing about who delegates them. The screen has no `delegatedBy` to send and
467
+ // no business inventing one — Intel writes it from the session, and `AgentDefinitionInput` has no
468
+ // field it could travel in. This is also what lets an agent with `tools: null`, which is every
469
+ // freshly created one, receive its first server at all.
470
+ function write(next: string[]) {
471
+ agent.save.mutate({ ...asDraft(definition), tools: { servers: next } });
472
+ }
473
+
474
+ const available = (servers.data?.items ?? []).filter(
475
+ (server) => !selected.includes(server.handle),
476
+ );
325
477
 
326
478
  return (
327
479
  <Section title={i18n.t("agent.tools")} hint={i18n.t("agent.toolsHint")}>
328
- {catalog.isPending ? (
329
- <p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
330
- ) : catalog.isError ? (
331
- <p role="alert" className="text-sm text-destructive">
332
- {i18n.t("tools.unreachableHelp")}
333
- </p>
334
- ) : catalog.data && catalog.data.items.length === 0 ? (
335
- <p className="text-sm text-muted-foreground">{i18n.t("tools.emptyHelp")}</p>
336
- ) : (
337
- <ul className="flex flex-wrap gap-2">
338
- {catalog.data?.items.map((tool) => (
339
- <li
340
- key={tool.name}
341
- className="rounded-full border bg-card px-3 py-1 text-xs text-muted-foreground"
342
- >
343
- {tool.name}
344
- </li>
345
- ))}
480
+ {selected.length === 0 ? null : (
481
+ <ul className="space-y-2">
482
+ {selected.map((handle) => {
483
+ const known = servers.data?.items.find((server) => server.handle === handle);
484
+ return (
485
+ <li
486
+ key={handle}
487
+ className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3"
488
+ >
489
+ <Wrench aria-hidden="true" className="size-4 shrink-0 text-primary" />
490
+ <span
491
+ className={`min-w-0 flex-1 truncate text-sm${known ? "" : " text-muted-foreground"}`}
492
+ >
493
+ {known?.name ?? handle}
494
+ </span>
495
+ {/* A server the signed-in person no longer reaches still stands in the definition,
496
+ and saying so beats drawing it as if it worked. The agent gets nothing from it
497
+ either — the catalog is cut against what the delegator reaches. */}
498
+ <span className="text-xs text-muted-foreground">
499
+ {known
500
+ ? i18n.t("tools.toolCount", { count: String(known.toolCount) })
501
+ : i18n.t("agent.toolServerUnavailable")}
502
+ </span>
503
+ <button
504
+ type="button"
505
+ onClick={() => write(selected.filter((current) => current !== handle))}
506
+ aria-label={i18n.t("agent.removeToolServerOf", { name: known?.name ?? handle })}
507
+ className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
508
+ >
509
+ <Trash2 aria-hidden="true" className="size-4" />
510
+ </button>
511
+ </li>
512
+ );
513
+ })}
346
514
  </ul>
347
515
  )}
516
+ <button
517
+ type="button"
518
+ onClick={() => setAdding(true)}
519
+ className="mt-3 inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
520
+ >
521
+ <Plus aria-hidden="true" className="size-4" />
522
+ {i18n.t("agent.addTool")}
523
+ </button>
524
+ <p className="mt-3 text-xs text-muted-foreground">{i18n.t("agent.toolsDelegationNotice")}</p>
525
+ {adding ? (
526
+ <Modal title={i18n.t("agent.addTool")} close={() => setAdding(false)}>
527
+ {servers.isPending ? (
528
+ <p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
529
+ ) : servers.isError ? (
530
+ <p role="alert" className="text-sm text-destructive">
531
+ {i18n.t("tools.unreachableHelp")}
532
+ </p>
533
+ ) : servers.data?.portalConnected === false ? (
534
+ <p className="text-sm text-muted-foreground">{i18n.t("agent.toolsNotConnected")}</p>
535
+ ) : available.length === 0 ? (
536
+ <p className="text-sm text-muted-foreground">{i18n.t("agent.toolsNoneLeft")}</p>
537
+ ) : (
538
+ <ul className="space-y-2">
539
+ {available.map((server) => (
540
+ <li key={server.handle}>
541
+ <button
542
+ type="button"
543
+ onClick={() => {
544
+ write([...selected, server.handle]);
545
+ setAdding(false);
546
+ }}
547
+ className="flex w-full items-center gap-3 rounded-lg border px-4 py-3 text-left outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
548
+ >
549
+ <Wrench aria-hidden="true" className="size-4 shrink-0 text-primary" />
550
+ <span className="min-w-0 flex-1 truncate text-sm">{server.name}</span>
551
+ <span className="text-xs text-muted-foreground">
552
+ {i18n.t("tools.toolCount", { count: String(server.toolCount) })}
553
+ </span>
554
+ </button>
555
+ </li>
556
+ ))}
557
+ </ul>
558
+ )}
559
+ <p className="mt-4 text-xs text-muted-foreground">
560
+ {i18n.t("agent.toolsDelegationNotice")}
561
+ </p>
562
+ </Modal>
563
+ ) : null}
348
564
  </Section>
349
565
  );
350
566
  }
@@ -367,14 +583,12 @@ function ScheduleSection({
367
583
  const [adding, setAdding] = useState(false);
368
584
 
369
585
  function write(schedules: AgentSchedule[]) {
370
- agent.save.mutate({ ...definition, schedules });
586
+ agent.save.mutate({ ...asDraft(definition), schedules });
371
587
  }
372
588
 
373
589
  return (
374
590
  <Section title={i18n.t("agent.schedule")} hint={i18n.t("agent.scheduleHint")}>
375
- {definition.schedules.length === 0 ? (
376
- <p className="text-sm text-muted-foreground">{i18n.t("agent.scheduleEmpty")}</p>
377
- ) : (
591
+ {definition.schedules.length === 0 ? null : (
378
592
  <ul className="space-y-2">
379
593
  {definition.schedules.map((schedule, index) => (
380
594
  <li
@@ -492,7 +706,7 @@ function ModelSection({
492
706
  value={modelKey(definition.model)}
493
707
  onValueChange={(key) => {
494
708
  const model = parseModelKey(key);
495
- if (model) agent.save.mutate({ ...definition, model });
709
+ if (model) agent.save.mutate({ ...asDraft(definition), model });
496
710
  }}
497
711
  >
498
712
  <SelectTrigger aria-label={i18n.t("agent.model")} className="w-full max-w-md">
@@ -46,3 +46,23 @@ export function agentAccessDenied(error: unknown): boolean {
46
46
  export function agentRuntimeMissing(error: unknown): boolean {
47
47
  return error instanceof IntelRequestError && error.code === "agent_runtime_not_configured";
48
48
  }
49
+
50
+ /**
51
+ * The failure that has a repair path, and a name for it (D29, #207, and the short half of #200).
52
+ *
53
+ * ⚠️ The code, not the 500. An agent whose Durable Object holds no key starts no run at all — the
54
+ * runtime refuses before Gate is asked — and until #207 that arrived on this page as
55
+ * `That did not work. Try again.` next to `Run now`, which is advice that cannot work: trying again
56
+ * produces the same refusal forever. Naming it is what lets the page send the reader to the one
57
+ * thing that fixes it, which is replacing the key in the agent's profile.
58
+ *
59
+ * ⚠️ `agent_key_unreadable` counts as the same case on purpose. Its cause is different — the
60
+ * deployment's master secret was replaced — but the reader's move is identical, and a second
61
+ * sentence saying the same thing differently would only be a second thing to keep in step.
62
+ */
63
+ export function agentKeyMissing(error: unknown): boolean {
64
+ return (
65
+ error instanceof IntelRequestError &&
66
+ (error.code === "agent_key_missing" || error.code === "agent_key_unreadable")
67
+ );
68
+ }
@@ -10,6 +10,7 @@ import { AgentLog } from "@/agent/agent-log/agent-log.tsx";
10
10
  import { AgentProfile } from "@/agent/agent-profile/agent-profile.tsx";
11
11
  import {
12
12
  agentAccessDenied,
13
+ agentKeyMissing,
13
14
  agentRuntimeMissing,
14
15
  agentStateKey,
15
16
  useAgentState,
@@ -260,9 +261,12 @@ export function AgentActions({
260
261
  {runReason}
261
262
  </span>
262
263
  ) : null}
264
+ {/* ⚠️ An agent with no key in its Durable Object fails every run the same way forever, so
265
+ "try again" is advice that cannot work (#200). The one refusal with a repair path says
266
+ what the repair is, and names where it lives — the profile, not this bar (D29). */}
263
267
  {failure ? (
264
268
  <span role="alert" className="text-xs text-destructive max-sm:sr-only sm:inline">
265
- {i18n.t("agent.actionFailed")}
269
+ {i18n.t(agentKeyMissing(failure) ? "agent.keyMissing" : "agent.actionFailed")}
266
270
  </span>
267
271
  ) : null}
268
272
  <button
@@ -213,7 +213,9 @@ export function AppTree() {
213
213
  parentId,
214
214
  title,
215
215
  description: null,
216
- definition: { references: [], schedules: [], model: defaultModel() },
216
+ // A new agent has no tools: giving it any is a choice made in the profile, out of what
217
+ // this person reaches in the portal (D30).
218
+ definition: { references: [], schedules: [], model: defaultModel(), tools: null },
217
219
  idempotencyKey: crypto.randomUUID(),
218
220
  });
219
221
  return {
@@ -1,4 +1,5 @@
1
1
  import {
2
+ AgentKeyRotated,
2
3
  AppendTableRowsInput,
3
4
  AppendTableRowsResult,
4
5
  ArchiveFlowInput,
@@ -52,6 +53,7 @@ import {
52
53
  ShareResult,
53
54
  StartFlowRunInput,
54
55
  ToolCatalog,
56
+ ToolServerCatalog,
55
57
  UpdateFlowInput,
56
58
  UpdateNodeInput,
57
59
  } from "@anchrd/intel-contract";
@@ -461,18 +463,38 @@ export function createIntelDataProvider(
461
463
  async listTools() {
462
464
  return await request("/tools", ToolCatalog);
463
465
  },
466
+ // The servers behind the same live list (D30). Its own call rather than a grouping done in the
467
+ // browser: which server a tool belongs to is the portal's answer, and a UI that split tool
468
+ // names would be guessing where the namespace ends.
469
+ async listToolServers() {
470
+ return await request("/tools/servers", ToolServerCatalog);
471
+ },
464
472
  async getAgent(nodeId) {
465
473
  return await request(`/nodes/${encodeURIComponent(nodeId)}/agent`, NodeAgent);
466
474
  },
467
- // ⚠️ `CreatedAgent`, not `NodeAgent`: the create answer carries the Gate application key once
468
- // (#182), and the read schema is strict parsing this response with it would reject the one
469
- // response in the product that has a credential in it.
475
+ // ⚠️ `CreatedAgent` is `NodeAgent` since D29, and that is the point rather than a leftover: the
476
+ // create answer used to carry the Gate application key once (#182), and the browser had to be
477
+ // given a credential it could not do anything with. It now goes from Gate into the agent runtime
478
+ // inside the request, so the create answers exactly what a read answers.
470
479
  async createAgent(input) {
471
480
  return await request("/nodes/agents", CreatedAgent, {
472
481
  method: "POST",
473
482
  body: JSON.stringify(CreateAgentInput.parse(input)),
474
483
  });
475
484
  },
485
+ /**
486
+ * Replace the key of the Gate Application an agent runs as (D29, #207).
487
+ *
488
+ * ⚠️ The repair path for `agent_key_missing`, and the answer carries no key — intel asks Gate
489
+ * and hands the new one to the runtime itself. Nothing here has a credential to hold.
490
+ */
491
+ async rotateAgentKey(nodeId) {
492
+ return await request(
493
+ `/nodes/agents/${encodeURIComponent(nodeId)}/rotate-key`,
494
+ AgentKeyRotated,
495
+ { method: "POST" },
496
+ );
497
+ },
476
498
  async saveAgentDefinition(input) {
477
499
  const parsed = SaveAgentDefinitionInput.parse(input);
478
500
  return await request(`/nodes/${encodeURIComponent(parsed.nodeId)}/agent`, NodeAgent, {
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ AgentKeyRotated,
2
3
  AgentScheduleTarget,
3
4
  AppendTableRowsInput,
4
5
  AppendTableRowsResult,
@@ -53,6 +54,7 @@ import type {
53
54
  ShareResult,
54
55
  StartFlowRunInput,
55
56
  ToolCatalog,
57
+ ToolServerCatalog,
56
58
  UpdateFlowInput,
57
59
  UpdateNodeInput,
58
60
  } from "@anchrd/intel-contract";
@@ -164,6 +166,7 @@ export interface IntelDataProvider {
164
166
  // Reading the catalog is the whole of the tool surface here: calling a tool belongs to a flow
165
167
  // or to the Intel MCP surface, not to the screen that shows what the portal offers.
166
168
  listTools(): Promise<ToolCatalog>;
169
+ listToolServers(): Promise<ToolServerCatalog>;
167
170
 
168
171
  // An agent node and the definition its current version carries. `definition` is `null` in the one
169
172
  // window where the node exists and no version has been written — the same window a document's
@@ -177,6 +180,10 @@ export interface IntelDataProvider {
177
180
  // is edited in place (ADR-0005). `baseVersionId` is the version the editor read, so a parallel
178
181
  // change is refused rather than silently overwritten.
179
182
  saveAgentDefinition(input: SaveAgentDefinitionInput): Promise<NodeAgent>;
183
+ // Replace the key of the Gate Application an agent runs as (D29). The repair path an agent that
184
+ // answers `agent_key_missing` has, and the only one — Gate keeps a key hashed. ⚠️ No key comes
185
+ // back: intel hands the new one to the agent runtime itself.
186
+ rotateAgentKey(nodeId: string): Promise<AgentKeyRotated>;
180
187
 
181
188
  // ⚠️ The seven below are answered by the agent RUNTIME, a separate Worker — but they are asked of
182
189
  // intel, which proxies them with the caller's own Gate token (#178). One origin, one cookie, no
package/src/i18n/en.json CHANGED
@@ -170,7 +170,10 @@
170
170
  "tools.connectFailed": "Portal sign-in failed",
171
171
  "tools.connectFailedHelp": "The sign-in to the company MCP portal broke before it could be answered. This is a technical fault, not a decision about your access — reload to try again, and tell an administrator if it stays.",
172
172
  "tools.origin": "Origin",
173
- "tools.originPortal": "Company portal",
173
+ "tools.originUnlisted": "Not from a listed server",
174
+ "tools.originUnlistedHelp": "The portal's server list names no server for these. Its own management tools sit here — the portal does not list itself — and so does anything from a server the list did not mention.",
175
+ "tools.ungrouped": "Not sorted by server",
176
+ "tools.ungroupedHelp": "The company MCP portal did not answer which servers these tools come from, so Intel cannot say which tool belongs where. Everything you can reach is listed below; only the grouping is missing.",
174
177
  "tools.inputSchema": "Input schema",
175
178
  "tools.outputSchema": "Result schema",
176
179
  "tools.destructiveShort": "Destructive",
@@ -292,11 +295,21 @@
292
295
  "agent.runNowNoTarget": "This agent has no schedule, so there is nothing to run.",
293
296
  "agent.runNowPaused": "Resume the agent before running it.",
294
297
  "agent.actionFailed": "That did not work. Try again.",
298
+ "agent.keyMissing": "This agent has no application key, so no run can start. Replace its key under Identity in the profile.",
295
299
  "agent.notPermitted": "You may read this agent, but not drive it. Driving an agent needs the “run agents” permission.",
296
300
  "agent.noRuntime": "This installation runs no agent runtime, so this agent cannot chat or run here. Its definition is kept and works again on an installation that has one.",
297
301
  "agent.loadFailed": "This agent could not be loaded. Check your access and try again.",
298
302
  "agent.saveFailed": "The change was not saved. Somebody else may have changed this agent first — reload and try again.",
299
303
  "agent.noDefinition": "This agent has no definition yet, so there is nothing to change here.",
304
+ "agent.identity": "Identity",
305
+ "agent.identityHint": "The Gate application this agent acts as. Its grants in the tree decide what a run may read and write, and its key lives encrypted in the agent runtime — never here and never in the definition.",
306
+ "agent.principal": "Gate application",
307
+ "agent.principalNone": "None",
308
+ "agent.principalNoneHint": "This agent has no Gate application, so there is no key to replace. Agents created before this feature, imported, or restored from a bundle are given one in Gate.",
309
+ "agent.rotateKey": "Replace key",
310
+ "agent.rotateKeyConfirm": "A new key is issued and given to the agent runtime. The previous key stops working immediately, and no key is ever shown here.",
311
+ "agent.rotateKeyDone": "The key was replaced. The agent runs with the new one from its next run.",
312
+ "agent.rotateKeyFailed": "The key was not replaced. Try again — and keep trying until it succeeds: if Gate already issued the new key, this agent cannot run until the agent runtime has it.",
300
313
  "agent.contact": "Contact",
301
314
  "agent.contactHint": "How this agent is reached. The built-in chat is part of the runtime and is always there.",
302
315
  "agent.contactChat": "Built-in chat",
@@ -308,7 +321,6 @@
308
321
  "agent.contactAddReason": "A mail address belongs to the installation, not to the agent's definition, so it is configured where the runtime is deployed.",
309
322
  "agent.knows": "What it knows",
310
323
  "agent.knowsHint": "The folders this agent reads, and what each one counts as. A system message instructs it, semantic context is searched, memory is written back to.",
311
- "agent.knowsEmpty": "This agent reads nothing yet.",
312
324
  "agent.addReference": "Add folder",
313
325
  "agent.removeReferenceOf": "Stop reading {title}",
314
326
  "agent.pickFolder": "Folder to read",
@@ -318,7 +330,13 @@
318
330
  "agent.role.memory": "Memory",
319
331
  "agent.unknownEntry": "Not available to you",
320
332
  "agent.tools": "Tools",
321
- "agent.toolsHint": "Read live from the company portal with your own access. Intel stores no tool permissions; the portal decides who may reach what.",
333
+ "agent.toolsHint": "The MCP servers you give this agent, out of the ones you reach yourself. Intel stores the choice, never the permission — the tools behind a server are read live with your portal access every time the agent runs.",
334
+ "agent.addTool": "Add tool",
335
+ "agent.removeToolServerOf": "Take {name} away from this agent",
336
+ "agent.toolServerUnavailable": "You no longer reach this server",
337
+ "agent.toolsDelegationNotice": "Anyone who may run this agent acts on your connection.",
338
+ "agent.toolsNotConnected": "You are not signed in to the company portal yet, so there is nothing to give away. Open Tools to connect.",
339
+ "agent.toolsNoneLeft": "Every server you reach is already given to this agent.",
322
340
  "agent.schedule": "Schedule",
323
341
  "agent.scheduleHint": "When this agent acts on its own, and what it runs. Times are UTC — a definition carries no timezone.",
324
342
  "agent.scheduleEmpty": "This agent runs only when it is asked.",
@@ -114,6 +114,7 @@ export function Nodes() {
114
114
  description={selected.description}
115
115
  target={{ type: "node", node: selected }}
116
116
  leading={selected.kind === "agent" ? <AgentAvatar title={selected.title} /> : null}
117
+ divider={selected.kind !== "agent"}
117
118
  >
118
119
  {selected.kind !== "folder" && (
119
120
  <TooltipProvider delayDuration={300}>
@@ -21,6 +21,7 @@ export function TitleRow({
21
21
  description,
22
22
  target,
23
23
  leading,
24
+ divider = true,
24
25
  children,
25
26
  }: {
26
27
  title: string;
@@ -30,10 +31,15 @@ export function TitleRow({
30
31
  // prop rather than another slot because it must sit BEFORE the title, and a portal can only
31
32
  // append — the same limitation that gave the shell two boxes instead of one list (#56).
32
33
  leading?: React.ReactNode;
34
+ // Off for the one kind whose next line is its own tab bar: two rules a few pixels apart read as
35
+ // clutter, so the agent page keeps only the tab bar's (#204).
36
+ divider?: boolean;
33
37
  children?: React.ReactNode;
34
38
  }) {
35
39
  return (
36
- <div className="flex items-start justify-between gap-5 border-b px-6 py-4">
40
+ <div
41
+ className={`flex items-start justify-between gap-5 px-6 py-4${divider ? " border-b" : ""}`}
42
+ >
37
43
  <div className="flex min-w-0 items-center gap-3">
38
44
  {leading}
39
45
  <div className="min-w-0">
@@ -1,4 +1,4 @@
1
- import type { ToolCapability } from "@anchrd/intel-contract";
1
+ import { serverOf, type ToolCapability, type ToolServer } from "@anchrd/intel-contract";
2
2
  import { useQuery } from "@tanstack/react-query";
3
3
  import { useRouterState } from "@tanstack/react-router";
4
4
  import { AlertTriangle, ChevronRight, PlugZap, ShieldOff, Timer, Wrench } from "lucide-react";
@@ -9,38 +9,55 @@ import type { I18n } from "@/i18n/i18n.types.ts";
9
9
  import { useIntelRouterContext } from "@/router/router-context.ts";
10
10
  import { selectedFrom } from "@/router/selection-search.ts";
11
11
 
12
- // The portal namespaces every tool as `<server>__<tool>`, and that prefix is the whole of what
13
- // Intel ever learns about a tool's origin: the portal resolves the real server and holds its
14
- // credentials (ADR-0003). A name without a namespace has no origin beyond the portal itself.
15
- const NamespaceSeparator = "__";
16
-
17
- function toolOrigin(name: string): string | null {
18
- const boundary = name.indexOf(NamespaceSeparator);
19
- return boundary > 0 ? name.slice(0, boundary) : null;
20
- }
21
-
22
12
  interface ToolGroup {
23
- // `null` is a tool the portal answers without a namespace: its origin is the portal itself.
24
- origin: string | null;
13
+ // The handle the portal named, or `null` for the box holding what no named server carries.
14
+ handle: string | null;
15
+ label: string;
25
16
  items: ToolCapability[];
26
17
  }
27
18
 
28
- // The catalog arrives as one flat list of a hundred-odd names, and the namespace is the only thing
29
- // that says which server a tool belongs to. Grouping is therefore not decoration — it is the one
30
- // piece of structure the wire carries, made visible (#99).
31
- //
32
- // The order is the portal's own: a group appears where its first tool appears. Sorting here would
33
- // replace an answer with a preference, and the portal already returns a server's tools together.
34
- function groupByOrigin(items: ToolCapability[]): ToolGroup[] {
19
+ /**
20
+ * The tools of one catalog, under the servers the portal itself named (#212).
21
+ *
22
+ * ⚠️ The grouping comes from `GET /api/v1/tools/servers`, never from the tool name alone. The name
23
+ * cannot say where the server part ends `intel_flow_get` reads as `intel` + `flow_get` and as
24
+ * `intel_flow` + `get` so this screen matches the portal's own handles with the same `serverOf`
25
+ * the delegation is cut with, longest match wins. The earlier reading, a cut at a separator, was
26
+ * shipped twice and was wrong twice: at `__` it put all 92 tools of the running installation in one
27
+ * box, because the portal writes a single underscore (#106), and at `_` it would break the first
28
+ * server whose slug carries one (#107, closed for that reason).
29
+ *
30
+ * ⚠️ A tool that matches no named handle is NOT dropped. It goes into its own box: the portal's
31
+ * management tools (`portal_list_servers`) live there, because the portal does not list itself as
32
+ * an upstream, and so does anything from a server the directory failed to mention. Hiding those
33
+ * would make the screen quietly disagree with `tools/list` — and the count on the lid is what the
34
+ * reader compares against the portal.
35
+ *
36
+ * The order is the portal's own: a group appears where its first tool appears, because the portal
37
+ * returns a server's tools together and sorting would replace its answer with a preference. Only
38
+ * the unattributed box is forced last — it is a remainder, not a server, and it must not lead.
39
+ */
40
+ function groupByServer(
41
+ items: ToolCapability[],
42
+ servers: ToolServer[],
43
+ unlistedLabel: string,
44
+ ): ToolGroup[] {
45
+ const named = new Map(servers.map((server) => [server.handle, server.name]));
35
46
  const groups = new Map<string, ToolGroup>();
47
+ const unlisted: ToolGroup = { handle: null, label: unlistedLabel, items: [] };
36
48
  for (const item of items) {
37
- const origin = toolOrigin(item.name);
38
- const key = origin ?? "";
39
- const group = groups.get(key) ?? { origin, items: [] };
49
+ const handle = serverOf(item.name, named.keys());
50
+ if (handle === null) {
51
+ unlisted.items.push(item);
52
+ continue;
53
+ }
54
+ // The server's display name, not its handle: `notion` is what the wire says, and what the
55
+ // portal calls it is what the reader knows it by.
56
+ const group = groups.get(handle) ?? { handle, label: named.get(handle) ?? handle, items: [] };
40
57
  group.items.push(item);
41
- groups.set(key, group);
58
+ groups.set(handle, group);
42
59
  }
43
- return [...groups.values()];
60
+ return unlisted.items.length > 0 ? [...groups.values(), unlisted] : [...groups.values()];
44
61
  }
45
62
 
46
63
  // Signed in to Intel is signed in to the portal, so the sign-in is attempted at most once per
@@ -65,6 +82,22 @@ function attemptStore(): Storage | null {
65
82
  export function Tools() {
66
83
  const { data, i18n } = useIntelRouterContext();
67
84
  const catalog = useQuery({ queryKey: ["tools"], queryFn: () => data.listTools() });
85
+ // The servers behind that same live list (D30). Its own query, and its own failure: a portal that
86
+ // answers tools but no directory still has a usable screen, it just has no boxes to put them in.
87
+ // The key is the one the agent profile already reads the directory under, so a visit to either
88
+ // screen serves the other from cache.
89
+ // ⚠️ No retry on this one, and that is a decision about the screen rather than about the request.
90
+ // The tools are held back until the directory has answered, so every further attempt is time
91
+ // somebody spends in front of the word "Loading" — and a retry does not even have to happen: a
92
+ // React Query retry is PAUSED while the tab is unfocused, so a screen waiting on one can stay
93
+ // "Loading" for as long as the reader is looking elsewhere. A portal that offers no directory
94
+ // answers the same 502 every time anyway; one attempt settles it, and what follows is the list
95
+ // plus the sentence saying why it is not sorted.
96
+ const servers = useQuery({
97
+ queryKey: ["tool-servers"],
98
+ queryFn: () => data.listToolServers(),
99
+ retry: false,
100
+ });
68
101
  // A hit from the header search arrives as `?select=<tool name>`, and the row it names opens with
69
102
  // the list. Nothing else about the row changes: it is still only a disclosure.
70
103
  const requested = useRouterState({ select: (state) => selectedFrom(state.location.search) });
@@ -162,26 +195,77 @@ export function Tools() {
162
195
  title={i18n.t("tools.empty")}
163
196
  help={i18n.t("tools.emptyHelp")}
164
197
  />
198
+ ) : servers.isPending ? (
199
+ // The tools are here and the servers are not yet. Showing the flat list first and
200
+ // reshuffling it a moment later would be a screen that moves under the reader's hand.
201
+ <p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
165
202
  ) : (
166
- <>
167
- <div className="space-y-3">
168
- {groupByOrigin(catalog.data.items).map((group) => (
169
- <ServerGroup
170
- key={group.origin ?? ""}
171
- group={group}
172
- i18n={i18n}
173
- requested={requested}
174
- />
175
- ))}
176
- </div>
177
- <p className="text-xs text-muted-foreground">{i18n.t("tools.liveNote")}</p>
178
- </>
203
+ <ToolCatalogView
204
+ items={catalog.data.items}
205
+ servers={servers.isError ? [] : servers.data.items}
206
+ i18n={i18n}
207
+ requested={requested}
208
+ />
179
209
  )}
180
210
  </div>
181
211
  </div>
182
212
  );
183
213
  }
184
214
 
215
+ /**
216
+ * The catalog, under the portal's servers where there are any.
217
+ *
218
+ * ⚠️ No directory, no boxes — and the screen says why instead of inventing them. A portal that does
219
+ * not offer `portal_list_servers`, or whose answer Intel cannot read, makes `/tools/servers` fail,
220
+ * and every alternative to this sentence is a guess: a cut at an underscore, or the one box called
221
+ * "Company portal" that held all 92 tools of the running installation (#106). The tools are all
222
+ * here, in the order the portal answered them; only the grouping is missing.
223
+ */
224
+ function ToolCatalogView({
225
+ items,
226
+ servers,
227
+ i18n,
228
+ requested,
229
+ }: {
230
+ items: ToolCapability[];
231
+ servers: ToolServer[];
232
+ i18n: I18n;
233
+ requested: string | null;
234
+ }) {
235
+ if (servers.length === 0) {
236
+ return (
237
+ <>
238
+ <section className="rounded-xl border border-dashed bg-card px-5 py-4">
239
+ <h2 className="text-sm font-semibold">{i18n.t("tools.ungrouped")}</h2>
240
+ <p className="mt-1 text-sm text-muted-foreground">{i18n.t("tools.ungroupedHelp")}</p>
241
+ </section>
242
+ {/* Open, not a lid: with no server to name, a closed box would hide the whole catalog
243
+ behind a word that says nothing. */}
244
+ <section className="overflow-hidden rounded-xl border bg-card shadow-sm">
245
+ <ToolRows items={items} i18n={i18n} requested={requested} />
246
+ </section>
247
+ <p className="text-xs text-muted-foreground">{i18n.t("tools.liveNote")}</p>
248
+ </>
249
+ );
250
+ }
251
+ return (
252
+ <>
253
+ <div className="space-y-3">
254
+ {groupByServer(items, servers, i18n.t("tools.originUnlisted")).map((group) => (
255
+ <ServerGroup
256
+ key={group.handle ?? ""}
257
+ group={group}
258
+ note={group.handle === null ? i18n.t("tools.originUnlistedHelp") : null}
259
+ i18n={i18n}
260
+ requested={requested}
261
+ />
262
+ ))}
263
+ </div>
264
+ <p className="text-xs text-muted-foreground">{i18n.t("tools.liveNote")}</p>
265
+ </>
266
+ );
267
+ }
268
+
185
269
  function Notice({
186
270
  icon,
187
271
  title,
@@ -213,10 +297,14 @@ function Notice({
213
297
  // somebody wants to know about it (#99).
214
298
  function ServerGroup({
215
299
  group,
300
+ note,
216
301
  i18n,
217
302
  requested,
218
303
  }: {
219
304
  group: ToolGroup;
305
+ // Why this box exists at all, for the one box that is not a server. A group named by the portal
306
+ // needs no explanation; the remainder does, or it reads as a server called something odd.
307
+ note: string | null;
220
308
  i18n: I18n;
221
309
  requested: string | null;
222
310
  }) {
@@ -224,14 +312,13 @@ function ServerGroup({
224
312
  // with it — otherwise Enter on a hit lands on a screen where nothing happened.
225
313
  const holdsRequested = group.items.some((item) => item.name === requested);
226
314
  const destructive = group.items.filter((item) => item.annotations.destructiveHint).length;
227
- const label = group.origin ?? i18n.t("tools.originPortal");
228
315
  const count =
229
316
  group.items.length === 1
230
317
  ? i18n.t("tools.toolCountOne")
231
318
  : i18n.t("tools.toolCount", { count: group.items.length });
232
319
  // The badges beside the name read as one run-on word when a screen reader concatenates them
233
320
  // ("wiki1 tool"), so the control says its own name instead of being read off its contents.
234
- const spoken = [`${i18n.t("tools.origin")}: ${label}`, count];
321
+ const spoken = [`${i18n.t("tools.origin")}: ${group.label}`, count];
235
322
  if (destructive > 0) spoken.push(i18n.t("tools.destructiveCount", { count: destructive }));
236
323
 
237
324
  return (
@@ -246,7 +333,7 @@ function ServerGroup({
246
333
  className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90"
247
334
  />
248
335
  <span className="min-w-0 flex-1">
249
- <span className="block truncate text-sm font-semibold">{label}</span>
336
+ <span className="block truncate text-sm font-semibold">{group.label}</span>
250
337
  </span>
251
338
  {/* Closed, the box hides every destructive badge inside it. The count says so on the lid
252
339
  rather than making somebody open each server to find out. */}
@@ -258,24 +345,39 @@ function ServerGroup({
258
345
  )}
259
346
  <span className="shrink-0 text-xs text-muted-foreground">{count}</span>
260
347
  </CollapsibleTrigger>
261
- <CollapsibleContent>
262
- <ul className="divide-y border-t">
263
- {group.items.map((capability) => (
264
- <li key={capability.name}>
265
- <ToolEntry
266
- capability={capability}
267
- i18n={i18n}
268
- open={capability.name === requested}
269
- />
270
- </li>
271
- ))}
272
- </ul>
348
+ <CollapsibleContent className="border-t">
349
+ {note && (
350
+ <p className="border-b bg-muted/50 px-5 py-3 text-xs text-muted-foreground">{note}</p>
351
+ )}
352
+ <ToolRows items={group.items} i18n={i18n} requested={requested} />
273
353
  </CollapsibleContent>
274
354
  </section>
275
355
  </Collapsible>
276
356
  );
277
357
  }
278
358
 
359
+ // The rows themselves, with or without a box around them: the ungrouped screen shows the same list
360
+ // without a lid, and duplicating it there is how the two would drift apart.
361
+ function ToolRows({
362
+ items,
363
+ i18n,
364
+ requested,
365
+ }: {
366
+ items: ToolCapability[];
367
+ i18n: I18n;
368
+ requested: string | null;
369
+ }) {
370
+ return (
371
+ <ul className="divide-y">
372
+ {items.map((capability) => (
373
+ <li key={capability.name}>
374
+ <ToolEntry capability={capability} i18n={i18n} open={capability.name === requested} />
375
+ </li>
376
+ ))}
377
+ </ul>
378
+ );
379
+ }
380
+
279
381
  // One tool, one disclosure. The row is the only control in the list: it opens the schema and does
280
382
  // nothing else — enabling, sharing or permitting a tool happens in the portal, never here.
281
383
  function ToolEntry({