@anchrd/intel-ui 0.14.0 → 0.16.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.
@@ -1,6 +1,6 @@
1
- import { AgentModel } from "@anchrd/intel-contract";
1
+ import { AgentModel, type ModelCatalogEntry } from "@anchrd/intel-contract";
2
2
  import { z } from "zod";
3
- import type { ModelFacts, ModelPrice } from "./agent-models.types.ts";
3
+ import type { ModelFacts } from "./agent-models.types.ts";
4
4
 
5
5
  /**
6
6
  * Which models this installation offers.
@@ -16,65 +16,6 @@ import type { ModelFacts, ModelPrice } from "./agent-models.types.ts";
16
16
  */
17
17
  const ConfiguredModels = z.array(AgentModel);
18
18
 
19
- /**
20
- * Context window and price per model, collected 2026-08-06 from
21
- * `GET /accounts/{id}/ai/models/search` (Workers AI, every entry carrying `function_calling`) and
22
- * from Anthropic's model list. Both figures are shown beside the select, so this table is the one
23
- * place to correct when they move.
24
- *
25
- * ⚠️ It is a copy and it ages silently. Cloudflare changes context windows and prices without
26
- * notice, and a wrong price in the interface is worse than none — re-read the endpoint above rather
27
- * than editing a number from memory. Asking live from the browser is not the way out: the account
28
- * token does not belong in a SPA.
29
- *
30
- * ⚠️ Workers AI models that do not carry `function_calling` are deliberately absent. An agent is a
31
- * tool loop; a model that cannot call a tool cannot run one, and offering it would only produce a
32
- * broken agent for whoever picked it.
33
- */
34
- const catalog: Record<string, { name: string; contextTokens: number; price: ModelPrice }> = {
35
- "anthropic:claude-opus-5": price("Opus 5", 1_000_000, 5, 25),
36
- "anthropic:claude-sonnet-5": price("Sonnet 5", 1_000_000, 3, 15),
37
- "anthropic:claude-sonnet-4": price("Sonnet 4", 200_000, 3, 15),
38
- "anthropic:claude-haiku-4-5": price("Haiku 4.5", 200_000, 1, 5),
39
- "workers-ai:@cf/openai/gpt-oss-120b": price("GPT-OSS 120B", 128_000, 0.35, 0.75),
40
- "workers-ai:@cf/openai/gpt-oss-20b": price("GPT-OSS 20B", 128_000, 0.2, 0.3),
41
- "workers-ai:@cf/moonshotai/kimi-k2.6": price("Kimi K2.6", 262_144, 0.95, 4),
42
- "workers-ai:@cf/moonshotai/kimi-k2.7-code": price("Kimi K2.7 Code", 262_144, 0.95, 4),
43
- "workers-ai:@cf/zai-org/glm-5.2": price("GLM 5.2", 262_144, 1.4, 4.4),
44
- "workers-ai:@cf/zai-org/glm-4.7-flash": price("GLM 4.7 Flash", 131_072, 0.0605, 0.4),
45
- "workers-ai:@cf/google/gemma-4-26b-a4b-it": price("Gemma 4 26B", 256_000, 0.1, 0.3),
46
- "workers-ai:@cf/nvidia/nemotron-3-120b-a12b": price("Nemotron 3 120B", 256_000, 0.5, 1.5),
47
- "workers-ai:@cf/meta/llama-4-scout-17b-16e-instruct": price("Llama 4 Scout", 131_000, 0.27, 0.85),
48
- "workers-ai:@cf/meta/llama-3.3-70b-instruct-fp8-fast": price(
49
- "Llama 3.3 70B Fast",
50
- 24_000,
51
- 0.293,
52
- 2.253,
53
- ),
54
- "workers-ai:@cf/mistralai/mistral-small-3.1-24b-instruct": price(
55
- "Small 3.1 24B",
56
- 128_000,
57
- 0.351,
58
- 0.555,
59
- ),
60
- "workers-ai:@cf/ibm-granite/granite-4.0-h-micro": price(
61
- "Granite 4.0 Micro",
62
- 131_000,
63
- 0.017,
64
- 0.112,
65
- ),
66
- "workers-ai:@cf/qwen/qwen3-30b-a3b-fp8": price("Qwen3 30B", 32_768, 0.0509, 0.335),
67
- };
68
-
69
- function price(
70
- name: string,
71
- contextTokens: number,
72
- inputPerMillion: number,
73
- outputPerMillion: number,
74
- ): { name: string; contextTokens: number; price: ModelPrice } {
75
- return { name, contextTokens, price: { inputPerMillion, outputPerMillion } };
76
- }
77
-
78
19
  /**
79
20
  * ⚠️ The brand comes from the model id, never from `provider`. `provider` says who serves the model,
80
21
  * so every Workers AI entry would read "Cloudflare" — thirteen identical labels in a list whose whole
@@ -131,20 +72,43 @@ export function parseModelKey(key: string): AgentModel | null {
131
72
  return parsed.success ? parsed.data : null;
132
73
  }
133
74
 
134
- // How a model is written for a person: brand first, then the model's own name, then the two numbers
135
- // somebody actually chooses on. A model outside the catalog keeps its own id as the name and reports
136
- // no figures at all.
137
- export function modelFacts(model: AgentModel): ModelFacts {
138
- const known = catalog[modelKey(model)];
75
+ /**
76
+ * How a model is written for a person: brand first, then the model's own name, then the two numbers
77
+ * somebody actually chooses on.
78
+ *
79
+ * ⚠️ The figures come IN, from intel's catalog (#257). There is no table in this package any more,
80
+ * and putting one back would restore exactly what the removed one was accused of: it was a copy, it
81
+ * aged silently, and a wrong price in the interface is worse than none. What this file still owns is
82
+ * how a model is written down — the brand, the fallback name, the two formats — none of which is a
83
+ * fact about money.
84
+ *
85
+ * ⚠️ A missing catalog and an unknown model produce the SAME answer: a name and no figures. The
86
+ * select therefore renders while the catalog is still on its way, and it renders at all when the
87
+ * catalog could not be fetched — an agent whose model cannot be chosen is an agent nobody can repair
88
+ * from this screen.
89
+ */
90
+ export function modelFacts(model: AgentModel, catalog?: ModelCatalogEntry[]): ModelFacts {
91
+ const known = catalog?.find(
92
+ (entry) => entry.provider === model.provider && entry.model === model.model,
93
+ );
139
94
  if (known) {
140
95
  return {
141
96
  brand: brandOf(model),
142
97
  name: known.name,
143
98
  contextTokens: known.contextTokens,
144
99
  price: known.price,
100
+ // Nothing is stale when there is nothing to be stale: a model the catalog carries no price
101
+ // for shows no price, and no warning about a price it never showed.
102
+ stale: known.source === "builtin" && known.price !== null,
145
103
  };
146
104
  }
147
- return { brand: brandOf(model), name: unknownName(model), contextTokens: null, price: null };
105
+ return {
106
+ brand: brandOf(model),
107
+ name: unknownName(model),
108
+ contextTokens: null,
109
+ price: null,
110
+ stale: false,
111
+ };
148
112
  }
149
113
 
150
114
  function brandOf(model: AgentModel): string {
@@ -1,3 +1,5 @@
1
+ import type { ModelPrice } from "@anchrd/intel-contract";
2
+
1
3
  /** What a reader needs in order to pick a model: whose it is, what it is called, and what it costs. */
2
4
  export interface ModelFacts {
3
5
  brand: string;
@@ -9,10 +11,14 @@ export interface ModelFacts {
9
11
  */
10
12
  contextTokens: number | null;
11
13
  price: ModelPrice | null;
14
+ /**
15
+ * Whether the two figures came off a written-out table rather than from Cloudflare (#257).
16
+ *
17
+ * ⚠️ It exists so the screen can SAY so. A silent fallback onto typed-out prices is the state
18
+ * this feature replaced, with more code — and a wrong price in the interface is worse than none.
19
+ * `false` for a model with no figures at all: there is nothing there to be stale.
20
+ */
21
+ stale: boolean;
12
22
  }
13
23
 
14
- /** Always US dollars per one million tokens — never per 1k, never a single blended number. */
15
- export interface ModelPrice {
16
- inputPerMillion: number;
17
- outputPerMillion: number;
18
- }
24
+ export type { ModelPrice };
@@ -1,14 +1,34 @@
1
1
  import {
2
+ type AgentCosts,
3
+ type AgentCostWindow,
2
4
  type AgentDefinition,
3
5
  type AgentReference,
6
+ AgentReferenceKinds,
4
7
  AgentReferenceRole,
5
8
  type AgentSchedule,
9
+ agentReferenceRolesFor,
6
10
  type Node,
11
+ type NodeKind,
7
12
  } from "@anchrd/intel-contract";
8
13
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
9
- import { Check, Copy, Info, KeyRound, MessageSquare, Plus, Trash2, Wrench } from "lucide-react";
14
+ import {
15
+ Bot,
16
+ Check,
17
+ Copy,
18
+ FileText,
19
+ Folder,
20
+ KeyRound,
21
+ MessageSquare,
22
+ Paperclip,
23
+ Plus,
24
+ Table2,
25
+ Trash2,
26
+ Wrench,
27
+ } from "lucide-react";
10
28
  import { useState } from "react";
29
+ import { costWindow, formatCost, useAgentCosts } from "@/agent/agent-costs/agent-costs.ts";
11
30
  import { type AgentDefinitionHandle, asDraft } from "@/agent/agent-definition/agent-definition.ts";
31
+ import { delegationNotice } from "@/agent/agent-delegation-notice/agent-delegation-notice.ts";
12
32
  import { useEntryTitle } from "@/agent/agent-entry-title/agent-entry-title.ts";
13
33
  import {
14
34
  formatContextTokens,
@@ -29,11 +49,14 @@ import {
29
49
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
30
50
  import { agentMcpAddress } from "@/data/agent-runtime/agent-runtime.ts";
31
51
  import { EntryPicker, type PickerKind } from "@/entry-picker/entry-picker.tsx";
52
+ import { useModelCatalog } from "@/hooks/use-model-catalog.ts";
32
53
  import { useI18n } from "@/i18n/i18n-context.tsx";
33
54
  import { Modal } from "@/modal/modal.tsx";
34
55
  import { useIntelRouterContext } from "@/router/router-context.ts";
56
+ import { SectionHint } from "@/section-hint/section-hint.tsx";
35
57
  import { TimezoneCombobox } from "@/timezone/timezone-combobox/timezone-combobox.tsx";
36
58
  import { useTimezone } from "@/timezone/timezone-context.tsx";
59
+ import { useSessionUser, useUserName } from "@/user-name/user-name.ts";
37
60
 
38
61
  // No dividers between sections — the space is the separation (#204). The explanation sits behind
39
62
  // the icon rather than under the title, so the page shows what the agent IS and keeps the prose
@@ -58,26 +81,6 @@ function Section({
58
81
  );
59
82
  }
60
83
 
61
- // ⚠️ The hint is the trigger's accessible name, not only the tooltip's content: Radix describes the
62
- // trigger by the content only while it is open, and a listener who tabs past a nameless icon button
63
- // would never learn there is anything behind it.
64
- function SectionHint({ hint }: { hint: string }) {
65
- return (
66
- <TooltipProvider delayDuration={300}>
67
- <Tooltip>
68
- <TooltipTrigger
69
- type="button"
70
- aria-label={hint}
71
- className="rounded-full text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
72
- >
73
- <Info aria-hidden="true" className="size-3.5" />
74
- </TooltipTrigger>
75
- <TooltipContent className="max-w-xs">{hint}</TooltipContent>
76
- </Tooltip>
77
- </TooltipProvider>
78
- );
79
- }
80
-
81
84
  /**
82
85
  * Everything an agent is, on one page: how to reach it, what it reads, what it may call, when it
83
86
  * acts on its own, which model does the thinking, and who it is in Gate.
@@ -131,7 +134,7 @@ export function AgentProfile({ node, agent }: { node: Node; agent: AgentDefiniti
131
134
  <KnowledgeSection definition={definition} agent={agent} />
132
135
  <ToolsSection definition={definition} agent={agent} />
133
136
  <ScheduleSection definition={definition} agent={agent} />
134
- <ModelSection definition={definition} agent={agent} />
137
+ <ModelSection definition={definition} agent={agent} agentId={node.id} />
135
138
  <IdentitySection node={node} agent={agent} />
136
139
  </div>
137
140
  );
@@ -310,6 +313,24 @@ function ContactSection({ node }: { node: Node }) {
310
313
  );
311
314
  }
312
315
 
316
+ // Everything any role accepts, which is what the picker may offer. Derived from the contract's one
317
+ // table rather than written out again — a second list here would drift.
318
+ const pickableKinds: NodeKind[] = [
319
+ ...new Set(Object.values(AgentReferenceKinds).flat()),
320
+ ] as NodeKind[];
321
+
322
+ /**
323
+ * The role a freshly picked entry starts as — the widest thing that kind can honestly be.
324
+ *
325
+ * A folder starts as search space, the way it always has; a document or a table starts as the
326
+ * system message, which is the only role that reads a single node at all. `null` for a flow and for
327
+ * a kind no role accepts, and the caller then adds nothing rather than inventing a role.
328
+ */
329
+ function defaultRoleFor(kind: PickerKind): AgentReferenceRole | null {
330
+ if (kind === "flow") return null;
331
+ return kind === "folder" ? "semantic-context" : (agentReferenceRolesFor(kind)[0] ?? null);
332
+ }
333
+
313
334
  /**
314
335
  * What the agent reads, and in which capacity.
315
336
  *
@@ -317,6 +338,14 @@ function ContactSection({ node }: { node: Node }) {
317
338
  * explicitly, and the reason is that the three roles do very different things — a folder read as
318
339
  * the system message becomes instructions, one read as memory is written back to. Something that
319
340
  * changes what a document MEANS to an agent must look changeable.
341
+ *
342
+ * ⚠️ Since #255 a row can also be a single document or table, and the role select shows only the
343
+ * roles that kind can carry. `memory` and `semantic-context` stay folders — the agent searches both
344
+ * and writes into one of them — so a document simply never offers them.
345
+ *
346
+ * ⚠️ Memory is searched too, since #259, and the hint beside this section says so in words (#269).
347
+ * The role name alone reads as a write-only notebook, and somebody who believes that files things
348
+ * there that come back at them out of every later search.
320
349
  */
321
350
  function KnowledgeSection({
322
351
  definition,
@@ -370,12 +399,20 @@ function KnowledgeSection({
370
399
  </button>
371
400
  {adding ? (
372
401
  <Modal title={i18n.t("agent.addReference")} close={() => setAdding(false)}>
402
+ {/* ⚠️ The picker offers the UNION of what any role accepts, and the row's role select
403
+ then offers only the roles that accept what was picked (#255). The rule is asked in
404
+ this direction because the thing is chosen first: a role chosen up front would mean a
405
+ second dialog before the picker, and a picker that let anything be chosen and refused
406
+ afterwards is worse than one that never offered it. Either way no illegal pair is
407
+ reachable — `agentReferenceKinds` is the one list both sides read. */}
373
408
  <EntryPicker
374
- kind="folder"
409
+ kind={pickableKinds}
375
410
  value=""
376
- label={i18n.t("agent.pickFolder")}
377
- onSelect={(nodeId) => {
378
- write([...definition.references, { nodeId, role: "semantic-context" }]);
411
+ label={i18n.t("agent.pickEntry")}
412
+ onSelect={(nodeId, entryKind) => {
413
+ const role = defaultRoleFor(entryKind);
414
+ if (role === null) return;
415
+ write([...definition.references, { nodeId, role }]);
379
416
  setAdding(false);
380
417
  }}
381
418
  />
@@ -385,13 +422,31 @@ function KnowledgeSection({
385
422
  );
386
423
  }
387
424
 
425
+ // What each kind looks like at a glance. An `attachment` and an `agent` cannot be picked, but an
426
+ // older definition may still name one, so every kind has a mark rather than a fallback that would
427
+ // draw two different things the same way.
428
+ const KindIcon: Record<NodeKind, typeof Folder> = {
429
+ folder: Folder,
430
+ document: FileText,
431
+ table: Table2,
432
+ attachment: Paperclip,
433
+ agent: Bot,
434
+ };
435
+
388
436
  /**
389
- * One folder the agent reads, and what it counts as.
437
+ * One thing the agent reads, where it sits, and what it counts as.
390
438
  *
391
- * ⚠️ Its own component so the select can be NAMED after the folder. Three rows carrying the same
392
- * "What this folder counts as" are three identical controls to anybody listening rather than
393
- * looking — the name is beside them on screen, and the screen is exactly what that reader does not
394
- * have. It needs the resolved title, which is why the lookup is a hook rather than a component here.
439
+ * ⚠️ Its own component so the select can be NAMED after the entry. Three rows carrying the same
440
+ * "What this counts as" are three identical controls to anybody listening rather than looking — the
441
+ * name is beside them on screen, and the screen is exactly what that reader does not have. It needs
442
+ * the resolved title, which is why the lookup is a hook rather than a component here.
443
+ *
444
+ * ⚠️ The path is not decoration (#255). Two folders called `Notes` in different corners of the tree
445
+ * were indistinguishable here, and this is the screen on which somebody decides what an agent may
446
+ * read and write into.
447
+ *
448
+ * ⚠️ The kind is a symbol AND a word. The symbol is for the eye and the `sr-only` word is for
449
+ * everyone else — an icon with no text is a row that says "Notes" three times to a screen reader.
395
450
  */
396
451
  function ReferenceRow({
397
452
  reference,
@@ -403,11 +458,31 @@ function ReferenceRow({
403
458
  remove(): void;
404
459
  }) {
405
460
  const i18n = useI18n();
406
- const { title, known } = useEntryTitle(reference.nodeId);
461
+ const { title, known, kind, path } = useEntryTitle(reference.nodeId);
462
+ const Icon = kind === null ? null : KindIcon[kind];
463
+ // ⚠️ Only the roles this kind can carry, so an impossible pair is never offered — and the current
464
+ // role is kept in the list whatever it is, or an older definition would open on an empty select
465
+ // and the first touch of the control would silently change what the agent does.
466
+ const roles = [
467
+ ...new Set([
468
+ ...(kind === null ? AgentReferenceRole.options : agentReferenceRolesFor(kind)),
469
+ reference.role,
470
+ ]),
471
+ ];
472
+
407
473
  return (
408
474
  <li className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3">
409
- <span className={`min-w-0 flex-1 truncate text-sm${known ? "" : " text-muted-foreground"}`}>
410
- {title}
475
+ {Icon ? <Icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" /> : null}
476
+ <span className={`min-w-0 flex-1${known ? "" : " text-muted-foreground"}`}>
477
+ <span className="block truncate text-sm">
478
+ {title}
479
+ {kind === null ? null : <span className="sr-only"> — {i18n.t(`node.kind.${kind}`)}</span>}
480
+ </span>
481
+ {path.length > 0 ? (
482
+ <span className="block truncate text-xs text-muted-foreground" title={path.join(" / ")}>
483
+ {path.join(" / ")}
484
+ </span>
485
+ ) : null}
411
486
  </span>
412
487
  <Select
413
488
  value={reference.role}
@@ -420,7 +495,7 @@ function ReferenceRow({
420
495
  <SelectValue />
421
496
  </SelectTrigger>
422
497
  <SelectContent>
423
- {AgentReferenceRole.options.map((role) => (
498
+ {roles.map((role) => (
424
499
  <SelectItem key={role} value={role}>
425
500
  {i18n.t(`agent.role.${role}`)}
426
501
  </SelectItem>
@@ -459,9 +534,10 @@ function EntryTitle({ entryId, flow = false }: { entryId: string; flow?: boolean
459
534
  *
460
535
  * ⚠️ The notice is not decoration, and it stands whether or not anything is listed yet. A shared
461
536
  * agent runs on the DELEGATOR's connection whoever starts it, which is a real widening of who can
462
- * act as this person so it belongs where somebody reads it BEFORE clicking `+ Add tool`, not as a
463
- * footnote that appears once the first server is already given away. The empty list itself stays
464
- * wordless, the way the other sections' do (#204): the button says what belongs here.
537
+ * act as this person. It is no longer a paragraph under the list (#254) it said the same thing on
538
+ * every visit at the place where somebody was doing something else but it is not gone either: it
539
+ * is the second half of this section's own hint, so it is in the trigger's accessible name and a
540
+ * keyboard reaches it, and it still stands in the picker where the delegation actually happens.
465
541
  */
466
542
  function ToolsSection({
467
543
  definition,
@@ -472,6 +548,9 @@ function ToolsSection({
472
548
  }) {
473
549
  const { data } = useIntelRouterContext();
474
550
  const i18n = useI18n();
551
+ const me = useSessionUser();
552
+ const delegatedBy = definition.tools?.delegatedBy ?? null;
553
+ const delegatorName = useUserName(delegatedBy);
475
554
  const [adding, setAdding] = useState(false);
476
555
  const servers = useQuery({ queryKey: ["tool-servers"], queryFn: () => data.listToolServers() });
477
556
  const selected = definition.tools?.servers ?? [];
@@ -488,8 +567,17 @@ function ToolsSection({
488
567
  (server) => !selected.includes(server.handle),
489
568
  );
490
569
 
570
+ // Two whole sentences joined, never one sentence built from two fragments: each stays its own
571
+ // catalog entry and each translates on its own. What the join fixes is only their order.
572
+ const notice = delegationNotice(i18n, {
573
+ delegatedBy,
574
+ viewerId: me?.id ?? null,
575
+ delegatorName,
576
+ });
577
+ const hint = `${i18n.t("agent.toolsHint")} ${notice}`;
578
+
491
579
  return (
492
- <Section title={i18n.t("agent.tools")} hint={i18n.t("agent.toolsHint")}>
580
+ <Section title={i18n.t("agent.tools")} hint={hint}>
493
581
  {selected.length === 0 ? null : (
494
582
  <ul className="space-y-2">
495
583
  {selected.map((handle) => {
@@ -534,7 +622,6 @@ function ToolsSection({
534
622
  <Plus aria-hidden="true" className="size-4" />
535
623
  {i18n.t("agent.addTool")}
536
624
  </button>
537
- <p className="mt-3 text-xs text-muted-foreground">{i18n.t("agent.toolsDelegationNotice")}</p>
538
625
  {adding ? (
539
626
  <Modal title={i18n.t("agent.addTool")} close={() => setAdding(false)}>
540
627
  {servers.isPending ? (
@@ -569,9 +656,10 @@ function ToolsSection({
569
656
  ))}
570
657
  </ul>
571
658
  )}
572
- <p className="mt-4 text-xs text-muted-foreground">
573
- {i18n.t("agent.toolsDelegationNotice")}
574
- </p>
659
+ {/* ⚠️ Here it stays a visible paragraph, unlike the section's own (#254). This is the
660
+ moment the delegation is actually made, and a consequence that is only reachable by
661
+ hovering an icon somewhere behind the dialog is a consequence nobody reads. */}
662
+ <p className="mt-4 text-xs text-muted-foreground">{notice}</p>
575
663
  </Modal>
576
664
  ) : null}
577
665
  </Section>
@@ -687,16 +775,24 @@ function ScheduleDialog({ close, add }: { close(): void; add(schedule: AgentSche
687
775
  {i18n.t("agent.cronHint")}
688
776
  </span>
689
777
  </label>
690
- <label className="block text-sm font-medium" htmlFor="agent-schedule-timezone">
691
- {i18n.t("agent.scheduleTimezone")}
692
- </label>
778
+ {/* ⚠️ The hint is behind the ⓘ (#249) and ALSO `sr-only` below. The two are not a
779
+ duplicate: the tooltip's content is in the accessibility tree only while it is open, so
780
+ the field would lose the description `aria-describedby` promises the moment the sentence
781
+ became a hover. Route (1) of the ticket, and the cheaper of the two — nothing has to be
782
+ argued about a description that is simply still there. */}
783
+ <div className="flex items-center gap-1.5">
784
+ <label className="block text-sm font-medium" htmlFor="agent-schedule-timezone">
785
+ {i18n.t("agent.scheduleTimezone")}
786
+ </label>
787
+ <SectionHint hint={i18n.t("agent.scheduleTimezoneHint")} />
788
+ </div>
693
789
  <TimezoneCombobox
694
790
  id="agent-schedule-timezone"
695
791
  value={timezone}
696
792
  onChange={setTimezone}
697
793
  describedBy="agent-timezone-hint"
698
794
  />
699
- <span id="agent-timezone-hint" className="-mt-2 block text-xs text-muted-foreground">
795
+ <span id="agent-timezone-hint" className="sr-only">
700
796
  {i18n.t("agent.scheduleTimezoneHint")}
701
797
  </span>
702
798
  <EntryPicker
@@ -721,18 +817,39 @@ function ScheduleDialog({ close, add }: { close(): void; add(schedule: AgentSche
721
817
  );
722
818
  }
723
819
 
724
- // Which model does the thinking. Changing it writes a new definition version like every other edit
725
- // here the runtime picks the provider up on its next read (its definition cache is 60 seconds).
820
+ /**
821
+ * Which model does the thinking, what it costs per million tokens and what this agent has
822
+ * actually cost with it.
823
+ *
824
+ * ⚠️ Changing it writes a new definition version like every other edit here; the runtime picks the
825
+ * provider up on its next read (its definition cache is 60 seconds).
826
+ *
827
+ * ⚠️ The figures come from intel's catalog since #257 and no longer from a table in this bundle.
828
+ * The table was a copy that aged silently, and the reason it stayed one — "a token does not belong
829
+ * in a SPA" — was correct and is exactly why the fetch moved to intel rather than into this
830
+ * component.
831
+ *
832
+ * ⚠️ The two numbers beside each other are a price and a bill, and they must not read as one thing.
833
+ * A price per million tokens answers "what does this model cost"; the thirty-day figure answers
834
+ * "what has this agent cost", it is money already spent on runs that used whatever model was chosen
835
+ * AT THE TIME, and the sentence under it says so. Without that, switching the model would make the
836
+ * old figure read as a forecast for the new one.
837
+ */
726
838
  function ModelSection({
727
839
  definition,
728
840
  agent,
841
+ agentId,
729
842
  }: {
730
843
  definition: AgentDefinition;
731
844
  agent: AgentDefinitionHandle;
845
+ agentId: string;
732
846
  }) {
733
847
  const i18n = useI18n();
848
+ const catalog = useModelCatalog();
849
+ const costs = useAgentCosts(agentId);
734
850
  const models = selectableModels(definition.model);
735
- const current = modelFacts(definition.model);
851
+ const current = modelFacts(definition.model, catalog.data?.entries);
852
+ const spent = costWindow(costs.data, 30);
736
853
 
737
854
  return (
738
855
  <Section title={i18n.t("agent.model")} hint={i18n.t("agent.modelHint")}>
@@ -753,7 +870,7 @@ function ModelSection({
753
870
  </SelectTrigger>
754
871
  <SelectContent>
755
872
  {models.map((model) => {
756
- const facts = modelFacts(model);
873
+ const facts = modelFacts(model, catalog.data?.entries);
757
874
  return (
758
875
  <SelectItem key={modelKey(model)} value={modelKey(model)}>
759
876
  {/* ⚠️ The space is not formatting. The option's accessible name is its text run
@@ -767,10 +884,79 @@ function ModelSection({
767
884
  </Select>
768
885
  <ModelFigures facts={current} />
769
886
  </div>
887
+ {/* ⚠️ Said once, under the row, and only when something on screen actually came off the
888
+ table. A silent fallback to typed-out prices is the state this feature replaced. */}
889
+ {current.stale ? (
890
+ <p className="mt-2 text-xs text-muted-foreground">{i18n.t("agent.modelFiguresStale")}</p>
891
+ ) : null}
892
+ <AgentSpend
893
+ spent={spent}
894
+ status={costs.data?.status}
895
+ partial={costs.data?.partial ?? false}
896
+ />
770
897
  </Section>
771
898
  );
772
899
  }
773
900
 
901
+ /**
902
+ * What this agent has actually cost in the last thirty days (#257).
903
+ *
904
+ * ⚠️ Written in the past tense and labelled with the models that produced it, because it is the
905
+ * only thing standing between "$0.23" and a reader taking it for what the model above will cost.
906
+ * The models are named from the gateway's own log lines, so a period spent on a different model
907
+ * says so by itself.
908
+ *
909
+ * ⚠️ Nothing is rendered where the figure is not known. The Runs tab is where the reason belongs —
910
+ * it is the screen about runs — and repeating "no read token" beside a model select would be an
911
+ * error message in the middle of an unrelated decision.
912
+ *
913
+ * ⚠️ `partial` is the one exception to that division, and it has to be repeated here. It does not
914
+ * say the figure is missing — it says the figure shown IS NOT THE TOTAL, because the gateway read
915
+ * stopped at its page limit. A lower bound printed as if it were a sum is the same lie as a zero
916
+ * printed as if it were free, and this is where somebody reads it while deciding what to spend.
917
+ * An agent on a frequent schedule crosses that limit within thirty days as a matter of course; the
918
+ * seven-day figure practically never does, which makes the case rarer and therefore easier to miss.
919
+ */
920
+ function AgentSpend({
921
+ spent,
922
+ status,
923
+ partial,
924
+ }: {
925
+ spent: AgentCostWindow | null;
926
+ status: AgentCosts["status"] | undefined;
927
+ partial: boolean;
928
+ }) {
929
+ const i18n = useI18n();
930
+ if (status !== "read" || !spent) return null;
931
+
932
+ return (
933
+ <p className="mt-3 text-sm">
934
+ <span className="font-medium">
935
+ {i18n.t("agent.costsSpent", {
936
+ amount: formatCost(spent.cost, i18n.locale),
937
+ days: String(spent.days),
938
+ })}
939
+ </span>
940
+ {spent.models.length > 0 ? (
941
+ <span className="text-muted-foreground">
942
+ {" "}
943
+ {i18n.t("agent.costsSpentWith", { models: spent.models.join(", ") })}
944
+ </span>
945
+ ) : null}
946
+ <span className="mt-0.5 block text-xs text-muted-foreground">
947
+ {i18n.t("agent.costsSpentPast")}
948
+ </span>
949
+ {/* The Runs tab's own wording, deliberately the same key: two different sentences for one
950
+ fact would leave a reader wondering whether they are the same fact. */}
951
+ {partial ? (
952
+ <span className="mt-0.5 block text-xs text-muted-foreground">
953
+ {i18n.t("agent.costPartial")}
954
+ </span>
955
+ ) : null}
956
+ </p>
957
+ );
958
+ }
959
+
774
960
  // Brand first, then the model's own name — "Claude Sonnet 5", not "claude-sonnet-5 · anthropic". The
775
961
  // provider stays in the definition; it names who serves the model, which is not what anybody reads a
776
962
  // list of models for.
@@ -60,6 +60,23 @@ export function agentRuntimeMissing(error: unknown): boolean {
60
60
  * deployment's master secret was replaced — but the reader's move is identical, and a second
61
61
  * sentence saying the same thing differently would only be a second thing to keep in step.
62
62
  */
63
+ /**
64
+ * The refusal in the words the refusal itself used (#201).
65
+ *
66
+ * ⚠️ Only for an answer that carried a problem document — `code` is what proves it did. Everything
67
+ * else that reaches here is a timeout, a proxy page or a parse failure, whose `message` is
68
+ * `Intel responded with 502`: true, and no help to anybody. Those keep the generic sentence.
69
+ *
70
+ * The reason has to reach the screen because `Resume` can fail for reasons only the runtime knows —
71
+ * "none of this agent's schedules has a next fire time" is one, and it is not something a reader
72
+ * can guess from `That did not work. Try again.` The runtime words its problems to be read (see
73
+ * `packages/agent`'s guard on model-facing errors), and the log tab already shows a run's failure
74
+ * verbatim for the same reason.
75
+ */
76
+ export function agentProblemDetail(error: unknown): string | null {
77
+ return error instanceof IntelRequestError && error.code !== null ? error.message : null;
78
+ }
79
+
63
80
  export function agentKeyMissing(error: unknown): boolean {
64
81
  return (
65
82
  error instanceof IntelRequestError &&