@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.
@@ -0,0 +1,73 @@
1
+ import type { AgentRun } from "@/data/agent-runtime/agent-runtime.ts";
2
+
3
+ /**
4
+ * The five things an agent's head can have to say. There is deliberately no "idle": the absence of
5
+ * a state is not a state, and writing it out at the most prominent place on the page was what #253
6
+ * removed.
7
+ */
8
+ export type AgentStatusKind = "unknown" | "paused" | "running" | "failed" | "waiting";
9
+
10
+ export interface AgentStatusReading {
11
+ kind: AgentStatusKind;
12
+ /** When the newest finished run ended — `null` while the agent has never finished one. */
13
+ lastRunAt: string | null;
14
+ }
15
+
16
+ export interface AgentStatusInput {
17
+ /** The run list could not be read. NOT the same as "no runs". */
18
+ runsUnreadable: boolean;
19
+ /** Newest first, the way the runtime answers. */
20
+ runs: AgentRun[];
21
+ paused: boolean;
22
+ /** Whether any schedule will fire — derived from the cron expressions, never asked. */
23
+ scheduled: boolean;
24
+ }
25
+
26
+ /**
27
+ * What the head says about this agent, or `null` when it has nothing to say (#253).
28
+ *
29
+ * ⚠️ `null` is the ordinary case and the whole point of the ticket: an agent with no schedule, no
30
+ * run in flight and no failure behind it is not "Idle · no scheduled run", it is a name. Two
31
+ * sentences spelling out the same absence stood at the most prominent place on the page.
32
+ *
33
+ * ⚠️ It is NOT simply "has a schedule". An agent somebody drives from the chat can be running, can
34
+ * be switched off, and can have failed its last run without ever carrying a schedule — hiding any
35
+ * of those would trade two useless sentences for one missing fact. What disappears is the absence,
36
+ * not the presence.
37
+ *
38
+ * ⚠️ An unreadable run list outranks everything and is its own state. A runtime that cannot be
39
+ * reached says nothing about what the agent is doing, and reporting "waiting" for it would be wrong
40
+ * exactly when something is wrong.
41
+ *
42
+ * ⚠️ Paused outranks running, the way the status line has always read it: a run still finishing
43
+ * while the agent is switched off does not make the agent switched on, and the reader's next
44
+ * question is why nothing starts afterwards.
45
+ */
46
+ export function agentStatus(input: AgentStatusInput): AgentStatusReading | null {
47
+ const finished = input.runs.find((run) => run.status !== "running") ?? null;
48
+ const lastRunAt = finished?.finishedAt ?? finished?.startedAt ?? null;
49
+
50
+ if (input.runsUnreadable) return { kind: "unknown", lastRunAt: null };
51
+ if (input.paused) return { kind: "paused", lastRunAt };
52
+ if (input.runs.some((run) => run.status === "running")) return { kind: "running", lastRunAt };
53
+ if (finished?.status === "failed") return { kind: "failed", lastRunAt };
54
+ if (input.scheduled) return { kind: "waiting", lastRunAt };
55
+ return null;
56
+ }
57
+
58
+ /**
59
+ * How each state is drawn.
60
+ *
61
+ * ⚠️ Shape carries what colour cannot. This theme is monochrome apart from `destructive` (see
62
+ * `styles.css`), so five states cannot be five hues without inventing colours the customer's own
63
+ * theme would then fight — and colour alone is no information for a reader who cannot separate two
64
+ * greys. Filled, hollow, dashed and moving are told apart without any hue at all, and the state is
65
+ * in the trigger's accessible name regardless.
66
+ */
67
+ export const statusDotClass: Record<AgentStatusKind, string> = {
68
+ unknown: "border border-dashed border-muted-foreground",
69
+ paused: "bg-muted-foreground",
70
+ running: "bg-primary motion-safe:animate-pulse",
71
+ failed: "bg-destructive",
72
+ waiting: "border border-muted-foreground",
73
+ };
@@ -11,10 +11,12 @@ import { AgentProfile } from "@/agent/agent-profile/agent-profile.tsx";
11
11
  import {
12
12
  agentAccessDenied,
13
13
  agentKeyMissing,
14
+ agentProblemDetail,
14
15
  agentRuntimeMissing,
15
16
  agentStateKey,
16
17
  useAgentState,
17
18
  } from "@/agent/agent-state/agent-state.ts";
19
+ import { agentStatus, statusDotClass } from "@/agent/agent-status-dot/agent-status-dot.ts";
18
20
  import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
19
21
  import {
20
22
  DropdownMenu,
@@ -23,9 +25,11 @@ import {
23
25
  DropdownMenuTrigger,
24
26
  } from "@/components/ui/dropdown-menu";
25
27
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
28
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
26
29
  import { useCapabilities } from "@/hooks/use-capabilities.ts";
27
30
  import { useI18n } from "@/i18n/i18n-context.tsx";
28
31
  import { useIntelRouterContext } from "@/router/router-context.ts";
32
+ import { useUserName } from "@/user-name/user-name.ts";
29
33
 
30
34
  const tabs = ["profile", "chat", "calendar", "log"] as const;
31
35
  type Tab = (typeof tabs)[number];
@@ -66,6 +70,9 @@ export function AgentPanel({ node }: { node: Node }) {
66
70
  <ActionSlot name="title-meta">
67
71
  <AgentStatus agentId={node.id} definition={agent.definition} />
68
72
  </ActionSlot>
73
+ <ActionSlot name="title-byline">
74
+ <AgentByline node={node} />
75
+ </ActionSlot>
69
76
  <ActionSlot name="title-actions">
70
77
  <AgentActions agentId={node.id} definition={agent.definition} />
71
78
  </ActionSlot>
@@ -108,6 +115,35 @@ export function AgentPanel({ node }: { node: Node }) {
108
115
  );
109
116
  }
110
117
 
118
+ /**
119
+ * Who made this agent — the first question anybody asks about one that works in folders with rights
120
+ * of its own (#258).
121
+ *
122
+ * ⚠️ Provenance and nothing else. An agent does NOT run with its creator's rights: it has a Gate
123
+ * Application of its own (D29), the assertion's subject is the agent, and Intel's grants on the
124
+ * tree decide what a run may read and write. Only the TOOLS run on a human's connection, and that
125
+ * is `delegatedBy` — a different field, in a different place on this page, on purpose. So this line
126
+ * says "by", never "runs as", and it carries no permission wording that would imply otherwise.
127
+ *
128
+ * ⚠️ `ownerId` and not the current version's `createdBy`: the owner is written once, from the actor
129
+ * who created the node, and never again — while `createdBy` moves to whoever saved last, which is a
130
+ * different fact and belongs to a different question.
131
+ *
132
+ * ⚠️ Unresolvable means NO line, not "by —". Most ids are unresolvable today (see `useUserName`),
133
+ * and an em dash under every foreign agent's name would be a row of furniture saying nothing.
134
+ */
135
+ function AgentByline({ node }: { node: Node }) {
136
+ const i18n = useI18n();
137
+ const name = useUserName(node.ownerId);
138
+ if (name === null) return null;
139
+
140
+ return (
141
+ <span className="block truncate text-xs text-muted-foreground" title={name}>
142
+ {i18n.t("agent.createdBy", { name })}
143
+ </span>
144
+ );
145
+ }
146
+
111
147
  /**
112
148
  * What somebody without `agents/run` sees instead of the chat and the log.
113
149
  *
@@ -141,11 +177,20 @@ function NoRuntime() {
141
177
  }
142
178
 
143
179
  /**
144
- * Whether the agent is working, whether it is switched off, and when it next will be.
180
+ * Whether the agent is working, whether it is switched off, and when it next will be — as a dot
181
+ * beside the name, and only when there is something to say (#253).
182
+ *
183
+ * ⚠️ A dot and not a sentence, and NOT ALWAYS a dot. What stood here was "Idle · no scheduled run"
184
+ * — the absence of a state, spelled out twice, at the most prominent place on the page. `null` from
185
+ * `agentStatus` is the ordinary case and renders nothing at all.
186
+ *
187
+ * ⚠️ The colour is never the only information. The state is the trigger's accessible NAME, the dot
188
+ * itself is `aria-hidden`, and the shapes differ as well as the hues — see `statusDotClass`. A
189
+ * reader who cannot separate two greys, and every reader who is listening rather than looking, gets
190
+ * the same answer as everybody else.
145
191
  *
146
192
  * ⚠️ "Running" is read from the runtime's own run list, and its absence is NOT read as "idle" — a
147
- * runtime that cannot be reached says nothing about what the agent is doing, and a status line that
148
- * quietly reported "idle" for an unreachable runtime would be wrong exactly when it matters.
193
+ * runtime that cannot be reached says nothing about what the agent is doing.
149
194
  *
150
195
  * ⚠️ "Next run" is derived from the cron expressions rather than asked. The runtime arms one alarm
151
196
  * and publishes neither it nor a schedule, so this is the same reading `AgentCalendar` does — and
@@ -164,22 +209,21 @@ export function AgentStatus({
164
209
  const runs = useQuery({
165
210
  queryKey: ["agent-runs", agentId],
166
211
  queryFn: () => data.listAgentRuns(agentId),
167
- // A status line is not worth a red screen: the runtime being unreachable is reported by the
212
+ // A status dot is not worth a red screen: the runtime being unreachable is reported by the
168
213
  // Log tab, which is the view that is actually about runs.
169
214
  retry: false,
170
215
  });
171
- const running = runs.data?.items.some((run) => run.status === "running") ?? false;
172
- const paused = state.data?.paused ?? false;
173
216
  const next = nextRunAt(definition, new Date());
174
- const parts = [
175
- runs.isError
176
- ? i18n.t("agent.statusUnknown")
177
- : paused
178
- ? i18n.t("agent.statusPaused")
179
- : running
180
- ? i18n.t("agent.statusRunning")
181
- : i18n.t("agent.statusIdle"),
182
- paused
217
+ const status = agentStatus({
218
+ runsUnreadable: runs.isError,
219
+ runs: runs.data?.items ?? [],
220
+ paused: state.data?.paused ?? false,
221
+ scheduled: next !== null,
222
+ });
223
+ if (status === null) return null;
224
+
225
+ const when =
226
+ status.kind === "paused"
183
227
  ? i18n.t("agent.pausedNoRuns")
184
228
  : next
185
229
  ? // ⚠️ Formatted in the SCHEDULE's zone and named with it (#228), not in the reader's.
@@ -194,9 +238,38 @@ export function AgentStatus({
194
238
  }).format(next.at),
195
239
  zone: next.timezone,
196
240
  })
197
- : i18n.t("agent.noNextRun"),
198
- ];
199
- return <span>{parts.join(" · ")}</span>;
241
+ : null;
242
+ // ⚠️ A past instant, so it is read in the reader's own zone and names none: unlike a schedule,
243
+ // which fires in the zone it carries, a finished run happened at one moment for everybody.
244
+ const last =
245
+ status.lastRunAt === null
246
+ ? null
247
+ : i18n.t("agent.lastRun", {
248
+ when: new Intl.DateTimeFormat(i18n.locale, {
249
+ dateStyle: "medium",
250
+ timeStyle: "short",
251
+ }).format(new Date(status.lastRunAt)),
252
+ });
253
+ const label = [i18n.t(`agent.status.${status.kind}`), last, when].filter(Boolean).join(" · ");
254
+
255
+ return (
256
+ <TooltipProvider delayDuration={300}>
257
+ <Tooltip>
258
+ {/* A real button, so the dot is reached by Tab and opened without a pointer. */}
259
+ <TooltipTrigger
260
+ type="button"
261
+ aria-label={label}
262
+ className="inline-flex items-center rounded-full p-1 outline-none focus-visible:ring-2 focus-visible:ring-ring"
263
+ >
264
+ <span
265
+ aria-hidden="true"
266
+ className={`block size-2.5 rounded-full ${statusDotClass[status.kind]}`}
267
+ />
268
+ </TooltipTrigger>
269
+ <TooltipContent className="max-w-xs">{label}</TooltipContent>
270
+ </Tooltip>
271
+ </TooltipProvider>
272
+ );
200
273
  }
201
274
 
202
275
  /**
@@ -245,7 +318,7 @@ export function AgentActions({
245
318
  const reason = denied
246
319
  ? i18n.t("agent.notPermitted")
247
320
  : state.isError
248
- ? i18n.t("agent.statusUnknown")
321
+ ? i18n.t("agent.status.unknown")
249
322
  : null;
250
323
  const runReason =
251
324
  reason ??
@@ -258,51 +331,87 @@ export function AgentActions({
258
331
 
259
332
  return (
260
333
  <span className="flex items-center gap-2">
261
- {/* ⚠️ `sr-only` rather than `hidden` where the bar is narrow. A disabled control whose reason
262
- is `display: none` has no reason at all for a screen reader the element it points at is
263
- not in the accessibility tree. */}
334
+ {/* ⚠️ `sr-only` and no longer beside the buttons (#253): the reason belongs in the tooltip of
335
+ the control it disables, not as a sentence in the title line. But it stays IN THE
336
+ ACCESSIBILITY TREE `hidden` or `display: none` would leave `aria-describedby` pointing at
337
+ nothing, and Radix puts the tooltip's own content there only while it is open. So the words
338
+ are here for the keyboard and on the hover for the pointer, and never in the bar. */}
264
339
  {runReason ? (
265
- <span
266
- id="agent-actions-reason"
267
- className="text-xs text-muted-foreground max-sm:sr-only sm:inline"
268
- >
340
+ <span id="agent-actions-reason" className="sr-only">
269
341
  {runReason}
270
342
  </span>
271
343
  ) : null}
272
344
  {/* ⚠️ An agent with no key in its Durable Object fails every run the same way forever, so
273
345
  "try again" is advice that cannot work (#200). The one refusal with a repair path says
274
- what the repair is, and names where it lives — the profile, not this bar (D29). */}
346
+ what the repair is, and names where it lives — the profile, not this bar (D29).
347
+
348
+ ⚠️ Every other refusal is shown in its OWN words where it brought any (#201). `Resume`
349
+ fails for reasons only the runtime knows, and "that did not work" turns each of them into
350
+ the same shrug — which is how a resume that could not arm the alarm looked like one that
351
+ simply needed another click. */}
275
352
  {failure ? (
276
353
  <span role="alert" className="text-xs text-destructive max-sm:sr-only sm:inline">
277
- {i18n.t(agentKeyMissing(failure) ? "agent.keyMissing" : "agent.actionFailed")}
354
+ {agentKeyMissing(failure)
355
+ ? i18n.t("agent.keyMissing")
356
+ : (agentProblemDetail(failure) ?? i18n.t("agent.actionFailed"))}
278
357
  </span>
279
358
  ) : null}
280
- <button
281
- type="button"
282
- onClick={() => toggle.mutate()}
283
- disabled={reason !== null || state.isPending || toggle.isPending}
284
- aria-busy={toggle.isPending}
285
- {...(reason ? { "aria-describedby": "agent-actions-reason" } : {})}
286
- className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
287
- >
288
- {paused ? (
289
- <Play aria-hidden="true" className="size-4" />
290
- ) : (
291
- <Pause aria-hidden="true" className="size-4" />
292
- )}
293
- {i18n.t(paused ? "agent.resume" : "agent.pause")}
294
- </button>
295
- <RunNow
296
- targets={targets}
297
- disabled={runReason !== null || runNow.isPending}
298
- busy={runNow.isPending}
299
- describedBy={runReason ? "agent-actions-reason" : undefined}
300
- start={(target) => runNow.mutate(target)}
301
- />
359
+ <WhyDisabled reason={reason}>
360
+ <button
361
+ type="button"
362
+ onClick={() => toggle.mutate()}
363
+ disabled={reason !== null || state.isPending || toggle.isPending}
364
+ aria-busy={toggle.isPending}
365
+ {...(reason ? { "aria-describedby": "agent-actions-reason" } : {})}
366
+ className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
367
+ >
368
+ {paused ? (
369
+ <Play aria-hidden="true" className="size-4" />
370
+ ) : (
371
+ <Pause aria-hidden="true" className="size-4" />
372
+ )}
373
+ {i18n.t(paused ? "agent.resume" : "agent.pause")}
374
+ </button>
375
+ </WhyDisabled>
376
+ <WhyDisabled reason={runReason}>
377
+ <RunNow
378
+ targets={targets}
379
+ disabled={runReason !== null || runNow.isPending}
380
+ busy={runNow.isPending}
381
+ describedBy={runReason ? "agent-actions-reason" : undefined}
382
+ start={(target) => runNow.mutate(target)}
383
+ />
384
+ </WhyDisabled>
302
385
  </span>
303
386
  );
304
387
  }
305
388
 
389
+ /**
390
+ * The reason a control is shut, on its own hover instead of beside it (#253).
391
+ *
392
+ * ⚠️ The wrapping span is not decoration. A disabled button fires no pointer events, so a trigger
393
+ * bound directly to it never opens — the same trick the profile's disabled "Add contact" needs, and
394
+ * the reason both keep an `sr-only` paragraph for `aria-describedby` beside the tooltip.
395
+ *
396
+ * ⚠️ The wrapper is ALWAYS here, and only the content comes and goes. A reason appears and vanishes
397
+ * as the state query settles, and a version of this that returned the bare child when there was
398
+ * nothing to say re-created the button on every such change — which throws away its focus and, in a
399
+ * test, leaves whoever grabbed it holding a node that is no longer on screen. Without content Radix
400
+ * opens nothing at all, so the empty case costs a span and shows no bubble.
401
+ */
402
+ function WhyDisabled({ reason, children }: { reason: string | null; children: React.ReactNode }) {
403
+ return (
404
+ <TooltipProvider delayDuration={300}>
405
+ <Tooltip>
406
+ <TooltipTrigger asChild>
407
+ <span className="inline-flex">{children}</span>
408
+ </TooltipTrigger>
409
+ {reason === null ? null : <TooltipContent className="max-w-xs">{reason}</TooltipContent>}
410
+ </Tooltip>
411
+ </TooltipProvider>
412
+ );
413
+ }
414
+
306
415
  /**
307
416
  * ⚠️ One schedule runs straight away; several open a menu. A button that silently picked the first
308
417
  * of three targets would run the wrong thing at a moment somebody chose deliberately, and a menu in
@@ -476,8 +476,14 @@ export function AppTree() {
476
476
  : "opacity-40";
477
477
 
478
478
  return (
479
- <SidebarMenuItem key={entry.id} className="group/row">
480
- {/* ⚠️ The colouring sits on the ROW, not on the button inside it the difference the
479
+ <SidebarMenuItem key={entry.id}>
480
+ {/* ⚠️ `group/row` belongs HERE and not on the `SidebarMenuItem` above, which is what #252
481
+ was: the item also holds the open sub-level, and `group-hover/row` is a DESCENDANT
482
+ selector — every plus further down matched the ancestor's hover, so pointing at one
483
+ folder lit up its whole open subtree. This div holds one row and none of its children,
484
+ which is the only element on which "the row is hovered" means that one row.
485
+
486
+ ⚠️ The colouring sits on the ROW, not on the button inside it — the difference the
481
487
  ticket is about. It used to hang on `SidebarMenuButton`, whose siblings the plus and this
482
488
  menu are, so the grey stopped short of them: two icons standing outside the very row they
483
489
  operate, with a pale strip left over on the right. Taken from gate, where the same
@@ -486,7 +492,7 @@ export function AppTree() {
486
492
  The button keeps its own hover and active fill switched off rather than doubled, so the
487
493
  row shows one surface instead of two overlapping ones. */}
488
494
  <div
489
- className={`flex items-center gap-0.5 rounded-md pr-1 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground ${
495
+ className={`group/row flex items-center gap-0.5 rounded-md pr-1 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground ${
490
496
  isActive ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" : ""
491
497
  }`}
492
498
  >
@@ -14,6 +14,7 @@ import {
14
14
  } from "@/components/ui/select";
15
15
  import { useI18n, useLanguage } from "@/i18n/i18n-context.tsx";
16
16
  import { languageName } from "@/i18n/i18n-languages/i18n-languages.ts";
17
+ import { SectionHint } from "@/section-hint/section-hint.tsx";
17
18
  import { type ThemeChoice, ThemeChoices } from "@/theme/theme.ts";
18
19
  import { useTheme } from "@/theme/theme-context.tsx";
19
20
  import { TimezoneCombobox } from "@/timezone/timezone-combobox/timezone-combobox.tsx";
@@ -94,16 +95,24 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
94
95
  ) : null}
95
96
  {timezone ? (
96
97
  <div className="grid gap-2">
97
- <label className="text-sm font-medium" htmlFor="settings-timezone">
98
- {i18n.t("settings.timezone")}
99
- </label>
98
+ {/* ⚠️ Behind the ⓘ (#249), and the SAME component the agent profile uses — three
99
+ lines of prose under a dropdown explained something once and then pushed the
100
+ dialog apart on every open. The `sr-only` copy below is not a duplicate: Radix
101
+ puts a tooltip's content in the accessibility tree only while it is open, so the
102
+ field would silently lose the description `aria-describedby` points at. */}
103
+ <div className="flex items-center gap-1.5">
104
+ <label className="text-sm font-medium" htmlFor="settings-timezone">
105
+ {i18n.t("settings.timezone")}
106
+ </label>
107
+ <SectionHint hint={i18n.t("settings.timezoneHint")} />
108
+ </div>
100
109
  <TimezoneCombobox
101
110
  id="settings-timezone"
102
111
  value={timezone.current}
103
112
  onChange={timezone.select}
104
113
  describedBy="settings-timezone-hint"
105
114
  />
106
- <p id="settings-timezone-hint" className="text-xs text-muted-foreground">
115
+ <p id="settings-timezone-hint" className="sr-only">
107
116
  {i18n.t("settings.timezoneHint")}
108
117
  </p>
109
118
  </div>
@@ -15,6 +15,25 @@ export const AgentRunTarget = z.object({
15
15
  });
16
16
  export type AgentRunTarget = z.infer<typeof AgentRunTarget>;
17
17
 
18
+ /**
19
+ * What one run consumed at the provider, as the runtime recorded it (#250).
20
+ *
21
+ * ⚠️ `inputTokens` is the part that was NOT cached, not the size of the prompt — the prompt is all
22
+ * four fields added together. Anything that shows the first as "the prompt" will report a run that
23
+ * suddenly shrank to a twentieth of the previous one.
24
+ *
25
+ * ⚠️ Optional here, and the optionality is the feature. A run recorded before #250 carries no
26
+ * numbers at all, and that is a different fact from "used no tokens" — the row shows an empty state
27
+ * rather than four zeroes, because a zero reads as "cost nothing".
28
+ */
29
+ export const AgentUsage = z.object({
30
+ inputTokens: z.number(),
31
+ outputTokens: z.number(),
32
+ cacheCreationInputTokens: z.number(),
33
+ cacheReadInputTokens: z.number(),
34
+ });
35
+ export type AgentUsage = z.infer<typeof AgentUsage>;
36
+
18
37
  export const AgentRun = z.object({
19
38
  id: z.string(),
20
39
  trigger: z.enum(["chat", "schedule", "mail", "task", "manual"]),
@@ -24,6 +43,25 @@ export const AgentRun = z.object({
24
43
  finishedAt: z.string().nullable(),
25
44
  steps: z.number(),
26
45
  error: z.string().nullable(),
46
+ /**
47
+ * What the run answered, for the runs nobody was watching: `schedule`, `manual` and `task`.
48
+ *
49
+ * ⚠️ A `chat` or `mail` run carries none, and that is a runtime invariant rather than an accident
50
+ * (#268): that answer belongs to the person whose window it was streamed into or to the
51
+ * correspondent it was posted to. The log screen therefore claims nothing at all on those two
52
+ * triggers — an empty state there would read as "the answer went missing".
53
+ *
54
+ * ⚠️ `.nullish()` for the same reason as `usage` below: a runtime from before #268 sends no such
55
+ * field, and a required one would blank the whole Log tab over its absence.
56
+ */
57
+ answer: z.string().nullish(),
58
+ // ⚠️ `.nullish()` on both, not `.nullable()`. The runtime is a separate deployment and may be a
59
+ // version BEHIND this page: a run list from a runtime without #250/#251 has no such field at all,
60
+ // and a required one would blank the whole Log tab over a missing number.
61
+ usage: AgentUsage.nullish(),
62
+ // Which model did the thinking, as `provider:model`, written into the run rather than looked up:
63
+ // switching the model in the profile must not relabel every past run (#251).
64
+ model: z.string().nullish(),
27
65
  });
28
66
  export type AgentRun = z.infer<typeof AgentRun>;
29
67
 
@@ -1,4 +1,5 @@
1
1
  import {
2
+ AgentCosts,
2
3
  AgentKeyRotated,
3
4
  AppendTableRowsInput,
4
5
  AppendTableRowsResult,
@@ -24,6 +25,7 @@ import {
24
25
  ListFlowRunsInput,
25
26
  ListFlowsInput,
26
27
  ListNodesInput,
28
+ ModelCatalog,
27
29
  Node,
28
30
  NodeAgent,
29
31
  NodeDocument,
@@ -203,6 +205,16 @@ export function createIntelDataProvider(
203
205
  async getCapabilities() {
204
206
  return await request("/capabilities", IntelCapabilities);
205
207
  },
208
+ /**
209
+ * What the offered models cost and hold (#257).
210
+ *
211
+ * ⚠️ Asked of intel, never of Cloudflare. The account endpoint needs a token and a token does
212
+ * not belong in a SPA — which was the stated reason the price table stayed hard-coded in this
213
+ * package until now. The figures cross the wire; the credential does not.
214
+ */
215
+ async listModels() {
216
+ return await request("/models", ModelCatalog);
217
+ },
206
218
  listNodes,
207
219
  async listTreeChildren(parentId) {
208
220
  // Both sides of one folder, asked for in parallel and merged here rather than on the server:
@@ -517,6 +529,14 @@ export function createIntelDataProvider(
517
529
  async listAgentRuns(agentId) {
518
530
  return await request(agentRuntimePath(agentId, "/runs"), AgentRunList);
519
531
  },
532
+ /**
533
+ * ⚠️ The one path under `/agents/:agentId` that intel answers ITSELF (#251). The runtime has no
534
+ * idea what anything costs — the figures come out of Cloudflare's AI Gateway log, read with a
535
+ * token that lives in intel and never in the browser.
536
+ */
537
+ async getAgentCosts(agentId) {
538
+ return await request(agentRuntimePath(agentId, "/costs"), AgentCosts);
539
+ },
520
540
  async getAgentRun(agentId, runId) {
521
541
  return await request(
522
542
  agentRuntimePath(agentId, `/runs/${encodeURIComponent(runId)}`),
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ AgentCosts,
2
3
  AgentKeyRotated,
3
4
  AgentScheduleTarget,
4
5
  AppendTableRowsInput,
@@ -25,6 +26,7 @@ import type {
25
26
  ListFlowRunsInput,
26
27
  ListFlowsInput,
27
28
  ListNodesInput,
29
+ ModelCatalog,
28
30
  Node,
29
31
  NodeAgent,
30
32
  NodeDocument,
@@ -86,6 +88,14 @@ export interface IntelDataProvider {
86
88
  // What this installation is equipped to do (#190) — deployment facts, not the caller's
87
89
  // permissions. `/session` stays identity only on purpose; this is the separate question.
88
90
  getCapabilities(): Promise<IntelCapabilities>;
91
+ /**
92
+ * What the offered models cost and how much they hold (#257).
93
+ *
94
+ * ⚠️ Answered by intel, which asks Cloudflare with a token this page never sees. Each entry says
95
+ * whether its figures were read live or came off intel's written-out table, and the select says so
96
+ * too — a silently stale price is the state this replaced, with more code.
97
+ */
98
+ listModels(): Promise<ModelCatalog>;
89
99
  listNodes(input?: Partial<ListNodesInput>): Promise<NodeList>;
90
100
  // One level of the shared tree: the documents and the flows filed in the same folder, in one
91
101
  // sorted list. Per level rather than recursive, so opening a folder is what costs a request.
@@ -190,6 +200,15 @@ export interface IntelDataProvider {
190
200
  // CORS. What that costs is that a 403 here means "you may not drive agents", which is a state the
191
201
  // agent page renders as an answer rather than as a failure.
192
202
  listAgentRuns(agentId: string): Promise<AgentRunList>;
203
+ /**
204
+ * What this agent has actually cost, out of Cloudflare's AI Gateway log (#251).
205
+ *
206
+ * ⚠️ Unlike its neighbours this one is answered by INTEL and not forwarded to the runtime, which
207
+ * knows nothing about money. ⚠️ Read `status` before the numbers: empty with `not_configured` or
208
+ * `unreadable` means "not known", and rendering it like "cost nothing" turns an outage into a
209
+ * saving.
210
+ */
211
+ getAgentCosts(agentId: string): Promise<AgentCosts>;
193
212
  getAgentRun(agentId: string, runId: string): Promise<AgentRunDetail>;
194
213
  // Whether the agent is switched off. Runtime state, deliberately not part of the definition: a
195
214
  // definition is versioned, shared and read into model context (#179).
@@ -32,7 +32,12 @@ export function EntryPicker({
32
32
  label,
33
33
  }: {
34
34
  // Which kind of node may be chosen. `null` offers every kind — what the Flow step uses.
35
- kind: NodeKind | null;
35
+ //
36
+ // ⚠️ A list is not a convenience (#255). An agent's system message takes a folder, a document or
37
+ // a table, and the alternative — one picker per kind, or a picker that lets everything be chosen
38
+ // and refuses afterwards — is either three dialogs or a rejection after the fact. What may be
39
+ // picked has to be the same question the caller can answer in one place.
40
+ kind: NodeKind | NodeKind[] | null;
36
41
  // ⚠️ Flows are a second list, not a kind of node, so they are asked for rather than assumed. An
37
42
  // agent's schedule may aim at either (#143); every other caller wants nodes only and pays for no
38
43
  // second request.
@@ -75,10 +80,16 @@ export function EntryPicker({
75
80
  }, [graph.data, flowList.data, flows]);
76
81
  // A flow is offered exactly when it was asked for — it is only in `nodes` at all in that case,
77
82
  // and saying so here keeps "may this be picked" one rule rather than two that must agree.
78
- const pickable = useMemo(
79
- () => (entry: PickerEntry) => entry.kind === "flow" || kind === null || entry.kind === kind,
80
- [kind],
81
- );
83
+ //
84
+ // ⚠️ The array is rebuilt from `kind` on every render when a caller passes a literal, so the set
85
+ // is keyed by its contents rather than by identity — a `useMemo` on the array itself would be a
86
+ // memo that never hits.
87
+ const wanted = Array.isArray(kind) ? kind.join(",") : kind;
88
+ const pickable = useMemo(() => {
89
+ const allowed = wanted === null ? null : new Set(wanted.split(","));
90
+ return (entry: PickerEntry) =>
91
+ entry.kind === "flow" || allowed === null || allowed.has(entry.kind);
92
+ }, [wanted]);
82
93
  const eligible = useMemo(() => nodes.filter(pickable), [nodes, pickable]);
83
94
 
84
95
  // Searching looks at every eligible node wherever it sits; browsing looks at one level. The two
@@ -0,0 +1,26 @@
1
+ import type { ModelCatalog } from "@anchrd/intel-contract";
2
+ import { type UseQueryResult, useQuery } from "@tanstack/react-query";
3
+ import { useIntelRouterContext } from "@/router/router-context.ts";
4
+
5
+ /**
6
+ * What the offered models cost and how much they hold, asked of intel once an hour at most (#257).
7
+ *
8
+ * ⚠️ The caching is the point of the hook and not an optimisation. Before this, the figures were a
9
+ * table in the bundle; the ticket's own condition for taking that away is that the replacement is
10
+ * not "one fetch every time somebody opens the select". Prices move a few times a year, so an hour
11
+ * is generous in the direction that matters.
12
+ *
13
+ * ⚠️ `retry: false`, like the tools screen's grouping query (#212). A React Query retry is PAUSED
14
+ * while the tab is unfocused, so a select that waited on a retried query could sit unusable for as
15
+ * long as the reader is looking elsewhere. This query only ENRICHES the select — the model can be
16
+ * chosen without it — so it fails fast and the row simply shows no figures.
17
+ */
18
+ export function useModelCatalog(): UseQueryResult<ModelCatalog> {
19
+ const { data } = useIntelRouterContext();
20
+ return useQuery({
21
+ queryKey: ["model-catalog"],
22
+ queryFn: () => data.listModels(),
23
+ staleTime: 60 * 60 * 1_000,
24
+ retry: false,
25
+ });
26
+ }