@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.
- package/package.json +2 -9
- package/src/app/app-tree/app-tree.tsx +68 -147
- package/src/app/header-search/header-search.tsx +2 -1
- package/src/app/reindex-dialog/reindex-dialog.tsx +79 -0
- package/src/app/settings-dialog/settings-dialog.tsx +10 -32
- package/src/app/user-footer/user-footer.tsx +19 -0
- package/src/archive/archive.tsx +2 -1
- package/src/data/intel-data-provider/intel-data-provider.ts +42 -191
- package/src/data/intel-data-provider/intel-data-provider.types.ts +36 -120
- package/src/document-link/document-link.tsx +1 -1
- package/src/entry-picker/entry-picker.tsx +7 -7
- package/src/flow-runs/flow-runs.tsx +1 -1
- package/src/flows/flows.tsx +3 -2
- package/src/flows/node-icon/node-icon.ts +1 -1
- package/src/flows/node-palette/node-palette.tsx +1 -1
- package/src/flows/node-palette/node-palette.types.ts +1 -1
- package/src/graph-pane/graph-pane.tsx +1 -1
- package/src/i18n/de.json +14 -217
- package/src/i18n/en.json +14 -217
- package/src/i18n/es.json +14 -217
- package/src/import-bundle/import-bundle.tsx +152 -0
- package/src/kind-icon.ts +1 -15
- package/src/main.tsx +7 -10
- package/src/node-editor/node-editor.tsx +1 -1
- package/src/node-graph/graph-notice.tsx +1 -1
- package/src/node-graph/node-graph.ts +1 -1
- package/src/node-graph/node-graph.tsx +1 -1
- package/src/node-table/node-table.tsx +3 -3
- package/src/nodes/nodes.tsx +1 -35
- package/src/resource-menu/resource-menu.tsx +45 -21
- package/src/section-hint/section-hint.tsx +2 -2
- package/src/title-row/title-row.tsx +5 -22
- package/src/tools/tools.tsx +1 -3
- package/vite.config.ts +0 -4
- package/src/agent/agent-avatar/agent-avatar.tsx +0 -34
- package/src/agent/agent-calendar/agent-calendar.tsx +0 -160
- package/src/agent/agent-chat/agent-chat.tsx +0 -118
- package/src/agent/agent-costs/agent-costs.ts +0 -95
- package/src/agent/agent-cron/agent-cron.ts +0 -68
- package/src/agent/agent-definition/agent-definition.ts +0 -96
- package/src/agent/agent-delegation-notice/agent-delegation-notice.ts +0 -48
- package/src/agent/agent-entry-title/agent-entry-title.ts +0 -72
- package/src/agent/agent-log/agent-log.tsx +0 -308
- package/src/agent/agent-models/agent-models.ts +0 -166
- package/src/agent/agent-models/agent-models.types.ts +0 -24
- package/src/agent/agent-profile/agent-profile.tsx +0 -1006
- package/src/agent/agent-state/agent-state.ts +0 -85
- package/src/agent/agent-status-dot/agent-status-dot.ts +0 -73
- package/src/agent/agent.tsx +0 -507
- package/src/board/board-calendar/board-calendar.tsx +0 -89
- package/src/board/board-card/board-card.tsx +0 -106
- package/src/board/board-data/board-data.ts +0 -241
- package/src/board/board-data/board-data.types.ts +0 -63
- package/src/board/board-detail/board-detail.tsx +0 -629
- package/src/board/board-gantt/board-gantt.ts +0 -545
- package/src/board/board-gantt/board-gantt.tsx +0 -286
- package/src/board/board-graph/board-graph.ts +0 -174
- package/src/board/board-graph/board-graph.tsx +0 -168
- package/src/board/board-items/board-items.ts +0 -183
- package/src/board/board-kanban/board-kanban.ts +0 -137
- package/src/board/board-kanban/board-kanban.tsx +0 -257
- package/src/board/board-status/board-status.ts +0 -59
- package/src/board/board-statuses/board-statuses.ts +0 -63
- package/src/board/board-statuses/board-statuses.tsx +0 -228
- package/src/board/board-table/board-table.ts +0 -33
- package/src/board/board-table/board-table.tsx +0 -413
- package/src/board/board-views/board-views.tsx +0 -68
- package/src/board/board-views/board-views.types.ts +0 -29
- package/src/board/board.tsx +0 -251
- package/src/components/ui/item-calendar.tsx +0 -181
- package/src/components/ui/item-gantt.tsx +0 -463
- package/src/components/ui/kanban.tsx +0 -282
- package/src/data/agent-runtime/agent-runtime.ts +0 -131
- package/src/hooks/use-capabilities.ts +0 -22
- package/src/hooks/use-model-catalog.ts +0 -26
- package/src/timezone/timezone-combobox/timezone-combobox.tsx +0 -149
- package/src/timezone/timezone-context.tsx +0 -65
- package/src/timezone/timezone.ts +0 -174
|
@@ -1,18 +1,15 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
85
|
+
* Which sentence explains a verb.
|
|
85
86
|
*
|
|
86
|
-
* ⚠️
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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
|
|
93
|
-
return verb
|
|
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:
|
|
9
|
-
*
|
|
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
|
|
15
|
-
* state is. `title-meta` is a quiet word beside the name (a table's row count); `title-
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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>
|
package/src/tools/tools.tsx
CHANGED
|
@@ -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
|
-
}
|