@anchrd/intel-ui 0.18.0 → 0.20.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 (78) hide show
  1. package/package.json +2 -9
  2. package/src/app/app-tree/app-tree.tsx +68 -147
  3. package/src/app/header-search/header-search.tsx +2 -1
  4. package/src/app/reindex-dialog/reindex-dialog.tsx +79 -0
  5. package/src/app/settings-dialog/settings-dialog.tsx +10 -32
  6. package/src/app/user-footer/user-footer.tsx +19 -0
  7. package/src/archive/archive.tsx +2 -1
  8. package/src/data/intel-data-provider/intel-data-provider.ts +42 -191
  9. package/src/data/intel-data-provider/intel-data-provider.types.ts +36 -120
  10. package/src/document-link/document-link.tsx +1 -1
  11. package/src/entry-picker/entry-picker.tsx +7 -7
  12. package/src/flow-runs/flow-runs.tsx +1 -1
  13. package/src/flows/flows.tsx +3 -2
  14. package/src/flows/node-icon/node-icon.ts +1 -1
  15. package/src/flows/node-palette/node-palette.tsx +1 -1
  16. package/src/flows/node-palette/node-palette.types.ts +1 -1
  17. package/src/graph-pane/graph-pane.tsx +1 -1
  18. package/src/i18n/de.json +14 -217
  19. package/src/i18n/en.json +14 -217
  20. package/src/i18n/es.json +14 -217
  21. package/src/import-bundle/import-bundle.tsx +152 -0
  22. package/src/kind-icon.ts +1 -15
  23. package/src/main.tsx +7 -10
  24. package/src/node-editor/node-editor.tsx +1 -1
  25. package/src/node-graph/graph-notice.tsx +1 -1
  26. package/src/node-graph/node-graph.ts +1 -1
  27. package/src/node-graph/node-graph.tsx +1 -1
  28. package/src/node-table/node-table.tsx +3 -3
  29. package/src/nodes/nodes.tsx +1 -35
  30. package/src/resource-menu/resource-menu.tsx +45 -21
  31. package/src/section-hint/section-hint.tsx +2 -2
  32. package/src/title-row/title-row.tsx +5 -22
  33. package/src/tools/tools.tsx +1 -3
  34. package/vite.config.ts +0 -4
  35. package/src/agent/agent-avatar/agent-avatar.tsx +0 -34
  36. package/src/agent/agent-calendar/agent-calendar.tsx +0 -160
  37. package/src/agent/agent-chat/agent-chat.tsx +0 -118
  38. package/src/agent/agent-costs/agent-costs.ts +0 -95
  39. package/src/agent/agent-cron/agent-cron.ts +0 -68
  40. package/src/agent/agent-definition/agent-definition.ts +0 -96
  41. package/src/agent/agent-delegation-notice/agent-delegation-notice.ts +0 -48
  42. package/src/agent/agent-entry-title/agent-entry-title.ts +0 -72
  43. package/src/agent/agent-log/agent-log.tsx +0 -308
  44. package/src/agent/agent-models/agent-models.ts +0 -166
  45. package/src/agent/agent-models/agent-models.types.ts +0 -24
  46. package/src/agent/agent-profile/agent-profile.tsx +0 -1006
  47. package/src/agent/agent-state/agent-state.ts +0 -85
  48. package/src/agent/agent-status-dot/agent-status-dot.ts +0 -73
  49. package/src/agent/agent.tsx +0 -507
  50. package/src/board/board-calendar/board-calendar.tsx +0 -89
  51. package/src/board/board-card/board-card.tsx +0 -106
  52. package/src/board/board-data/board-data.ts +0 -241
  53. package/src/board/board-data/board-data.types.ts +0 -63
  54. package/src/board/board-detail/board-detail.tsx +0 -629
  55. package/src/board/board-gantt/board-gantt.ts +0 -545
  56. package/src/board/board-gantt/board-gantt.tsx +0 -286
  57. package/src/board/board-graph/board-graph.ts +0 -174
  58. package/src/board/board-graph/board-graph.tsx +0 -168
  59. package/src/board/board-items/board-items.ts +0 -183
  60. package/src/board/board-kanban/board-kanban.ts +0 -137
  61. package/src/board/board-kanban/board-kanban.tsx +0 -257
  62. package/src/board/board-status/board-status.ts +0 -59
  63. package/src/board/board-statuses/board-statuses.ts +0 -63
  64. package/src/board/board-statuses/board-statuses.tsx +0 -228
  65. package/src/board/board-table/board-table.ts +0 -33
  66. package/src/board/board-table/board-table.tsx +0 -413
  67. package/src/board/board-views/board-views.tsx +0 -68
  68. package/src/board/board-views/board-views.types.ts +0 -29
  69. package/src/board/board.tsx +0 -251
  70. package/src/components/ui/item-calendar.tsx +0 -181
  71. package/src/components/ui/item-gantt.tsx +0 -463
  72. package/src/components/ui/kanban.tsx +0 -282
  73. package/src/data/agent-runtime/agent-runtime.ts +0 -131
  74. package/src/hooks/use-capabilities.ts +0 -22
  75. package/src/hooks/use-model-catalog.ts +0 -26
  76. package/src/timezone/timezone-combobox/timezone-combobox.tsx +0 -149
  77. package/src/timezone/timezone-context.tsx +0 -65
  78. package/src/timezone/timezone.ts +0 -174
@@ -1,18 +1,15 @@
1
- import type {
2
- Flow,
3
- FlowValidation,
4
- Node,
5
- NodeKind,
6
- ResourceVerb,
7
- UnreadableNodes,
8
- } from "@anchrd/intel-contract";
1
+ import type { Flow, FlowValidation } from "@anchrd/intel-contract/flow";
2
+ import type { Node } from "@anchrd/intel-contract/node";
3
+ import type { ResourceVerb, UnreadableNodes } from "@anchrd/intel-contract/share";
9
4
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
10
5
  import { useNavigate, useRouterState } from "@tanstack/react-router";
11
6
  import {
12
7
  Archive,
13
8
  CornerLeftUp,
14
9
  Ellipsis,
10
+ FileArchive,
15
11
  FolderDown,
12
+ FolderUp,
16
13
  Link2,
17
14
  Pencil,
18
15
  Share2,
@@ -27,11 +24,15 @@ import {
27
24
  DropdownMenuContent,
28
25
  DropdownMenuItem,
29
26
  DropdownMenuSeparator,
27
+ DropdownMenuSub,
28
+ DropdownMenuSubContent,
29
+ DropdownMenuSubTrigger,
30
30
  DropdownMenuTrigger,
31
31
  } from "@/components/ui/dropdown-menu";
32
32
  import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
33
33
  import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
34
34
  import { useI18n } from "@/i18n/i18n-context.tsx";
35
+ import { useBundleImport } from "@/import-bundle/import-bundle.tsx";
35
36
  import { Modal } from "@/modal/modal.tsx";
36
37
  import { useIntelRouterContext } from "@/router/router-context.ts";
37
38
  import { selectedFrom } from "@/router/selection-search.ts";
@@ -81,18 +82,15 @@ function parentOfTarget(target: ResourceTarget): string | null {
81
82
  }
82
83
 
83
84
  /**
84
- * Which sentence explains a verb, for THIS kind of thing.
85
+ * Which sentence explains a verb.
85
86
  *
86
- * ⚠️ "Run" does not mean the same thing twice. On a folder it grants starting the flows filed in
87
- * it; on an agent it grants using the agent through its chat, its schedules and its MCP address.
88
- * Until #143 there was one sentence for both, and on an agent it named flows that have nothing to
89
- * do with the grant being made. A key per kind, falling back to the general one, so a kind that
90
- * needs no special wording needs no entry.
87
+ * ⚠️ One sentence per verb, and that is only true again since Agents were parked (#388). "Run"
88
+ * used to mean two different things starting the flows filed in a folder, and using an agent
89
+ * through its chat, its schedules and its MCP address so #143 gave the agent its own wording.
90
+ * With one meaning left, a per-kind branch would be a fork nothing takes.
91
91
  */
92
- export function verbHintKey(verb: ResourceVerb, kind: NodeKind): string {
93
- return verb === "execute" && kind === "agent"
94
- ? "node.verbHint.execute.agent"
95
- : `node.verbHint.${verb}`;
92
+ export function verbHintKey(verb: ResourceVerb): string {
93
+ return `node.verbHint.${verb}`;
96
94
  }
97
95
 
98
96
  // ⚠️ The same reading `moveErrorKey` does for a move, for the changes this menu makes. A conflict is
@@ -163,6 +161,10 @@ export function ResourceMenu({
163
161
  // link anywhere. Both entries are therefore absent on one rather than offered and inert.
164
162
  const retrievable = node !== null && node.kind !== "folder";
165
163
  const linkable = retrievable;
164
+ // ⚠️ Absent, not disabled — the rule at the top of this file. An import needs a folder to file
165
+ // into; a document has nowhere to put a subtree, and a greyed-out entry would promise it does.
166
+ const importable = node !== null && node.kind === "folder";
167
+ const bundleImport = useBundleImport({ where: title });
166
168
 
167
169
  // Everything a change here can make stale. The row sits in one level of the tree, the open screen
168
170
  // reads the record, and the flat collections behind the search, the link picker and the relation
@@ -299,6 +301,26 @@ export function ResourceMenu({
299
301
  <FolderDown aria-hidden="true" />
300
302
  {i18n.t("resource.export")}
301
303
  </DropdownMenuItem>
304
+ {/* The other half of the same round trip, next to it rather than in the tree's plus
305
+ (#346): one word in the menu, both sources under it. */}
306
+ {importable ? (
307
+ <DropdownMenuSub>
308
+ <DropdownMenuSubTrigger>
309
+ <FolderUp aria-hidden="true" />
310
+ {i18n.t("resource.import")}
311
+ </DropdownMenuSubTrigger>
312
+ <DropdownMenuSubContent>
313
+ <DropdownMenuItem onSelect={() => bundleImport.start(id, "importZip")}>
314
+ <FileArchive aria-hidden="true" />
315
+ {i18n.t("tree.new.importZip")}
316
+ </DropdownMenuItem>
317
+ <DropdownMenuItem onSelect={() => bundleImport.start(id, "importFolder")}>
318
+ <FolderUp aria-hidden="true" />
319
+ {i18n.t("tree.new.importFolder")}
320
+ </DropdownMenuItem>
321
+ </DropdownMenuSubContent>
322
+ </DropdownMenuSub>
323
+ ) : null}
302
324
  {/* Only a flow can be run, so only a flow can be asked whether it would. Rarely needed —
303
325
  which is why it is in the menu and not in the title line (#72). */}
304
326
  {node === null ? (
@@ -344,6 +366,10 @@ export function ResourceMenu({
344
366
  {/* Its own sentence, not `resourceErrorKey`'s: nothing was changed, something failed to
345
367
  arrive, and "the change was not saved" would send the reader looking for a change. */}
346
368
  {exportBundle.isError ? <MenuFailure>{i18n.t("resource.exportFailed")}</MenuFailure> : null}
369
+ {/* The import's own sentence, and its own pickers — only where the entry exists, because two
370
+ file inputs on every document's menu would be two elements nothing can ever open. */}
371
+ {bundleImport.isError ? <MenuFailure>{i18n.t("tree.importFailed")}</MenuFailure> : null}
372
+ {importable ? bundleImport.inputs : null}
347
373
  {/* ⚠️ A move has four refusals of its own and they are told apart by `moveErrorKey`, not by
348
374
  `resourceErrorKey`: "that is not a folder" and "that would be a loop" have no equivalent
349
375
  among the changes above, and one shared sentence would leave the reader guessing. */}
@@ -696,9 +722,7 @@ function SharePanel({ node, close }: { node: Node; close(): void }) {
696
722
  className="size-4 rounded border outline-none focus-visible:ring-2 focus-visible:ring-ring"
697
723
  />
698
724
  <span>{i18n.t(`node.verb.${verb}`)}</span>
699
- <span className="text-xs text-muted-foreground">
700
- {i18n.t(verbHintKey(verb, node.kind))}
701
- </span>
725
+ <span className="text-xs text-muted-foreground">{i18n.t(verbHintKey(verb))}</span>
702
726
  </label>
703
727
  ))}
704
728
  </fieldset>
@@ -5,8 +5,8 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
5
5
  * An explanation behind an ⓘ, beside the thing it explains (#204, shared since #249).
6
6
  *
7
7
  * It exists for the sentence that answers "what IS this" — read once, then furniture. Not for the
8
- * sentence you need WHILE you type: `agent.cronHint` ("five fields: minute, hour, …") deliberately
9
- * stays a visible paragraph, because a reference you read while writing must not be behind a hover.
8
+ * sentence you need WHILE you type: a reference read while writing must not be behind a hover, so
9
+ * something like a field's format ("five fields: minute, hour, …") stays a visible paragraph.
10
10
  * That is the dividing line: *what is this* may hide, *how do I write this* may not.
11
11
  *
12
12
  * ⚠️ The hint is the trigger's accessible NAME, not only the tooltip's content: Radix describes the
@@ -11,47 +11,30 @@ import { ResourceMenu, type ResourceTarget } from "@/resource-menu/resource-menu
11
11
  * the rule could be forgotten. That is the whole point: the menu holds rename, move, share and
12
12
  * archive, and a position one has to look for is a position one stops using.
13
13
  *
14
- * The three slots are for what belongs to this thing but lives further down the tree, where its
15
- * state is. `title-meta` is a quiet word beside the name (a table's row count); `title-byline` is a
16
- * quiet line under it (who made an agent, #258); `title-actions` is a button in the group (saving a
17
- * document, exporting a table). All are filled through `ActionSlot`, and the two in the group sit
18
- * before the menu for the same reason `children` do.
14
+ * The two slots are for what belongs to this thing but lives further down the tree, where its
15
+ * state is. `title-meta` is a quiet word beside the name (a table's row count); `title-actions` is a
16
+ * button in the group (saving a document, exporting a table). Both are filled through `ActionSlot`,
17
+ * and the one in the group sits before the menu for the same reason `children` do.
19
18
  */
20
19
  export function TitleRow({
21
20
  title,
22
21
  description,
23
22
  target,
24
- leading,
25
- divider = true,
26
23
  children,
27
24
  }: {
28
25
  title: string;
29
26
  description?: string | null;
30
27
  target: ResourceTarget;
31
- // ⚠️ A picture before the name, for the one kind that has a face: an agent's initials (#143). A
32
- // prop rather than another slot because it must sit BEFORE the title, and a portal can only
33
- // append — the same limitation that gave the shell two boxes instead of one list (#56).
34
- leading?: React.ReactNode;
35
- // Off for the one kind whose next line is its own tab bar: two rules a few pixels apart read as
36
- // clutter, so the agent page keeps only the tab bar's (#204).
37
- divider?: boolean;
38
28
  children?: React.ReactNode;
39
29
  }) {
40
30
  return (
41
- <div
42
- className={`flex items-start justify-between gap-5 px-6 py-4${divider ? " border-b" : ""}`}
43
- >
31
+ <div className="flex items-start justify-between gap-5 border-b px-6 py-4">
44
32
  <div className="flex min-w-0 items-center gap-3">
45
- {leading}
46
33
  <div className="min-w-0">
47
34
  <div className="flex min-w-0 flex-wrap items-baseline gap-x-3">
48
35
  <h2 className="truncate text-lg font-semibold">{title}</h2>
49
36
  <span data-slot="title-meta" className="text-sm text-muted-foreground" />
50
37
  </div>
51
- {/* ⚠️ `empty:hidden` is the whole rule for "no line rather than an empty one": nothing is
52
- portalled in here for most kinds, and a slot that kept its margin would make every
53
- head a few pixels taller for a line nobody wrote (#258). */}
54
- <span data-slot="title-byline" className="mt-0.5 block min-w-0 empty:hidden" />
55
38
  {description ? <p className="mt-1 text-sm text-muted-foreground">{description}</p> : null}
56
39
  </div>
57
40
  </div>
@@ -1,4 +1,4 @@
1
- import { serverOf, type ToolCapability, type ToolServer } from "@anchrd/intel-contract";
1
+ import { serverOf, type ToolCapability, type ToolServer } from "@anchrd/intel-contract/tool";
2
2
  import { useQuery } from "@tanstack/react-query";
3
3
  import { useRouterState } from "@tanstack/react-router";
4
4
  import { AlertTriangle, ChevronRight, PlugZap, ShieldOff, Timer, Wrench } from "lucide-react";
@@ -86,8 +86,6 @@ export function Tools() {
86
86
  const catalog = useQuery({ queryKey: ["tools"], queryFn: () => data.listTools() });
87
87
  // The servers behind that same live list (D30). Its own query, and its own failure: a portal that
88
88
  // answers tools but no directory still has a usable screen, it just has no boxes to put them in.
89
- // The key is the one the agent profile already reads the directory under, so a visit to either
90
- // screen serves the other from cache.
91
89
  // ⚠️ No retry on this one, and that is a decision about the screen rather than about the request.
92
90
  // The tools are held back until the directory has answered, so every further attempt is time
93
91
  // somebody spends in front of the word "Loading" — and a retry does not even have to happen: a
package/vite.config.ts CHANGED
@@ -12,10 +12,6 @@ export default defineConfig({
12
12
  "/auth": { target: "http://localhost:8788", changeOrigin: true },
13
13
  "/mcp": { target: "http://localhost:8788", changeOrigin: true },
14
14
  "/.well-known": { target: "http://localhost:8788", changeOrigin: true },
15
- // ⚠️ There is deliberately no `/agents` entry any more (#178). The browser reaches the agent
16
- // runtime through intel, which forwards over its `AGENT` service binding — so in development
17
- // the runtime still has to be running (`workers/agent`, port 8789, started separately from
18
- // `bun run dev`), but Vite proxies nothing to it: wrangler's dev registry connects the two.
19
15
  },
20
16
  },
21
17
  });
@@ -1,34 +0,0 @@
1
- import { Avatar, AvatarFallback } from "@/components/ui/avatar";
2
-
3
- /**
4
- * The first letters of the words in a name, at most two.
5
- *
6
- * ⚠️ `Intl.Segmenter` rather than `slice(0, 1)`: an agent may be called "Émile" or "🛰 Radar", and
7
- * a code unit is not a character. Slicing a surrogate pair in half puts a replacement glyph where
8
- * the initial should be.
9
- */
10
- export function initialsOf(title: string): string {
11
- const words = title.trim().split(/\s+/).filter(Boolean).slice(0, 2);
12
- const segmenter = new Intl.Segmenter();
13
- return words
14
- .map((word) => [...segmenter.segment(word)][0]?.segment ?? "")
15
- .join("")
16
- .toLocaleUpperCase();
17
- }
18
-
19
- /**
20
- * Who the agent is, as a picture.
21
- *
22
- * ⚠️ Decorative on purpose: the name it abbreviates stands right beside it in the title line, so a
23
- * screen reader that announced both would read the same name twice — once spelled out letter by
24
- * letter. The picture is the redundant half here, not the text.
25
- */
26
- export function AgentAvatar({ title }: { title: string }) {
27
- return (
28
- <Avatar aria-hidden="true" className="size-9">
29
- <AvatarFallback className="bg-primary/10 text-sm font-semibold text-primary">
30
- {initialsOf(title)}
31
- </AvatarFallback>
32
- </Avatar>
33
- );
34
- }
@@ -1,160 +0,0 @@
1
- import type { AgentDefinition } from "@anchrd/intel-contract";
2
- import { CalendarClock } from "lucide-react";
3
- import { nextCronFires } from "@/agent/agent-cron/agent-cron.ts";
4
- import { useI18n } from "@/i18n/i18n-context.tsx";
5
- import { SectionHint } from "@/section-hint/section-hint.tsx";
6
- import { useChosenTimezone } from "@/timezone/timezone-context.tsx";
7
-
8
- // How far ahead one schedule is shown. Enough to recognise a rhythm — daily, weekly, the first of
9
- // the month — and not so much that the page becomes a year planner nobody asked for.
10
- const PerSchedule = 5;
11
-
12
- /**
13
- * What this agent is going to do, and when.
14
- *
15
- * ⚠️ Derived, never asked. The runtime arms one alarm at a time and publishes neither it nor a
16
- * calendar, so these times are this page's own reading of the same cron expressions — by the same
17
- * rules, pinned by `agent-cron.unit.ts` against the runtime's test.
18
- *
19
- * ⚠️ Each entry is drawn in ITS OWN schedule's timezone and names it (#228). Not in the reader's:
20
- * the agent fires at the hour its schedule says, and an instant shown in the zone of whoever is
21
- * looking would be a different hour from the one the agent keeps — a difference that only ever
22
- * surfaces as "it ran at the wrong time". And not one zone for the whole list either, because two
23
- * schedules of one agent may sit in different ones.
24
- *
25
- * ⚠️ The reader's own reading is ADDED beside it, never in its place (#256). The schedule's time is
26
- * the leading one because it is the reason the agent runs; the second is a reading aid, smaller and
27
- * behind it. A calendar that converted everything to the reader would have undone #228 with a
28
- * feature request.
29
- *
30
- * ⚠️ And only for a zone somebody SET. `useChosenTimezone` answers `null` where nothing was chosen,
31
- * and then this screen is exactly what it was — no browser guess dressed up as "your time", and no
32
- * second time on every row for a reader who never asked for one.
33
- *
34
- * ⚠️ A paused agent gets a sentence over the list, because this is the tab somebody opens to find
35
- * out WHETHER something runs (#203). Without it two places on one page said opposite things: the
36
- * header read `Paused · no schedule fires`, and this list went on naming the next five times as if
37
- * they would happen. The list is kept rather than emptied — what the agent WOULD do is still the
38
- * answer to "what did I switch off" — but it may not read as a plan.
39
- */
40
- export function AgentCalendar({
41
- definition,
42
- now,
43
- paused,
44
- }: {
45
- definition: AgentDefinition | null;
46
- // Handed in rather than read from the clock, so the view is a function of its input and a test
47
- // does not have to travel in time to assert on it.
48
- now: Date;
49
- // ⚠️ Handed in for the same reason, and not read from `useAgentState` here: that hook needs the
50
- // router context and a query client, and reaching for it would turn every test of this view into
51
- // an integration test of two providers. The caller already holds the state for the header.
52
- paused: boolean;
53
- }) {
54
- const i18n = useI18n();
55
- const mine = useChosenTimezone();
56
- const schedules = definition?.schedules ?? [];
57
- const upcoming = schedules
58
- // ⚠️ The schedule's own position rides along, and it is the only thing that separates two
59
- // LITERALLY identical entries (#323). Two schedules with the same cron, zone and target are a
60
- // definition somebody can save, and every other part of the key would be equal for them —
61
- // including the fire time, which is computed from exactly those three.
62
- .flatMap((schedule, position) =>
63
- nextCronFires(schedule.cron, now, PerSchedule, schedule.timezone).map((at) => ({
64
- at,
65
- schedule,
66
- position,
67
- })),
68
- )
69
- .sort((left, right) => left.at.getTime() - right.at.getTime());
70
-
71
- // One formatter per zone rather than one per row: a DateTimeFormat is expensive to build, and a
72
- // calendar of five entries per schedule would otherwise build one for each of them.
73
- const formatters = new Map<string, Intl.DateTimeFormat>();
74
- const formatIn = (zone: string, at: Date) => {
75
- let format = formatters.get(zone);
76
- if (!format) {
77
- format = new Intl.DateTimeFormat(i18n.locale, {
78
- dateStyle: "full",
79
- timeStyle: "short",
80
- timeZone: zone,
81
- });
82
- formatters.set(zone, format);
83
- }
84
- return format.format(at);
85
- };
86
-
87
- // Shorter than the leading time on purpose: it is a reading aid, not a second headline. The date
88
- // rides along because a schedule in Tokyo is regularly the reader's previous day.
89
- const myFormat =
90
- mine === null
91
- ? null
92
- : new Intl.DateTimeFormat(i18n.locale, {
93
- dateStyle: "medium",
94
- timeStyle: "short",
95
- timeZone: mine,
96
- });
97
-
98
- return (
99
- <section
100
- aria-label={i18n.t("agent.calendar")}
101
- className="min-h-0 flex-1 overflow-y-auto px-6 py-5"
102
- >
103
- {/* ⚠️ Behind the ⓘ rather than as a paragraph (#256): it was the longest text on the tab and
104
- it explained, on every visit, a rule the list itself shows. The heading is what the hint
105
- now hangs off — the same shape the profile's sections have. */}
106
- <div className="flex items-center gap-1.5">
107
- <h3 className="text-sm font-semibold">{i18n.t("agent.calendar")}</h3>
108
- <SectionHint hint={i18n.t("agent.calendarZones")} />
109
- </div>
110
- {/* ⚠️ An agent with no schedule shows an empty calendar and nothing else (#254). The sentence
111
- that used to stand here described the emptiness the reader is already looking at; the two
112
- other states below are different, because each of them is a fact the list cannot show. */}
113
- {/* ⚠️ Only where there is a list to qualify. A paused agent WITHOUT schedules shows the same
114
- empty calendar it always did — the two states have to stay distinguishable, and a sentence
115
- about times that do not happen, over no times at all, describes nothing. Same reason it
116
- does not appear over `calendarUnreadable`: that one already says nothing is planned. */}
117
- {paused && upcoming.length > 0 ? (
118
- <p role="status" className="mt-4 rounded-lg border bg-muted px-4 py-3 text-sm">
119
- {i18n.t("agent.calendarPaused")}
120
- </p>
121
- ) : null}
122
- {schedules.length === 0 ? null : upcoming.length === 0 ? (
123
- // Schedules exist and none of them fires: every expression is unusable. A different answer
124
- // from "no schedules", because it is a different problem and needs a different fix.
125
- <p role="alert" className="mt-4 text-sm text-destructive">
126
- {i18n.t("agent.calendarUnreadable")}
127
- </p>
128
- ) : (
129
- <ol className="mt-4 space-y-2">
130
- {upcoming.map((entry) => (
131
- <li
132
- key={`${entry.position}:${entry.schedule.cron}@${entry.schedule.timezone}:${entry.schedule.target.id}:${entry.at.toISOString()}`}
133
- className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3 text-sm"
134
- >
135
- <CalendarClock aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
136
- <time dateTime={entry.at.toISOString()} className="font-medium">
137
- {formatIn(entry.schedule.timezone, entry.at)}
138
- </time>
139
- {/* The zone stands next to the time, not once above the list: without it the same
140
- wall-clock hour from two schedules reads as a duplicate. */}
141
- <span className="text-xs text-muted-foreground">{entry.schedule.timezone}</span>
142
- {/* ⚠️ Nothing at all where the two zones agree (#256). For the ordinary reader — one
143
- zone, their own — the row must not grow a second time saying what the first
144
- already says. */}
145
- {myFormat && mine !== entry.schedule.timezone ? (
146
- <span className="text-xs text-muted-foreground">
147
- {i18n.t("agent.calendarYourTime", { when: myFormat.format(entry.at) })}
148
- </span>
149
- ) : null}
150
- <code className="rounded bg-muted px-2 py-0.5 text-xs">{entry.schedule.cron}</code>
151
- <span className="text-xs text-muted-foreground">
152
- {i18n.t(`node.kind.${entry.schedule.target.kind}`)}
153
- </span>
154
- </li>
155
- ))}
156
- </ol>
157
- )}
158
- </section>
159
- );
160
- }
@@ -1,118 +0,0 @@
1
- import {
2
- AssistantRuntimeProvider,
3
- ComposerPrimitive,
4
- ErrorPrimitive,
5
- MessagePrimitive,
6
- ThreadPrimitive,
7
- } from "@assistant-ui/react";
8
- import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/react-ai-sdk";
9
- import { ArrowDown, Send } from "lucide-react";
10
- import { useMemo } from "react";
11
- import { useI18n } from "@/i18n/i18n-context.tsx";
12
- import { useIntelRouterContext } from "@/router/router-context.ts";
13
-
14
- /**
15
- * The built-in chat, against the agent runtime's own SSE endpoint.
16
- *
17
- * ⚠️ assistant-ui rather than a hand-rolled reader, and the reason is the wire: the runtime already
18
- * frames its answer as a Vercel AI UI message stream (`x-vercel-ai-ui-message-stream: v1`) with
19
- * `text-delta`, `tool-input-available` and `tool-output-available` parts. That format has a client,
20
- * and writing a second one would mean re-implementing tool-call rendering and stream recovery to
21
- * arrive at the same place.
22
- *
23
- * ⚠️ The endpoint is on INTEL, which forwards the turn to the runtime with the caller's own Gate
24
- * token (#178). That is what `credentials: "include"` is carrying: the intel session cookie, on the
25
- * origin this page came from. The runtime still demands `agents/run` — it just hears it from intel
26
- * rather than from a bearer this page would have had to hold.
27
- */
28
- export function AgentChat({ agentId }: { agentId: string }) {
29
- const { data } = useIntelRouterContext();
30
- const i18n = useI18n();
31
- // ⚠️ Built once per agent. A transport rebuilt on every render restarts the runtime underneath
32
- // assistant-ui and takes the open thread with it.
33
- const transport = useMemo(
34
- () =>
35
- new AssistantChatTransport({
36
- api: data.agentChatApi(agentId),
37
- credentials: "include",
38
- }),
39
- [data, agentId],
40
- );
41
- const runtime = useChatRuntime({ transport });
42
-
43
- return (
44
- <AssistantRuntimeProvider runtime={runtime}>
45
- <ThreadPrimitive.Root className="flex min-h-0 flex-1 flex-col bg-card">
46
- <ThreadPrimitive.Viewport
47
- autoScroll
48
- className="relative flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-6 py-5"
49
- >
50
- <ThreadPrimitive.Empty>
51
- <p className="m-auto max-w-md text-center text-sm text-muted-foreground">
52
- {i18n.t("agent.chatEmpty")}
53
- </p>
54
- </ThreadPrimitive.Empty>
55
- <ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
56
- <ThreadPrimitive.ScrollToBottom asChild>
57
- <button
58
- type="button"
59
- aria-label={i18n.t("agent.chatScrollDown")}
60
- className="sticky bottom-0 z-10 mx-auto inline-flex size-8 items-center justify-center rounded-full border bg-background shadow-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:invisible"
61
- >
62
- <ArrowDown aria-hidden="true" className="size-4" />
63
- </button>
64
- </ThreadPrimitive.ScrollToBottom>
65
- </ThreadPrimitive.Viewport>
66
- <ComposerPrimitive.Root className="flex items-end gap-2 border-t px-6 py-4">
67
- <ComposerPrimitive.Input
68
- rows={1}
69
- autoFocus
70
- aria-label={i18n.t("agent.chatInput")}
71
- placeholder={i18n.t("agent.chatPlaceholder")}
72
- className="max-h-40 min-h-9 flex-1 resize-none rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
73
- />
74
- <ComposerPrimitive.Send asChild>
75
- <button
76
- type="button"
77
- aria-label={i18n.t("agent.chatSend")}
78
- className="inline-flex size-9 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
79
- >
80
- <Send aria-hidden="true" className="size-4" />
81
- </button>
82
- </ComposerPrimitive.Send>
83
- </ComposerPrimitive.Root>
84
- </ThreadPrimitive.Root>
85
- </AssistantRuntimeProvider>
86
- );
87
- }
88
-
89
- function UserMessage() {
90
- return (
91
- <MessagePrimitive.Root className="flex justify-end">
92
- <div className="max-w-[80%] rounded-2xl bg-primary px-4 py-2 text-sm text-primary-foreground">
93
- <MessagePrimitive.Parts />
94
- </div>
95
- </MessagePrimitive.Root>
96
- );
97
- }
98
-
99
- function AssistantMessage() {
100
- return (
101
- <MessagePrimitive.Root className="flex justify-start">
102
- <div className="max-w-[80%] space-y-2 text-sm">
103
- <MessagePrimitive.Parts />
104
- {/* The runtime reports a failed turn as an `error` part on a 200 stream, so a refusal shows
105
- up here rather than as a request that failed. It is worded by the runtime, which words
106
- it to be read (see `packages/agent`'s guard on model-facing errors). */}
107
- <MessagePrimitive.Error>
108
- <ErrorPrimitive.Root
109
- role="alert"
110
- className="rounded-md border border-destructive/40 px-3 py-2 text-destructive"
111
- >
112
- <ErrorPrimitive.Message />
113
- </ErrorPrimitive.Root>
114
- </MessagePrimitive.Error>
115
- </div>
116
- </MessagePrimitive.Root>
117
- );
118
- }
@@ -1,95 +0,0 @@
1
- import type { AgentCosts, AgentCostWindow } from "@anchrd/intel-contract";
2
- import { type UseQueryResult, useQuery } from "@tanstack/react-query";
3
- import type { AgentRun, AgentUsage } from "@/data/agent-runtime/agent-runtime.ts";
4
- import { useIntelRouterContext } from "@/router/router-context.ts";
5
-
6
- /**
7
- * What this agent has cost, out of Cloudflare's AI Gateway log (#251).
8
- *
9
- * ⚠️ `retry: false`, and the run list beside it keeps its retries. These figures ENRICH a screen
10
- * that is already answered — the runs and their tokens come from the runtime and never depended on
11
- * Cloudflare — and a retry is paused while the tab is unfocused (#212), so a list that waited on
12
- * this one could sit on "Loading" for as long as the reader is looking elsewhere.
13
- */
14
- export function useAgentCosts(agentId: string): UseQueryResult<AgentCosts> {
15
- const { data } = useIntelRouterContext();
16
- return useQuery({
17
- queryKey: ["agent-costs", agentId],
18
- queryFn: () => data.getAgentCosts(agentId),
19
- retry: false,
20
- });
21
- }
22
-
23
- export function costWindow(costs: AgentCosts | undefined, days: number): AgentCostWindow | null {
24
- return costs?.windows.find((window) => window.days === days) ?? null;
25
- }
26
-
27
- /**
28
- * What one run cost, or why that is not known.
29
- *
30
- * ⚠️ Four outcomes and not two, because "nothing" and "not known" are different answers and only one
31
- * of them is about money. A gateway that was never configured, a gateway that did not answer, and a
32
- * run the gateway has no line for all mean "not known" — and rendering any of them as `$0.00` says
33
- * the agent was free, which is the failure this ticket exists to remove.
34
- */
35
- export type RunCost =
36
- | { kind: "known"; cost: number; calls: number }
37
- | { kind: "not_configured" }
38
- | { kind: "unreadable" }
39
- | { kind: "missing" };
40
-
41
- export function runCost(costs: AgentCosts | undefined, runId: string): RunCost {
42
- if (!costs || costs.status === "unreadable") return { kind: "unreadable" };
43
- if (costs.status === "not_configured") return { kind: "not_configured" };
44
- const found = costs.runs.find((run) => run.runId === runId);
45
- // ⚠️ A run with no line is `missing`, never zero. Every run before #251 is one, and so is any run
46
- // whose window has scrolled past the read's page limit.
47
- return found ? { kind: "known", cost: found.cost, calls: found.calls } : { kind: "missing" };
48
- }
49
-
50
- /**
51
- * What one run consumed, or why that is not known either.
52
- *
53
- * ⚠️ All four counters at zero is read as "the provider reported nothing", NOT as "no tokens". A
54
- * completed model call always consumed input tokens, so four zeroes cannot honestly mean zero — and
55
- * Workers AI reports no usage at all, so every Workers AI run looks exactly like this. Showing them
56
- * as `0 in, 0 out` would put a false and very reassuring number on the screen.
57
- */
58
- export function runUsage(run: AgentRun): AgentUsage | null {
59
- const usage = run.usage;
60
- if (!usage) return null;
61
- const total =
62
- usage.inputTokens +
63
- usage.outputTokens +
64
- usage.cacheCreationInputTokens +
65
- usage.cacheReadInputTokens;
66
- return total > 0 ? usage : null;
67
- }
68
-
69
- /**
70
- * Money, with enough decimals to still be money.
71
- *
72
- * ⚠️ Four decimals below a dollar, not two. A single model call costs $0.0565 — at two decimals it
73
- * is "$0.06", and two runs that differ by half again read identically. Above a dollar the extra
74
- * digits are noise and are dropped.
75
- *
76
- * ⚠️ A cost that is real but smaller than the smallest digit shown gets a "<", never a rounded
77
- * "$0.00". The whole point of the figure is that nothing is silently free.
78
- */
79
- export function formatCost(dollars: number, locale: string): string {
80
- const digits = Math.abs(dollars) >= 1 ? 2 : 4;
81
- const format = (value: number) =>
82
- value.toLocaleString(locale, {
83
- style: "currency",
84
- currency: "USD",
85
- minimumFractionDigits: 2,
86
- maximumFractionDigits: digits,
87
- });
88
- if (dollars > 0 && dollars < 0.0001) return `< ${format(0.0001)}`;
89
- return format(dollars);
90
- }
91
-
92
- /** Token counts, short. 56 473 is "56.5K" — the exact figure helps nobody and costs the eye a line. */
93
- export function formatTokens(tokens: number, locale: string): string {
94
- return tokens.toLocaleString(locale, { notation: "compact", maximumFractionDigits: 1 });
95
- }