@anchrd/intel-ui 0.14.0 → 0.15.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,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,10 @@ 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 WRITES into
344
+ * one and SEARCHES the other — so a document simply never offers them.
320
345
  */
321
346
  function KnowledgeSection({
322
347
  definition,
@@ -370,12 +395,20 @@ function KnowledgeSection({
370
395
  </button>
371
396
  {adding ? (
372
397
  <Modal title={i18n.t("agent.addReference")} close={() => setAdding(false)}>
398
+ {/* ⚠️ The picker offers the UNION of what any role accepts, and the row's role select
399
+ then offers only the roles that accept what was picked (#255). The rule is asked in
400
+ this direction because the thing is chosen first: a role chosen up front would mean a
401
+ second dialog before the picker, and a picker that let anything be chosen and refused
402
+ afterwards is worse than one that never offered it. Either way no illegal pair is
403
+ reachable — `agentReferenceKinds` is the one list both sides read. */}
373
404
  <EntryPicker
374
- kind="folder"
405
+ kind={pickableKinds}
375
406
  value=""
376
- label={i18n.t("agent.pickFolder")}
377
- onSelect={(nodeId) => {
378
- write([...definition.references, { nodeId, role: "semantic-context" }]);
407
+ label={i18n.t("agent.pickEntry")}
408
+ onSelect={(nodeId, entryKind) => {
409
+ const role = defaultRoleFor(entryKind);
410
+ if (role === null) return;
411
+ write([...definition.references, { nodeId, role }]);
379
412
  setAdding(false);
380
413
  }}
381
414
  />
@@ -385,13 +418,31 @@ function KnowledgeSection({
385
418
  );
386
419
  }
387
420
 
421
+ // What each kind looks like at a glance. An `attachment` and an `agent` cannot be picked, but an
422
+ // older definition may still name one, so every kind has a mark rather than a fallback that would
423
+ // draw two different things the same way.
424
+ const KindIcon: Record<NodeKind, typeof Folder> = {
425
+ folder: Folder,
426
+ document: FileText,
427
+ table: Table2,
428
+ attachment: Paperclip,
429
+ agent: Bot,
430
+ };
431
+
388
432
  /**
389
- * One folder the agent reads, and what it counts as.
433
+ * One thing the agent reads, where it sits, and what it counts as.
390
434
  *
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.
435
+ * ⚠️ Its own component so the select can be NAMED after the entry. Three rows carrying the same
436
+ * "What this counts as" are three identical controls to anybody listening rather than looking — the
437
+ * name is beside them on screen, and the screen is exactly what that reader does not have. It needs
438
+ * the resolved title, which is why the lookup is a hook rather than a component here.
439
+ *
440
+ * ⚠️ The path is not decoration (#255). Two folders called `Notes` in different corners of the tree
441
+ * were indistinguishable here, and this is the screen on which somebody decides what an agent may
442
+ * read and write into.
443
+ *
444
+ * ⚠️ The kind is a symbol AND a word. The symbol is for the eye and the `sr-only` word is for
445
+ * everyone else — an icon with no text is a row that says "Notes" three times to a screen reader.
395
446
  */
396
447
  function ReferenceRow({
397
448
  reference,
@@ -403,11 +454,31 @@ function ReferenceRow({
403
454
  remove(): void;
404
455
  }) {
405
456
  const i18n = useI18n();
406
- const { title, known } = useEntryTitle(reference.nodeId);
457
+ const { title, known, kind, path } = useEntryTitle(reference.nodeId);
458
+ const Icon = kind === null ? null : KindIcon[kind];
459
+ // ⚠️ Only the roles this kind can carry, so an impossible pair is never offered — and the current
460
+ // role is kept in the list whatever it is, or an older definition would open on an empty select
461
+ // and the first touch of the control would silently change what the agent does.
462
+ const roles = [
463
+ ...new Set([
464
+ ...(kind === null ? AgentReferenceRole.options : agentReferenceRolesFor(kind)),
465
+ reference.role,
466
+ ]),
467
+ ];
468
+
407
469
  return (
408
470
  <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}
471
+ {Icon ? <Icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" /> : null}
472
+ <span className={`min-w-0 flex-1${known ? "" : " text-muted-foreground"}`}>
473
+ <span className="block truncate text-sm">
474
+ {title}
475
+ {kind === null ? null : <span className="sr-only"> — {i18n.t(`node.kind.${kind}`)}</span>}
476
+ </span>
477
+ {path.length > 0 ? (
478
+ <span className="block truncate text-xs text-muted-foreground" title={path.join(" / ")}>
479
+ {path.join(" / ")}
480
+ </span>
481
+ ) : null}
411
482
  </span>
412
483
  <Select
413
484
  value={reference.role}
@@ -420,7 +491,7 @@ function ReferenceRow({
420
491
  <SelectValue />
421
492
  </SelectTrigger>
422
493
  <SelectContent>
423
- {AgentReferenceRole.options.map((role) => (
494
+ {roles.map((role) => (
424
495
  <SelectItem key={role} value={role}>
425
496
  {i18n.t(`agent.role.${role}`)}
426
497
  </SelectItem>
@@ -459,9 +530,10 @@ function EntryTitle({ entryId, flow = false }: { entryId: string; flow?: boolean
459
530
  *
460
531
  * ⚠️ The notice is not decoration, and it stands whether or not anything is listed yet. A shared
461
532
  * 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.
533
+ * act as this person. It is no longer a paragraph under the list (#254) it said the same thing on
534
+ * every visit at the place where somebody was doing something else but it is not gone either: it
535
+ * is the second half of this section's own hint, so it is in the trigger's accessible name and a
536
+ * keyboard reaches it, and it still stands in the picker where the delegation actually happens.
465
537
  */
466
538
  function ToolsSection({
467
539
  definition,
@@ -472,6 +544,9 @@ function ToolsSection({
472
544
  }) {
473
545
  const { data } = useIntelRouterContext();
474
546
  const i18n = useI18n();
547
+ const me = useSessionUser();
548
+ const delegatedBy = definition.tools?.delegatedBy ?? null;
549
+ const delegatorName = useUserName(delegatedBy);
475
550
  const [adding, setAdding] = useState(false);
476
551
  const servers = useQuery({ queryKey: ["tool-servers"], queryFn: () => data.listToolServers() });
477
552
  const selected = definition.tools?.servers ?? [];
@@ -488,8 +563,17 @@ function ToolsSection({
488
563
  (server) => !selected.includes(server.handle),
489
564
  );
490
565
 
566
+ // Two whole sentences joined, never one sentence built from two fragments: each stays its own
567
+ // catalog entry and each translates on its own. What the join fixes is only their order.
568
+ const notice = delegationNotice(i18n, {
569
+ delegatedBy,
570
+ viewerId: me?.id ?? null,
571
+ delegatorName,
572
+ });
573
+ const hint = `${i18n.t("agent.toolsHint")} ${notice}`;
574
+
491
575
  return (
492
- <Section title={i18n.t("agent.tools")} hint={i18n.t("agent.toolsHint")}>
576
+ <Section title={i18n.t("agent.tools")} hint={hint}>
493
577
  {selected.length === 0 ? null : (
494
578
  <ul className="space-y-2">
495
579
  {selected.map((handle) => {
@@ -534,7 +618,6 @@ function ToolsSection({
534
618
  <Plus aria-hidden="true" className="size-4" />
535
619
  {i18n.t("agent.addTool")}
536
620
  </button>
537
- <p className="mt-3 text-xs text-muted-foreground">{i18n.t("agent.toolsDelegationNotice")}</p>
538
621
  {adding ? (
539
622
  <Modal title={i18n.t("agent.addTool")} close={() => setAdding(false)}>
540
623
  {servers.isPending ? (
@@ -569,9 +652,10 @@ function ToolsSection({
569
652
  ))}
570
653
  </ul>
571
654
  )}
572
- <p className="mt-4 text-xs text-muted-foreground">
573
- {i18n.t("agent.toolsDelegationNotice")}
574
- </p>
655
+ {/* ⚠️ Here it stays a visible paragraph, unlike the section's own (#254). This is the
656
+ moment the delegation is actually made, and a consequence that is only reachable by
657
+ hovering an icon somewhere behind the dialog is a consequence nobody reads. */}
658
+ <p className="mt-4 text-xs text-muted-foreground">{notice}</p>
575
659
  </Modal>
576
660
  ) : null}
577
661
  </Section>
@@ -687,16 +771,24 @@ function ScheduleDialog({ close, add }: { close(): void; add(schedule: AgentSche
687
771
  {i18n.t("agent.cronHint")}
688
772
  </span>
689
773
  </label>
690
- <label className="block text-sm font-medium" htmlFor="agent-schedule-timezone">
691
- {i18n.t("agent.scheduleTimezone")}
692
- </label>
774
+ {/* ⚠️ The hint is behind the ⓘ (#249) and ALSO `sr-only` below. The two are not a
775
+ duplicate: the tooltip's content is in the accessibility tree only while it is open, so
776
+ the field would lose the description `aria-describedby` promises the moment the sentence
777
+ became a hover. Route (1) of the ticket, and the cheaper of the two — nothing has to be
778
+ argued about a description that is simply still there. */}
779
+ <div className="flex items-center gap-1.5">
780
+ <label className="block text-sm font-medium" htmlFor="agent-schedule-timezone">
781
+ {i18n.t("agent.scheduleTimezone")}
782
+ </label>
783
+ <SectionHint hint={i18n.t("agent.scheduleTimezoneHint")} />
784
+ </div>
693
785
  <TimezoneCombobox
694
786
  id="agent-schedule-timezone"
695
787
  value={timezone}
696
788
  onChange={setTimezone}
697
789
  describedBy="agent-timezone-hint"
698
790
  />
699
- <span id="agent-timezone-hint" className="-mt-2 block text-xs text-muted-foreground">
791
+ <span id="agent-timezone-hint" className="sr-only">
700
792
  {i18n.t("agent.scheduleTimezoneHint")}
701
793
  </span>
702
794
  <EntryPicker
@@ -721,18 +813,39 @@ function ScheduleDialog({ close, add }: { close(): void; add(schedule: AgentSche
721
813
  );
722
814
  }
723
815
 
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).
816
+ /**
817
+ * Which model does the thinking, what it costs per million tokens and what this agent has
818
+ * actually cost with it.
819
+ *
820
+ * ⚠️ Changing it writes a new definition version like every other edit here; the runtime picks the
821
+ * provider up on its next read (its definition cache is 60 seconds).
822
+ *
823
+ * ⚠️ The figures come from intel's catalog since #257 and no longer from a table in this bundle.
824
+ * The table was a copy that aged silently, and the reason it stayed one — "a token does not belong
825
+ * in a SPA" — was correct and is exactly why the fetch moved to intel rather than into this
826
+ * component.
827
+ *
828
+ * ⚠️ The two numbers beside each other are a price and a bill, and they must not read as one thing.
829
+ * A price per million tokens answers "what does this model cost"; the thirty-day figure answers
830
+ * "what has this agent cost", it is money already spent on runs that used whatever model was chosen
831
+ * AT THE TIME, and the sentence under it says so. Without that, switching the model would make the
832
+ * old figure read as a forecast for the new one.
833
+ */
726
834
  function ModelSection({
727
835
  definition,
728
836
  agent,
837
+ agentId,
729
838
  }: {
730
839
  definition: AgentDefinition;
731
840
  agent: AgentDefinitionHandle;
841
+ agentId: string;
732
842
  }) {
733
843
  const i18n = useI18n();
844
+ const catalog = useModelCatalog();
845
+ const costs = useAgentCosts(agentId);
734
846
  const models = selectableModels(definition.model);
735
- const current = modelFacts(definition.model);
847
+ const current = modelFacts(definition.model, catalog.data?.entries);
848
+ const spent = costWindow(costs.data, 30);
736
849
 
737
850
  return (
738
851
  <Section title={i18n.t("agent.model")} hint={i18n.t("agent.modelHint")}>
@@ -753,7 +866,7 @@ function ModelSection({
753
866
  </SelectTrigger>
754
867
  <SelectContent>
755
868
  {models.map((model) => {
756
- const facts = modelFacts(model);
869
+ const facts = modelFacts(model, catalog.data?.entries);
757
870
  return (
758
871
  <SelectItem key={modelKey(model)} value={modelKey(model)}>
759
872
  {/* ⚠️ The space is not formatting. The option's accessible name is its text run
@@ -767,10 +880,79 @@ function ModelSection({
767
880
  </Select>
768
881
  <ModelFigures facts={current} />
769
882
  </div>
883
+ {/* ⚠️ Said once, under the row, and only when something on screen actually came off the
884
+ table. A silent fallback to typed-out prices is the state this feature replaced. */}
885
+ {current.stale ? (
886
+ <p className="mt-2 text-xs text-muted-foreground">{i18n.t("agent.modelFiguresStale")}</p>
887
+ ) : null}
888
+ <AgentSpend
889
+ spent={spent}
890
+ status={costs.data?.status}
891
+ partial={costs.data?.partial ?? false}
892
+ />
770
893
  </Section>
771
894
  );
772
895
  }
773
896
 
897
+ /**
898
+ * What this agent has actually cost in the last thirty days (#257).
899
+ *
900
+ * ⚠️ Written in the past tense and labelled with the models that produced it, because it is the
901
+ * only thing standing between "$0.23" and a reader taking it for what the model above will cost.
902
+ * The models are named from the gateway's own log lines, so a period spent on a different model
903
+ * says so by itself.
904
+ *
905
+ * ⚠️ Nothing is rendered where the figure is not known. The Runs tab is where the reason belongs —
906
+ * it is the screen about runs — and repeating "no read token" beside a model select would be an
907
+ * error message in the middle of an unrelated decision.
908
+ *
909
+ * ⚠️ `partial` is the one exception to that division, and it has to be repeated here. It does not
910
+ * say the figure is missing — it says the figure shown IS NOT THE TOTAL, because the gateway read
911
+ * stopped at its page limit. A lower bound printed as if it were a sum is the same lie as a zero
912
+ * printed as if it were free, and this is where somebody reads it while deciding what to spend.
913
+ * An agent on a frequent schedule crosses that limit within thirty days as a matter of course; the
914
+ * seven-day figure practically never does, which makes the case rarer and therefore easier to miss.
915
+ */
916
+ function AgentSpend({
917
+ spent,
918
+ status,
919
+ partial,
920
+ }: {
921
+ spent: AgentCostWindow | null;
922
+ status: AgentCosts["status"] | undefined;
923
+ partial: boolean;
924
+ }) {
925
+ const i18n = useI18n();
926
+ if (status !== "read" || !spent) return null;
927
+
928
+ return (
929
+ <p className="mt-3 text-sm">
930
+ <span className="font-medium">
931
+ {i18n.t("agent.costsSpent", {
932
+ amount: formatCost(spent.cost, i18n.locale),
933
+ days: String(spent.days),
934
+ })}
935
+ </span>
936
+ {spent.models.length > 0 ? (
937
+ <span className="text-muted-foreground">
938
+ {" "}
939
+ {i18n.t("agent.costsSpentWith", { models: spent.models.join(", ") })}
940
+ </span>
941
+ ) : null}
942
+ <span className="mt-0.5 block text-xs text-muted-foreground">
943
+ {i18n.t("agent.costsSpentPast")}
944
+ </span>
945
+ {/* The Runs tab's own wording, deliberately the same key: two different sentences for one
946
+ fact would leave a reader wondering whether they are the same fact. */}
947
+ {partial ? (
948
+ <span className="mt-0.5 block text-xs text-muted-foreground">
949
+ {i18n.t("agent.costPartial")}
950
+ </span>
951
+ ) : null}
952
+ </p>
953
+ );
954
+ }
955
+
774
956
  // Brand first, then the model's own name — "Claude Sonnet 5", not "claude-sonnet-5 · anthropic". The
775
957
  // provider stays in the definition; it names who serves the model, which is not what anybody reads a
776
958
  // 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 &&
@@ -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
+ };