@anchrd/intel-ui 0.18.0 → 0.19.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.
Files changed (63) hide show
  1. package/package.json +2 -9
  2. package/src/app/app-tree/app-tree.tsx +59 -147
  3. package/src/app/settings-dialog/settings-dialog.tsx +10 -32
  4. package/src/data/intel-data-provider/intel-data-provider.ts +0 -163
  5. package/src/data/intel-data-provider/intel-data-provider.types.ts +0 -100
  6. package/src/entry-picker/entry-picker.tsx +6 -6
  7. package/src/i18n/de.json +6 -217
  8. package/src/i18n/en.json +6 -217
  9. package/src/i18n/es.json +6 -217
  10. package/src/import-bundle/import-bundle.tsx +152 -0
  11. package/src/kind-icon.ts +1 -15
  12. package/src/main.tsx +7 -10
  13. package/src/node-table/node-table.tsx +2 -2
  14. package/src/nodes/nodes.tsx +0 -34
  15. package/src/resource-menu/resource-menu.tsx +42 -14
  16. package/src/section-hint/section-hint.tsx +2 -2
  17. package/src/title-row/title-row.tsx +5 -22
  18. package/src/tools/tools.tsx +0 -2
  19. package/vite.config.ts +0 -4
  20. package/src/agent/agent-avatar/agent-avatar.tsx +0 -34
  21. package/src/agent/agent-calendar/agent-calendar.tsx +0 -160
  22. package/src/agent/agent-chat/agent-chat.tsx +0 -118
  23. package/src/agent/agent-costs/agent-costs.ts +0 -95
  24. package/src/agent/agent-cron/agent-cron.ts +0 -68
  25. package/src/agent/agent-definition/agent-definition.ts +0 -96
  26. package/src/agent/agent-delegation-notice/agent-delegation-notice.ts +0 -48
  27. package/src/agent/agent-entry-title/agent-entry-title.ts +0 -72
  28. package/src/agent/agent-log/agent-log.tsx +0 -308
  29. package/src/agent/agent-models/agent-models.ts +0 -166
  30. package/src/agent/agent-models/agent-models.types.ts +0 -24
  31. package/src/agent/agent-profile/agent-profile.tsx +0 -1006
  32. package/src/agent/agent-state/agent-state.ts +0 -85
  33. package/src/agent/agent-status-dot/agent-status-dot.ts +0 -73
  34. package/src/agent/agent.tsx +0 -507
  35. package/src/board/board-calendar/board-calendar.tsx +0 -89
  36. package/src/board/board-card/board-card.tsx +0 -106
  37. package/src/board/board-data/board-data.ts +0 -241
  38. package/src/board/board-data/board-data.types.ts +0 -63
  39. package/src/board/board-detail/board-detail.tsx +0 -629
  40. package/src/board/board-gantt/board-gantt.ts +0 -545
  41. package/src/board/board-gantt/board-gantt.tsx +0 -286
  42. package/src/board/board-graph/board-graph.ts +0 -174
  43. package/src/board/board-graph/board-graph.tsx +0 -168
  44. package/src/board/board-items/board-items.ts +0 -183
  45. package/src/board/board-kanban/board-kanban.ts +0 -137
  46. package/src/board/board-kanban/board-kanban.tsx +0 -257
  47. package/src/board/board-status/board-status.ts +0 -59
  48. package/src/board/board-statuses/board-statuses.ts +0 -63
  49. package/src/board/board-statuses/board-statuses.tsx +0 -228
  50. package/src/board/board-table/board-table.ts +0 -33
  51. package/src/board/board-table/board-table.tsx +0 -413
  52. package/src/board/board-views/board-views.tsx +0 -68
  53. package/src/board/board-views/board-views.types.ts +0 -29
  54. package/src/board/board.tsx +0 -251
  55. package/src/components/ui/item-calendar.tsx +0 -181
  56. package/src/components/ui/item-gantt.tsx +0 -463
  57. package/src/components/ui/kanban.tsx +0 -282
  58. package/src/data/agent-runtime/agent-runtime.ts +0 -131
  59. package/src/hooks/use-capabilities.ts +0 -22
  60. package/src/hooks/use-model-catalog.ts +0 -26
  61. package/src/timezone/timezone-combobox/timezone-combobox.tsx +0 -149
  62. package/src/timezone/timezone-context.tsx +0 -65
  63. package/src/timezone/timezone.ts +0 -174
@@ -1,1006 +0,0 @@
1
- import {
2
- type AgentCosts,
3
- type AgentCostWindow,
4
- type AgentDefinition,
5
- type AgentReference,
6
- AgentReferenceKinds,
7
- AgentReferenceRole,
8
- type AgentSchedule,
9
- agentReferenceRolesFor,
10
- type Node,
11
- type NodeKind,
12
- } from "@anchrd/intel-contract";
13
- import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
14
- import {
15
- Bot,
16
- Check,
17
- Copy,
18
- FileText,
19
- Folder,
20
- KanbanSquare,
21
- KeyRound,
22
- MessageSquare,
23
- Paperclip,
24
- Plus,
25
- Table2,
26
- Trash2,
27
- Wrench,
28
- } from "lucide-react";
29
- import { useState } from "react";
30
- import { costWindow, formatCost, useAgentCosts } from "@/agent/agent-costs/agent-costs.ts";
31
- import { type AgentDefinitionHandle, asDraft } from "@/agent/agent-definition/agent-definition.ts";
32
- import { delegationNotice } from "@/agent/agent-delegation-notice/agent-delegation-notice.ts";
33
- import { useEntryTitle } from "@/agent/agent-entry-title/agent-entry-title.ts";
34
- import {
35
- formatContextTokens,
36
- formatPricePerMillion,
37
- modelFacts,
38
- modelKey,
39
- parseModelKey,
40
- selectableModels,
41
- } from "@/agent/agent-models/agent-models.ts";
42
- import type { ModelFacts } from "@/agent/agent-models/agent-models.types.ts";
43
- import {
44
- Select,
45
- SelectContent,
46
- SelectItem,
47
- SelectTrigger,
48
- SelectValue,
49
- } from "@/components/ui/select";
50
- import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
51
- import { agentMcpAddress } from "@/data/agent-runtime/agent-runtime.ts";
52
- import { EntryPicker, type PickerKind } from "@/entry-picker/entry-picker.tsx";
53
- import { useModelCatalog } from "@/hooks/use-model-catalog.ts";
54
- import { useI18n } from "@/i18n/i18n-context.tsx";
55
- import { Modal } from "@/modal/modal.tsx";
56
- import { useIntelRouterContext } from "@/router/router-context.ts";
57
- import { SectionHint } from "@/section-hint/section-hint.tsx";
58
- import { TimezoneCombobox } from "@/timezone/timezone-combobox/timezone-combobox.tsx";
59
- import { useTimezone } from "@/timezone/timezone-context.tsx";
60
- import { useSessionUser, useUserName } from "@/user-name/user-name.ts";
61
-
62
- // No dividers between sections — the space is the separation (#204). The explanation sits behind
63
- // the icon rather than under the title, so the page shows what the agent IS and keeps the prose
64
- // for whoever asks.
65
- function Section({
66
- title,
67
- hint,
68
- children,
69
- }: {
70
- title: string;
71
- hint?: string;
72
- children: React.ReactNode;
73
- }) {
74
- return (
75
- <section aria-label={title} className="px-6 py-7">
76
- <div className="flex items-center gap-1.5">
77
- <h3 className="text-sm font-semibold">{title}</h3>
78
- {hint ? <SectionHint hint={hint} /> : null}
79
- </div>
80
- <div className="mt-3">{children}</div>
81
- </section>
82
- );
83
- }
84
-
85
- /**
86
- * Everything an agent is, on one page: how to reach it, what it reads, what it may call, when it
87
- * acts on its own, which model does the thinking, and who it is in Gate.
88
- *
89
- * ⚠️ Four of the six sections write, and they all write the SAME way — a whole new definition
90
- * version through `save` (see `agent-definition.ts`). Tools joined them with D30: what it writes is
91
- * a selection of whole MCP servers out of the signed-in person's own portal connection, never a
92
- * mirrored permission, so ADR-0003 still holds — the catalog behind a chosen server stays a live
93
- * `tools/list`.
94
- *
95
- * ⚠️ Contact and Identity are the two that do not. Identity acts in Gate and in the agent runtime
96
- * rather than on the document, and a key has no business being in a definition — a definition is
97
- * versioned, shared and read into model context (ADR-0005 §4).
98
- */
99
- export function AgentProfile({ node, agent }: { node: Node; agent: AgentDefinitionHandle }) {
100
- const i18n = useI18n();
101
- const definition = agent.definition;
102
-
103
- // The definition is `null` in exactly one window: the node exists and no version has been written
104
- // yet. Nothing on this page can be edited until there is one, and saying so beats five sections
105
- // of empty controls that all refuse.
106
- if (agent.query.isPending) {
107
- return <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>;
108
- }
109
- if (agent.query.isError) {
110
- return (
111
- <div role="alert" className="space-y-3 p-6 text-sm">
112
- <p className="text-destructive">{i18n.t("agent.loadFailed")}</p>
113
- <button
114
- type="button"
115
- onClick={() => void agent.query.refetch()}
116
- className="rounded-md border px-3 py-2 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
117
- >
118
- {i18n.t("common.retry")}
119
- </button>
120
- </div>
121
- );
122
- }
123
- if (!definition) {
124
- return <p className="p-6 text-sm text-muted-foreground">{i18n.t("agent.noDefinition")}</p>;
125
- }
126
-
127
- return (
128
- <div className="min-h-0 flex-1 overflow-y-auto">
129
- {agent.save.isError ? (
130
- <p role="alert" className="border-b bg-destructive/5 px-6 py-3 text-sm text-destructive">
131
- {i18n.t("agent.saveFailed")}
132
- </p>
133
- ) : null}
134
- <ContactSection node={node} />
135
- <KnowledgeSection definition={definition} agent={agent} />
136
- <ToolsSection definition={definition} agent={agent} />
137
- <ScheduleSection definition={definition} agent={agent} />
138
- <ModelSection definition={definition} agent={agent} agentId={node.id} />
139
- <IdentitySection node={node} agent={agent} />
140
- </div>
141
- );
142
- }
143
-
144
- /**
145
- * Who the agent IS to the rest of the installation, and the one button that repairs it (D29, #207).
146
- *
147
- * ⚠️ The application ID is shown and the key is not, because only one of the two is a name. The key
148
- * exists in Gate for the length of one call and goes straight into the agent runtime; there is
149
- * nothing for this page to reveal, copy or store, and that is what makes an agent created here able
150
- * to run without anybody opening a terminal (#200).
151
- *
152
- * ⚠️ Last on the page on purpose. It is the section a reader needs on the day something is wrong,
153
- * not while they are describing what the agent should do.
154
- */
155
- function IdentitySection({ node, agent }: { node: Node; agent: AgentDefinitionHandle }) {
156
- const { data } = useIntelRouterContext();
157
- const i18n = useI18n();
158
- const queryClient = useQueryClient();
159
- const [confirming, setConfirming] = useState(false);
160
- const applicationId = agent.query.data?.applicationId ?? null;
161
-
162
- const rotate = useMutation({
163
- mutationFn: async () => await data.rotateAgentKey(node.id),
164
- onSuccess: () => {
165
- setConfirming(false);
166
- // The run that failed with `agent_key_missing` is the reason somebody pressed this, so the
167
- // page's own state is re-read rather than left showing the refusal that sent them here.
168
- void queryClient.invalidateQueries({ queryKey: ["agent-state", node.id] });
169
- },
170
- });
171
-
172
- return (
173
- <Section title={i18n.t("agent.identity")} hint={i18n.t("agent.identityHint")}>
174
- <div className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3">
175
- <KeyRound aria-hidden="true" className="size-4 shrink-0 text-primary" />
176
- <span className="min-w-0 flex-1">
177
- <span className="block text-sm font-medium">{i18n.t("agent.principal")}</span>
178
- <code className="mt-0.5 block truncate text-xs text-muted-foreground">
179
- {applicationId ?? i18n.t("agent.principalNone")}
180
- </code>
181
- </span>
182
- <button
183
- type="button"
184
- onClick={() => setConfirming(true)}
185
- disabled={applicationId === null || rotate.isPending}
186
- aria-busy={rotate.isPending}
187
- {...(applicationId === null ? { "aria-describedby": "agent-rotate-reason" } : {})}
188
- 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"
189
- >
190
- {i18n.t("agent.rotateKey")}
191
- </button>
192
- </div>
193
- {applicationId === null ? (
194
- <p id="agent-rotate-reason" className="mt-3 text-xs text-muted-foreground">
195
- {i18n.t("agent.principalNoneHint")}
196
- </p>
197
- ) : null}
198
- {rotate.isError ? (
199
- <p role="alert" className="mt-3 text-xs text-destructive">
200
- {i18n.t("agent.rotateKeyFailed")}
201
- </p>
202
- ) : null}
203
- {rotate.isSuccess ? (
204
- <p role="status" className="mt-3 text-xs text-muted-foreground">
205
- {i18n.t("agent.rotateKeyDone")}
206
- </p>
207
- ) : null}
208
- {confirming ? (
209
- <Modal title={i18n.t("agent.rotateKey")} close={() => setConfirming(false)}>
210
- {/* ⚠️ Confirmed rather than done on the first click. The previous key stops working the
211
- moment this runs, so a misclick takes a working agent down until the call finishes. */}
212
- <p className="text-sm text-muted-foreground">{i18n.t("agent.rotateKeyConfirm")}</p>
213
- <button
214
- type="button"
215
- onClick={() => rotate.mutate()}
216
- disabled={rotate.isPending}
217
- aria-busy={rotate.isPending}
218
- 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"
219
- >
220
- {i18n.t("agent.rotateKey")}
221
- </button>
222
- </Modal>
223
- ) : null}
224
- </Section>
225
- );
226
- }
227
-
228
- /**
229
- * How to reach this agent.
230
- *
231
- * ⚠️ The built-in chat is listed first and cannot be removed, because it is not a channel somebody
232
- * configured — it is the runtime itself (ADR-0005 §5). Listing it beside the others, with no
233
- * delete, is what says "built in, always there" without a sentence explaining it.
234
- */
235
- function ContactSection({ node }: { node: Node }) {
236
- const i18n = useI18n();
237
- const [copied, setCopied] = useState(false);
238
- // ⚠️ The runtime's own address, NOT intel's proxy. This one is dialled by a portal or another
239
- // agent with its own credential (ADR-0005 §7); intel's door is for this page's browser calls and
240
- // deliberately does not carry `/mcp` (#178).
241
- const mcpAddress = agentMcpAddress(node.id);
242
- // A relative path is what the page was built with when the runtime shares the origin, and a
243
- // relative MCP address is useless to the client that has to dial it. Resolving against the page
244
- // makes the copied value absolute wherever it came from.
245
- const absolute =
246
- typeof window === "undefined" ? mcpAddress : new URL(mcpAddress, window.location.origin).href;
247
-
248
- return (
249
- <Section title={i18n.t("agent.contact")} hint={i18n.t("agent.contactHint")}>
250
- <ul className="space-y-2">
251
- <li className="flex items-center gap-3 rounded-lg border bg-card px-4 py-3">
252
- <MessageSquare aria-hidden="true" className="size-4 shrink-0 text-primary" />
253
- <span className="min-w-0 flex-1">
254
- <span className="block text-sm font-medium">{i18n.t("agent.contactChat")}</span>
255
- <span className="block text-xs text-muted-foreground">
256
- {i18n.t("agent.contactChatBuiltIn")}
257
- </span>
258
- </span>
259
- </li>
260
- <li className="flex items-center gap-3 rounded-lg border bg-card px-4 py-3">
261
- <Wrench aria-hidden="true" className="size-4 shrink-0 text-primary" />
262
- <span className="min-w-0 flex-1">
263
- <span className="block text-sm font-medium">{i18n.t("agent.contactMcp")}</span>
264
- <code className="mt-0.5 block truncate text-xs text-muted-foreground">{absolute}</code>
265
- </span>
266
- <button
267
- type="button"
268
- onClick={() => {
269
- void navigator.clipboard.writeText(absolute).then(() => {
270
- setCopied(true);
271
- setTimeout(() => setCopied(false), 2_000);
272
- });
273
- }}
274
- 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"
275
- >
276
- {copied ? (
277
- <Check aria-hidden="true" className="size-3.5" />
278
- ) : (
279
- <Copy aria-hidden="true" className="size-3.5" />
280
- )}
281
- {i18n.t(copied ? "agent.copied" : "agent.copy")}
282
- </button>
283
- </li>
284
- </ul>
285
- {/* ⚠️ Disabled, with the reason behind a hover rather than on the page (#204). A mail contact
286
- is deployment configuration (`AGENT_MAILBOXES`) and deliberately not part of the definition
287
- — ADR-0005 §4 — so a working button here would write a field the runtime never reads. The
288
- span carries the tooltip because a disabled button swallows the pointer events the trigger
289
- listens for; the sr-only copy keeps the reason where `aria-describedby` points. */}
290
- <div className="mt-3">
291
- <TooltipProvider delayDuration={300}>
292
- <Tooltip>
293
- <TooltipTrigger asChild>
294
- <span className="inline-flex">
295
- <button
296
- type="button"
297
- disabled
298
- aria-describedby="agent-contact-add-reason"
299
- className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm disabled:opacity-50"
300
- >
301
- <Plus aria-hidden="true" className="size-4" />
302
- {i18n.t("agent.contactAdd")}
303
- </button>
304
- </span>
305
- </TooltipTrigger>
306
- <TooltipContent className="max-w-xs">{i18n.t("agent.contactAddReason")}</TooltipContent>
307
- </Tooltip>
308
- </TooltipProvider>
309
- <p id="agent-contact-add-reason" className="sr-only">
310
- {i18n.t("agent.contactAddReason")}
311
- </p>
312
- </div>
313
- </Section>
314
- );
315
- }
316
-
317
- // Everything any role accepts, which is what the picker may offer. Derived from the contract's one
318
- // table rather than written out again — a second list here would drift.
319
- const pickableKinds: NodeKind[] = [
320
- ...new Set(Object.values(AgentReferenceKinds).flat()),
321
- ] as NodeKind[];
322
-
323
- /**
324
- * The role a freshly picked entry starts as — the widest thing that kind can honestly be.
325
- *
326
- * A folder starts as search space, the way it always has; a document or a table starts as the
327
- * system message, which is the only role that reads a single node at all. `null` for a flow and for
328
- * a kind no role accepts, and the caller then adds nothing rather than inventing a role.
329
- */
330
- function defaultRoleFor(kind: PickerKind): AgentReferenceRole | null {
331
- if (kind === "flow") return null;
332
- return kind === "folder" ? "semantic-context" : (agentReferenceRolesFor(kind)[0] ?? null);
333
- }
334
-
335
- /**
336
- * What the agent reads, and in which capacity.
337
- *
338
- * ⚠️ The role is a select with a visible chevron, not a coloured word. #143 asks for that
339
- * explicitly, and the reason is that the three roles do very different things — a folder read as
340
- * the system message becomes instructions, one read as memory is written back to. Something that
341
- * changes what a document MEANS to an agent must look changeable.
342
- *
343
- * ⚠️ Since #255 a row can also be a single document or table, and the role select shows only the
344
- * roles that kind can carry. `memory` and `semantic-context` stay folders — the agent searches both
345
- * and writes into one of them — so a document simply never offers them.
346
- *
347
- * ⚠️ Memory is searched too, since #259, and the hint beside this section says so in words (#269).
348
- * The role name alone reads as a write-only notebook, and somebody who believes that files things
349
- * there that come back at them out of every later search.
350
- */
351
- function KnowledgeSection({
352
- definition,
353
- agent,
354
- }: {
355
- definition: AgentDefinition;
356
- agent: AgentDefinitionHandle;
357
- }) {
358
- const i18n = useI18n();
359
- const [adding, setAdding] = useState(false);
360
-
361
- function write(references: AgentReference[]) {
362
- agent.save.mutate({ ...asDraft(definition), references });
363
- }
364
-
365
- return (
366
- <Section title={i18n.t("agent.knows")} hint={i18n.t("agent.knowsHint")}>
367
- {/* An empty list shows nothing at all — the add button says what belongs here (#204). */}
368
- {definition.references.length === 0 ? null : (
369
- <ul className="space-y-2">
370
- {/* ⚠️ The position IS the identity here: `references` is an ordered array with no ids,
371
- the contract permits the same folder twice, and every edit below addresses a row by
372
- its index. A key built from the contents alone would collide on exactly the duplicate
373
- the array is allowed to hold. */}
374
- {definition.references.map((reference, index) => (
375
- <ReferenceRow
376
- // biome-ignore lint/suspicious/noArrayIndexKey: the position is the identity — see above
377
- key={`${reference.nodeId}:${index}`}
378
- reference={reference}
379
- setRole={(role) =>
380
- write(
381
- definition.references.map((current, position) =>
382
- position === index ? { ...current, role } : current,
383
- ),
384
- )
385
- }
386
- remove={() =>
387
- write(definition.references.filter((_, position) => position !== index))
388
- }
389
- />
390
- ))}
391
- </ul>
392
- )}
393
- <button
394
- type="button"
395
- onClick={() => setAdding(true)}
396
- 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"
397
- >
398
- <Plus aria-hidden="true" className="size-4" />
399
- {i18n.t("agent.addReference")}
400
- </button>
401
- {adding ? (
402
- <Modal title={i18n.t("agent.addReference")} close={() => setAdding(false)}>
403
- {/* ⚠️ The picker offers the UNION of what any role accepts, and the row's role select
404
- then offers only the roles that accept what was picked (#255). The rule is asked in
405
- this direction because the thing is chosen first: a role chosen up front would mean a
406
- second dialog before the picker, and a picker that let anything be chosen and refused
407
- afterwards is worse than one that never offered it. Either way no illegal pair is
408
- reachable — `agentReferenceKinds` is the one list both sides read. */}
409
- <EntryPicker
410
- kind={pickableKinds}
411
- value=""
412
- label={i18n.t("agent.pickEntry")}
413
- onSelect={(nodeId, entryKind) => {
414
- const role = defaultRoleFor(entryKind);
415
- if (role === null) return;
416
- write([...definition.references, { nodeId, role }]);
417
- setAdding(false);
418
- }}
419
- />
420
- </Modal>
421
- ) : null}
422
- </Section>
423
- );
424
- }
425
-
426
- // What each kind looks like at a glance. An `attachment` and an `agent` cannot be picked, but an
427
- // older definition may still name one, so every kind has a mark rather than a fallback that would
428
- // draw two different things the same way.
429
- const KindIcon: Record<NodeKind, typeof Folder> = {
430
- folder: Folder,
431
- document: FileText,
432
- table: Table2,
433
- attachment: Paperclip,
434
- agent: Bot,
435
- board: KanbanSquare,
436
- };
437
-
438
- /**
439
- * One thing the agent reads, where it sits, and what it counts as.
440
- *
441
- * ⚠️ Its own component so the select can be NAMED after the entry. Three rows carrying the same
442
- * "What this counts as" are three identical controls to anybody listening rather than looking — the
443
- * name is beside them on screen, and the screen is exactly what that reader does not have. It needs
444
- * the resolved title, which is why the lookup is a hook rather than a component here.
445
- *
446
- * ⚠️ The path is not decoration (#255). Two folders called `Notes` in different corners of the tree
447
- * were indistinguishable here, and this is the screen on which somebody decides what an agent may
448
- * read and write into.
449
- *
450
- * ⚠️ The kind is a symbol AND a word. The symbol is for the eye and the `sr-only` word is for
451
- * everyone else — an icon with no text is a row that says "Notes" three times to a screen reader.
452
- */
453
- function ReferenceRow({
454
- reference,
455
- setRole,
456
- remove,
457
- }: {
458
- reference: AgentReference;
459
- setRole(role: AgentReferenceRole): void;
460
- remove(): void;
461
- }) {
462
- const i18n = useI18n();
463
- const { title, known, kind, path } = useEntryTitle(reference.nodeId);
464
- const Icon = kind === null ? null : KindIcon[kind];
465
- // ⚠️ Only the roles this kind can carry, so an impossible pair is never offered — and the current
466
- // role is kept in the list whatever it is, or an older definition would open on an empty select
467
- // and the first touch of the control would silently change what the agent does.
468
- const roles = [
469
- ...new Set([
470
- ...(kind === null ? AgentReferenceRole.options : agentReferenceRolesFor(kind)),
471
- reference.role,
472
- ]),
473
- ];
474
-
475
- return (
476
- <li className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3">
477
- {Icon ? <Icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" /> : null}
478
- <span className={`min-w-0 flex-1${known ? "" : " text-muted-foreground"}`}>
479
- <span className="block truncate text-sm">
480
- {title}
481
- {kind === null ? null : <span className="sr-only"> — {i18n.t(`node.kind.${kind}`)}</span>}
482
- </span>
483
- {path.length > 0 ? (
484
- <span className="block truncate text-xs text-muted-foreground" title={path.join(" / ")}>
485
- {path.join(" / ")}
486
- </span>
487
- ) : null}
488
- </span>
489
- <Select
490
- value={reference.role}
491
- onValueChange={(role) => {
492
- const parsed = AgentReferenceRole.safeParse(role);
493
- if (parsed.success) setRole(parsed.data);
494
- }}
495
- >
496
- <SelectTrigger size="sm" aria-label={i18n.t("agent.roleFor", { title })}>
497
- <SelectValue />
498
- </SelectTrigger>
499
- <SelectContent>
500
- {roles.map((role) => (
501
- <SelectItem key={role} value={role}>
502
- {i18n.t(`agent.role.${role}`)}
503
- </SelectItem>
504
- ))}
505
- </SelectContent>
506
- </Select>
507
- <button
508
- type="button"
509
- onClick={remove}
510
- aria-label={i18n.t("agent.removeReferenceOf", { title })}
511
- className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
512
- >
513
- <Trash2 aria-hidden="true" className="size-4" />
514
- </button>
515
- </li>
516
- );
517
- }
518
-
519
- function EntryTitle({ entryId, flow = false }: { entryId: string; flow?: boolean }) {
520
- const { title, known } = useEntryTitle(entryId, flow);
521
- return (
522
- <span className={`min-w-0 flex-1 truncate text-sm${known ? "" : " text-muted-foreground"}`}>
523
- {title}
524
- </span>
525
- );
526
- }
527
-
528
- /**
529
- * What the agent may call: whole MCP servers, given from the signed-in person's own portal
530
- * connection (D30).
531
- *
532
- * ⚠️ What is saved is a SELECTION, never a catalog. The tools behind a server stay a live
533
- * `tools/list` made with the delegator's token at the moment the agent runs (ADR-0003), so a server
534
- * listed here is a name, not a promise — losing it in the portal takes it away from the agent with
535
- * nothing to edit.
536
- *
537
- * ⚠️ The notice is not decoration, and it stands whether or not anything is listed yet. A shared
538
- * agent runs on the DELEGATOR's connection whoever starts it, which is a real widening of who can
539
- * act as this person. It is no longer a paragraph under the list (#254) — it said the same thing on
540
- * every visit at the place where somebody was doing something else — but it is not gone either: it
541
- * is the second half of this section's own hint, so it is in the trigger's accessible name and a
542
- * keyboard reaches it, and it still stands in the picker where the delegation actually happens.
543
- */
544
- function ToolsSection({
545
- definition,
546
- agent,
547
- }: {
548
- definition: AgentDefinition;
549
- agent: AgentDefinitionHandle;
550
- }) {
551
- const { data } = useIntelRouterContext();
552
- const i18n = useI18n();
553
- const me = useSessionUser();
554
- const delegatedBy = definition.tools?.delegatedBy ?? null;
555
- const delegatorName = useUserName(delegatedBy);
556
- const [adding, setAdding] = useState(false);
557
- const servers = useQuery({ queryKey: ["tool-servers"], queryFn: () => data.listToolServers() });
558
- const selected = definition.tools?.servers ?? [];
559
-
560
- // ⚠️ Servers, and nothing about who delegates them. The screen has no `delegatedBy` to send and
561
- // no business inventing one — Intel writes it from the session, and `AgentDefinitionInput` has no
562
- // field it could travel in. This is also what lets an agent with `tools: null`, which is every
563
- // freshly created one, receive its first server at all.
564
- function write(next: string[]) {
565
- agent.save.mutate({ ...asDraft(definition), tools: { servers: next } });
566
- }
567
-
568
- const available = (servers.data?.items ?? []).filter(
569
- (server) => !selected.includes(server.handle),
570
- );
571
-
572
- // Two whole sentences joined, never one sentence built from two fragments: each stays its own
573
- // catalog entry and each translates on its own. What the join fixes is only their order.
574
- const notice = delegationNotice(i18n, {
575
- delegatedBy,
576
- viewerId: me?.id ?? null,
577
- delegatorName,
578
- });
579
- const hint = `${i18n.t("agent.toolsHint")} ${notice}`;
580
-
581
- return (
582
- <Section title={i18n.t("agent.tools")} hint={hint}>
583
- {selected.length === 0 ? null : (
584
- <ul className="space-y-2">
585
- {selected.map((handle) => {
586
- const known = servers.data?.items.find((server) => server.handle === handle);
587
- /**
588
- * ⚠️ Silence about the question is not a negative answer to it (#350). While the list is
589
- * loading — and after a failed read — `servers.data` is `undefined`, and `known` is
590
- * then falsy for exactly the same reason as "the portal answered and this handle was
591
- * not in it". Drawn as one, a row said `You no longer reach this server` for half a
592
- * second on every visit: a statement about a REVOKED permission, and the sentence that
593
- * makes somebody re-delegate, sign in to the portal again, or open a ticket.
594
- *
595
- * It is the same rule `adapters/tools` keeps for `portalConnected` and `reached` on the
596
- * server side, where `packages/api/CLAUDE.md` spells it out — a missing field is a
597
- * reason to say less, never to refuse more. This is the one place that said it the
598
- * other way round.
599
- */
600
- const answered = servers.isSuccess;
601
- return (
602
- <li
603
- key={handle}
604
- className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3"
605
- >
606
- <Wrench aria-hidden="true" className="size-4 shrink-0 text-primary" />
607
- <span
608
- className={`min-w-0 flex-1 truncate text-sm${known ? "" : " text-muted-foreground"}`}
609
- >
610
- {known?.name ?? handle}
611
- </span>
612
- {/* A server the signed-in person no longer reaches still stands in the definition,
613
- and saying so beats drawing it as if it worked. The agent gets nothing from it
614
- either — the catalog is cut against what the delegator reaches. But only once
615
- the portal has actually answered: until then the row carries the handle and
616
- nothing else, which is everything that is known about it. */}
617
- <span className="text-xs text-muted-foreground">
618
- {known
619
- ? i18n.t("tools.toolCount", { count: String(known.toolCount) })
620
- : answered
621
- ? i18n.t("agent.toolServerUnavailable")
622
- : null}
623
- </span>
624
- <button
625
- type="button"
626
- onClick={() => write(selected.filter((current) => current !== handle))}
627
- aria-label={i18n.t("agent.removeToolServerOf", { name: known?.name ?? handle })}
628
- className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
629
- >
630
- <Trash2 aria-hidden="true" className="size-4" />
631
- </button>
632
- </li>
633
- );
634
- })}
635
- </ul>
636
- )}
637
- <button
638
- type="button"
639
- onClick={() => setAdding(true)}
640
- 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"
641
- >
642
- <Plus aria-hidden="true" className="size-4" />
643
- {i18n.t("agent.addTool")}
644
- </button>
645
- {adding ? (
646
- <Modal title={i18n.t("agent.addTool")} close={() => setAdding(false)}>
647
- {servers.isPending ? (
648
- <p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
649
- ) : servers.isError ? (
650
- <p role="alert" className="text-sm text-destructive">
651
- {i18n.t("tools.unreachableHelp")}
652
- </p>
653
- ) : servers.data?.portalConnected === false ? (
654
- <p className="text-sm text-muted-foreground">{i18n.t("agent.toolsNotConnected")}</p>
655
- ) : available.length === 0 ? (
656
- <p className="text-sm text-muted-foreground">{i18n.t("agent.toolsNoneLeft")}</p>
657
- ) : (
658
- <ul className="space-y-2">
659
- {available.map((server) => (
660
- <li key={server.handle}>
661
- <button
662
- type="button"
663
- onClick={() => {
664
- write([...selected, server.handle]);
665
- setAdding(false);
666
- }}
667
- 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"
668
- >
669
- <Wrench aria-hidden="true" className="size-4 shrink-0 text-primary" />
670
- <span className="min-w-0 flex-1 truncate text-sm">{server.name}</span>
671
- <span className="text-xs text-muted-foreground">
672
- {i18n.t("tools.toolCount", { count: String(server.toolCount) })}
673
- </span>
674
- </button>
675
- </li>
676
- ))}
677
- </ul>
678
- )}
679
- {/* ⚠️ Here it stays a visible paragraph, unlike the section's own (#254). This is the
680
- moment the delegation is actually made, and a consequence that is only reachable by
681
- hovering an icon somewhere behind the dialog is a consequence nobody reads. */}
682
- <p className="mt-4 text-xs text-muted-foreground">{notice}</p>
683
- </Modal>
684
- ) : null}
685
- </Section>
686
- );
687
- }
688
-
689
- /**
690
- * When the agent acts without being asked.
691
- *
692
- * ⚠️ The target is a flow OR a document and nothing else — the contract says so, and the picker is
693
- * told to offer both rather than being given a free text field. A schedule aimed at something that
694
- * cannot be run is a run that fails at 3 a.m. with nobody watching.
695
- */
696
- function ScheduleSection({
697
- definition,
698
- agent,
699
- }: {
700
- definition: AgentDefinition;
701
- agent: AgentDefinitionHandle;
702
- }) {
703
- const i18n = useI18n();
704
- const [adding, setAdding] = useState(false);
705
-
706
- function write(schedules: AgentSchedule[]) {
707
- agent.save.mutate({ ...asDraft(definition), schedules });
708
- }
709
-
710
- return (
711
- <Section title={i18n.t("agent.schedule")} hint={i18n.t("agent.scheduleHint")}>
712
- {definition.schedules.length === 0 ? null : (
713
- <ul className="space-y-2">
714
- {definition.schedules.map((schedule, index) => (
715
- <li
716
- // biome-ignore lint/suspicious/noArrayIndexKey: same as the references above — an ordered array with no ids, edited by position
717
- key={`${schedule.cron}@${schedule.timezone}:${schedule.target.id}:${index}`}
718
- className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3"
719
- >
720
- <code className="rounded bg-muted px-2 py-1 text-xs">{schedule.cron}</code>
721
- {/* The zone belongs beside the expression: "0 8 * * *" alone does not say eight
722
- o'clock WHERE, and that is the whole difference this ticket is about. */}
723
- <span className="text-xs text-muted-foreground">{schedule.timezone}</span>
724
- <EntryTitle entryId={schedule.target.id} flow={schedule.target.kind === "flow"} />
725
- <span className="text-xs text-muted-foreground">
726
- {i18n.t(`node.kind.${schedule.target.kind}`)}
727
- </span>
728
- <button
729
- type="button"
730
- onClick={() =>
731
- write(definition.schedules.filter((_, position) => position !== index))
732
- }
733
- aria-label={i18n.t("agent.removeSchedule")}
734
- className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
735
- >
736
- <Trash2 aria-hidden="true" className="size-4" />
737
- </button>
738
- </li>
739
- ))}
740
- </ul>
741
- )}
742
- <button
743
- type="button"
744
- onClick={() => setAdding(true)}
745
- 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"
746
- >
747
- <Plus aria-hidden="true" className="size-4" />
748
- {i18n.t("agent.addSchedule")}
749
- </button>
750
- {adding ? (
751
- <ScheduleDialog
752
- close={() => setAdding(false)}
753
- add={(schedule) => {
754
- write([...definition.schedules, schedule]);
755
- setAdding(false);
756
- }}
757
- />
758
- ) : null}
759
- </Section>
760
- );
761
- }
762
-
763
- function ScheduleDialog({ close, add }: { close(): void; add(schedule: AgentSchedule): void }) {
764
- const i18n = useI18n();
765
- const [cron, setCron] = useState("0 8 * * *");
766
- // ⚠️ The reader's preference is the SUGGESTION, and only at creation time (#228). What is stored
767
- // is this value, so the schedule keeps meaning the same hour after its author travels — and it
768
- // means the same hour to everybody else reading the agent.
769
- const preferred = useTimezone();
770
- const [timezone, setTimezone] = useState(preferred);
771
- const [target, setTarget] = useState<{ id: string; kind: "document" | "flow" } | null>(null);
772
-
773
- return (
774
- <Modal title={i18n.t("agent.addSchedule")} close={close}>
775
- <form
776
- className="space-y-4"
777
- onSubmit={(event) => {
778
- event.preventDefault();
779
- if (target && cron.trim()) add({ cron: cron.trim(), timezone, target });
780
- }}
781
- >
782
- <label className="block text-sm font-medium">
783
- {i18n.t("agent.cron")}
784
- <input
785
- required
786
- value={cron}
787
- onChange={(event) => setCron(event.target.value)}
788
- aria-describedby="agent-cron-hint"
789
- className="mt-2 w-full rounded-md border bg-background px-3 py-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
790
- />
791
- <span
792
- id="agent-cron-hint"
793
- className="mt-1 block text-xs font-normal text-muted-foreground"
794
- >
795
- {i18n.t("agent.cronHint")}
796
- </span>
797
- </label>
798
- {/* ⚠️ The hint is behind the ⓘ (#249) and ALSO `sr-only` below. The two are not a
799
- duplicate: the tooltip's content is in the accessibility tree only while it is open, so
800
- the field would lose the description `aria-describedby` promises the moment the sentence
801
- became a hover. Route (1) of the ticket, and the cheaper of the two — nothing has to be
802
- argued about a description that is simply still there. */}
803
- <div className="flex items-center gap-1.5">
804
- <label className="block text-sm font-medium" htmlFor="agent-schedule-timezone">
805
- {i18n.t("agent.scheduleTimezone")}
806
- </label>
807
- <SectionHint hint={i18n.t("agent.scheduleTimezoneHint")} />
808
- </div>
809
- <TimezoneCombobox
810
- id="agent-schedule-timezone"
811
- value={timezone}
812
- onChange={setTimezone}
813
- describedBy="agent-timezone-hint"
814
- />
815
- <span id="agent-timezone-hint" className="sr-only">
816
- {i18n.t("agent.scheduleTimezoneHint")}
817
- </span>
818
- <EntryPicker
819
- kind="document"
820
- flows
821
- value={target?.id ?? ""}
822
- label={i18n.t("agent.scheduleTarget")}
823
- onSelect={(entryId, entryKind: PickerKind) => {
824
- if (entryKind !== "document" && entryKind !== "flow") return;
825
- setTarget({ id: entryId, kind: entryKind });
826
- }}
827
- />
828
- <button
829
- type="submit"
830
- disabled={!target || cron.trim().length === 0}
831
- 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"
832
- >
833
- {i18n.t("common.create")}
834
- </button>
835
- </form>
836
- </Modal>
837
- );
838
- }
839
-
840
- /**
841
- * Which model does the thinking, what it costs per million tokens — and what this agent has
842
- * actually cost with it.
843
- *
844
- * ⚠️ Changing it writes a new definition version like every other edit here; the runtime picks the
845
- * provider up on its next read (its definition cache is 60 seconds).
846
- *
847
- * ⚠️ The figures come from intel's catalog since #257 and no longer from a table in this bundle.
848
- * The table was a copy that aged silently, and the reason it stayed one — "a token does not belong
849
- * in a SPA" — was correct and is exactly why the fetch moved to intel rather than into this
850
- * component.
851
- *
852
- * ⚠️ The two numbers beside each other are a price and a bill, and they must not read as one thing.
853
- * A price per million tokens answers "what does this model cost"; the thirty-day figure answers
854
- * "what has this agent cost", it is money already spent on runs that used whatever model was chosen
855
- * AT THE TIME, and the sentence under it says so. Without that, switching the model would make the
856
- * old figure read as a forecast for the new one.
857
- */
858
- function ModelSection({
859
- definition,
860
- agent,
861
- agentId,
862
- }: {
863
- definition: AgentDefinition;
864
- agent: AgentDefinitionHandle;
865
- agentId: string;
866
- }) {
867
- const i18n = useI18n();
868
- const catalog = useModelCatalog();
869
- const costs = useAgentCosts(agentId);
870
- const models = selectableModels(definition.model);
871
- const current = modelFacts(definition.model, catalog.data?.entries);
872
- const spent = costWindow(costs.data, 30);
873
-
874
- return (
875
- <Section title={i18n.t("agent.model")} hint={i18n.t("agent.modelHint")}>
876
- <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
877
- <Select
878
- value={modelKey(definition.model)}
879
- onValueChange={(key) => {
880
- const model = parseModelKey(key);
881
- if (model) agent.save.mutate({ ...asDraft(definition), model });
882
- }}
883
- >
884
- <SelectTrigger aria-label={i18n.t("agent.model")} className="w-full max-w-md sm:w-72">
885
- {/* ⚠️ Own children rather than the default mirror of the chosen item: the item carries
886
- the figures too, and mirrored into the trigger they would stand twice in one row. */}
887
- <SelectValue>
888
- <ModelName facts={current} />
889
- </SelectValue>
890
- </SelectTrigger>
891
- <SelectContent>
892
- {models.map((model) => {
893
- const facts = modelFacts(model, catalog.data?.entries);
894
- return (
895
- <SelectItem key={modelKey(model)} value={modelKey(model)}>
896
- {/* ⚠️ The space is not formatting. The option's accessible name is its text run
897
- together, so without it a screen reader reads "…70B Fast24k context". The eye
898
- never notices, because the row is a flex box with a gap. */}
899
- <ModelName facts={facts} /> <ModelFigures facts={facts} />
900
- </SelectItem>
901
- );
902
- })}
903
- </SelectContent>
904
- </Select>
905
- <ModelFigures facts={current} />
906
- </div>
907
- {/* ⚠️ Said once, under the row, and only when something on screen actually came off the
908
- table. A silent fallback to typed-out prices is the state this feature replaced. */}
909
- {current.stale ? (
910
- <p className="mt-2 text-xs text-muted-foreground">{i18n.t("agent.modelFiguresStale")}</p>
911
- ) : null}
912
- <AgentSpend
913
- spent={spent}
914
- status={costs.data?.status}
915
- partial={costs.data?.partial ?? false}
916
- />
917
- </Section>
918
- );
919
- }
920
-
921
- /**
922
- * What this agent has actually cost in the last thirty days (#257).
923
- *
924
- * ⚠️ Written in the past tense and labelled with the models that produced it, because it is the
925
- * only thing standing between "$0.23" and a reader taking it for what the model above will cost.
926
- * The models are named from the gateway's own log lines, so a period spent on a different model
927
- * says so by itself.
928
- *
929
- * ⚠️ Nothing is rendered where the figure is not known. The Runs tab is where the reason belongs —
930
- * it is the screen about runs — and repeating "no read token" beside a model select would be an
931
- * error message in the middle of an unrelated decision.
932
- *
933
- * ⚠️ `partial` is the one exception to that division, and it has to be repeated here. It does not
934
- * say the figure is missing — it says the figure shown IS NOT THE TOTAL, because the gateway read
935
- * stopped at its page limit. A lower bound printed as if it were a sum is the same lie as a zero
936
- * printed as if it were free, and this is where somebody reads it while deciding what to spend.
937
- * An agent on a frequent schedule crosses that limit within thirty days as a matter of course; the
938
- * seven-day figure practically never does, which makes the case rarer and therefore easier to miss.
939
- */
940
- function AgentSpend({
941
- spent,
942
- status,
943
- partial,
944
- }: {
945
- spent: AgentCostWindow | null;
946
- status: AgentCosts["status"] | undefined;
947
- partial: boolean;
948
- }) {
949
- const i18n = useI18n();
950
- if (status !== "read" || !spent) return null;
951
-
952
- return (
953
- <p className="mt-3 text-sm">
954
- <span className="font-medium">
955
- {i18n.t("agent.costsSpent", {
956
- amount: formatCost(spent.cost, i18n.locale),
957
- days: String(spent.days),
958
- })}
959
- </span>
960
- {spent.models.length > 0 ? (
961
- <span className="text-muted-foreground">
962
- {" "}
963
- {i18n.t("agent.costsSpentWith", { models: spent.models.join(", ") })}
964
- </span>
965
- ) : null}
966
- <span className="mt-0.5 block text-xs text-muted-foreground">
967
- {i18n.t("agent.costsSpentPast")}
968
- </span>
969
- {/* The Runs tab's own wording, deliberately the same key: two different sentences for one
970
- fact would leave a reader wondering whether they are the same fact. */}
971
- {partial ? (
972
- <span className="mt-0.5 block text-xs text-muted-foreground">
973
- {i18n.t("agent.costPartial")}
974
- </span>
975
- ) : null}
976
- </p>
977
- );
978
- }
979
-
980
- // Brand first, then the model's own name — "Claude Sonnet 5", not "claude-sonnet-5 · anthropic". The
981
- // provider stays in the definition; it names who serves the model, which is not what anybody reads a
982
- // list of models for.
983
- function ModelName({ facts }: { facts: ModelFacts }) {
984
- return (
985
- <span className="truncate">
986
- <span className="font-medium">{facts.brand}</span> {facts.name}
987
- </span>
988
- );
989
- }
990
-
991
- // The two numbers a model is actually chosen on. Absent for a model this installation has no figures
992
- // for — the row then simply ends, because a made-up zero would be a false statement about money.
993
- function ModelFigures({ facts }: { facts: ModelFacts }) {
994
- const i18n = useI18n();
995
- if (facts.contextTokens === null || facts.price === null) return null;
996
-
997
- return (
998
- <span className="text-xs whitespace-nowrap text-muted-foreground">
999
- {i18n.t("agent.modelFigures", {
1000
- context: formatContextTokens(facts.contextTokens),
1001
- input: formatPricePerMillion(facts.price.inputPerMillion, i18n.locale),
1002
- output: formatPricePerMillion(facts.price.outputPerMillion, i18n.locale),
1003
- })}
1004
- </span>
1005
- );
1006
- }