@anchrd/intel-ui 0.17.0 → 0.19.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 (63) hide show
  1. package/package.json +2 -9
  2. package/src/app/app-tree/app-tree.tsx +59 -147
  3. package/src/app/settings-dialog/settings-dialog.tsx +10 -32
  4. package/src/data/intel-data-provider/intel-data-provider.ts +0 -163
  5. package/src/data/intel-data-provider/intel-data-provider.types.ts +0 -100
  6. package/src/entry-picker/entry-picker.tsx +6 -6
  7. package/src/i18n/de.json +6 -213
  8. package/src/i18n/en.json +6 -213
  9. package/src/i18n/es.json +6 -213
  10. package/src/import-bundle/import-bundle.tsx +152 -0
  11. package/src/kind-icon.ts +1 -15
  12. package/src/main.tsx +7 -10
  13. package/src/node-table/node-table.tsx +5 -4
  14. package/src/nodes/nodes.tsx +0 -34
  15. package/src/resource-menu/resource-menu.tsx +68 -20
  16. package/src/section-hint/section-hint.tsx +2 -2
  17. package/src/title-row/title-row.tsx +5 -22
  18. package/src/tools/tools.tsx +0 -2
  19. package/vite.config.ts +0 -4
  20. package/src/agent/agent-avatar/agent-avatar.tsx +0 -34
  21. package/src/agent/agent-calendar/agent-calendar.tsx +0 -135
  22. package/src/agent/agent-chat/agent-chat.tsx +0 -118
  23. package/src/agent/agent-costs/agent-costs.ts +0 -95
  24. package/src/agent/agent-cron/agent-cron.ts +0 -68
  25. package/src/agent/agent-definition/agent-definition.ts +0 -96
  26. package/src/agent/agent-delegation-notice/agent-delegation-notice.ts +0 -48
  27. package/src/agent/agent-entry-title/agent-entry-title.ts +0 -72
  28. package/src/agent/agent-log/agent-log.tsx +0 -308
  29. package/src/agent/agent-models/agent-models.ts +0 -166
  30. package/src/agent/agent-models/agent-models.types.ts +0 -24
  31. package/src/agent/agent-profile/agent-profile.tsx +0 -988
  32. package/src/agent/agent-state/agent-state.ts +0 -85
  33. package/src/agent/agent-status-dot/agent-status-dot.ts +0 -73
  34. package/src/agent/agent.tsx +0 -482
  35. package/src/board/board-calendar/board-calendar.tsx +0 -89
  36. package/src/board/board-card/board-card.tsx +0 -106
  37. package/src/board/board-data/board-data.ts +0 -241
  38. package/src/board/board-data/board-data.types.ts +0 -63
  39. package/src/board/board-detail/board-detail.tsx +0 -629
  40. package/src/board/board-gantt/board-gantt.ts +0 -545
  41. package/src/board/board-gantt/board-gantt.tsx +0 -286
  42. package/src/board/board-graph/board-graph.ts +0 -174
  43. package/src/board/board-graph/board-graph.tsx +0 -168
  44. package/src/board/board-items/board-items.ts +0 -183
  45. package/src/board/board-kanban/board-kanban.ts +0 -97
  46. package/src/board/board-kanban/board-kanban.tsx +0 -211
  47. package/src/board/board-status/board-status.ts +0 -59
  48. package/src/board/board-statuses/board-statuses.ts +0 -63
  49. package/src/board/board-statuses/board-statuses.tsx +0 -228
  50. package/src/board/board-table/board-table.ts +0 -33
  51. package/src/board/board-table/board-table.tsx +0 -413
  52. package/src/board/board-views/board-views.tsx +0 -68
  53. package/src/board/board-views/board-views.types.ts +0 -29
  54. package/src/board/board.tsx +0 -251
  55. package/src/components/ui/item-calendar.tsx +0 -181
  56. package/src/components/ui/item-gantt.tsx +0 -463
  57. package/src/components/ui/kanban.tsx +0 -245
  58. package/src/data/agent-runtime/agent-runtime.ts +0 -131
  59. package/src/hooks/use-capabilities.ts +0 -22
  60. package/src/hooks/use-model-catalog.ts +0 -26
  61. package/src/timezone/timezone-combobox/timezone-combobox.tsx +0 -149
  62. package/src/timezone/timezone-context.tsx +0 -65
  63. package/src/timezone/timezone.ts +0 -174
@@ -1,85 +0,0 @@
1
- import { type UseQueryResult, useQuery } from "@tanstack/react-query";
2
- import type { AgentState } from "@/data/agent-runtime/agent-runtime.ts";
3
- import { IntelRequestError } from "@/data/intel-data-provider/intel-data-provider.ts";
4
- import { useIntelRouterContext } from "@/router/router-context.ts";
5
-
6
- export const agentStateKey = (agentId: string) => ["agent-state", agentId] as const;
7
-
8
- /**
9
- * Whether this agent is switched off, read from the runtime through intel.
10
- *
11
- * ⚠️ One query for the whole page, shared by the status line, the two head actions and the tabs
12
- * that have to say "not for you". It is also the page's PROBE: the answer to "may this reader drive
13
- * this agent" is only knowable by asking, because `/session` deliberately carries identity and no
14
- * capabilities (`SessionUser` — "no capabilities, no token"). So a 403 here is not a failure to
15
- * report but a fact to render.
16
- */
17
- export function useAgentState(agentId: string): UseQueryResult<AgentState> {
18
- const { data } = useIntelRouterContext();
19
- return useQuery({
20
- queryKey: agentStateKey(agentId),
21
- queryFn: () => data.getAgentState(agentId),
22
- // A refusal is an answer, and retrying it three times only delays the sentence that explains it.
23
- retry: false,
24
- });
25
- }
26
-
27
- /**
28
- * The refusal that is about permission rather than about something being broken.
29
- *
30
- * ⚠️ 403 and nothing else. A 401 has already sent the page to the sign-in, and a 503 means this
31
- * installation runs no agent runtime — reading either as "you may not" would tell somebody they
32
- * lack a permission they actually have.
33
- */
34
- export function agentAccessDenied(error: unknown): boolean {
35
- return error instanceof IntelRequestError && error.status === 403;
36
- }
37
-
38
- /**
39
- * The refusal that says this installation runs no agent runtime at all (#190).
40
- *
41
- * ⚠️ The code, not the 503: a runtime that exists but is down may answer 503 too, and reading that
42
- * as "this deployment has no agents" would explain a permanent absence to somebody who is looking
43
- * at an outage. Normally `/capabilities` has already said so and the page never asks — this is the
44
- * belt to that brace, for the probe that reached the door anyway.
45
- */
46
- export function agentRuntimeMissing(error: unknown): boolean {
47
- return error instanceof IntelRequestError && error.code === "agent_runtime_not_configured";
48
- }
49
-
50
- /**
51
- * The failure that has a repair path, and a name for it (D29, #207, and the short half of #200).
52
- *
53
- * ⚠️ The code, not the 500. An agent whose Durable Object holds no key starts no run at all — the
54
- * runtime refuses before Gate is asked — and until #207 that arrived on this page as
55
- * `That did not work. Try again.` next to `Run now`, which is advice that cannot work: trying again
56
- * produces the same refusal forever. Naming it is what lets the page send the reader to the one
57
- * thing that fixes it, which is replacing the key in the agent's profile.
58
- *
59
- * ⚠️ `agent_key_unreadable` counts as the same case on purpose. Its cause is different — the
60
- * deployment's master secret was replaced — but the reader's move is identical, and a second
61
- * sentence saying the same thing differently would only be a second thing to keep in step.
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
-
80
- export function agentKeyMissing(error: unknown): boolean {
81
- return (
82
- error instanceof IntelRequestError &&
83
- (error.code === "agent_key_missing" || error.code === "agent_key_unreadable")
84
- );
85
- }
@@ -1,73 +0,0 @@
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
- };
@@ -1,482 +0,0 @@
1
- import type { AgentDefinition, AgentScheduleTarget, Node } from "@anchrd/intel-contract";
2
- import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
- import { Pause, Play } from "lucide-react";
4
- import { useState } from "react";
5
- import { AgentCalendar } from "@/agent/agent-calendar/agent-calendar.tsx";
6
- import { AgentChat } from "@/agent/agent-chat/agent-chat.tsx";
7
- import { nextRunAt, useAgentDefinition } from "@/agent/agent-definition/agent-definition.ts";
8
- import { useEntryTitle } from "@/agent/agent-entry-title/agent-entry-title.ts";
9
- import { AgentLog } from "@/agent/agent-log/agent-log.tsx";
10
- import { AgentProfile } from "@/agent/agent-profile/agent-profile.tsx";
11
- import {
12
- agentAccessDenied,
13
- agentKeyMissing,
14
- agentProblemDetail,
15
- agentRuntimeMissing,
16
- agentStateKey,
17
- useAgentState,
18
- } from "@/agent/agent-state/agent-state.ts";
19
- import { agentStatus, statusDotClass } from "@/agent/agent-status-dot/agent-status-dot.ts";
20
- import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
21
- import {
22
- DropdownMenu,
23
- DropdownMenuContent,
24
- DropdownMenuItem,
25
- DropdownMenuTrigger,
26
- } from "@/components/ui/dropdown-menu";
27
- import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
28
- import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
29
- import { useCapabilities } from "@/hooks/use-capabilities.ts";
30
- import { useI18n } from "@/i18n/i18n-context.tsx";
31
- import { useIntelRouterContext } from "@/router/router-context.ts";
32
- import { useUserName } from "@/user-name/user-name.ts";
33
-
34
- const tabs = ["profile", "chat", "calendar", "log"] as const;
35
- type Tab = (typeof tabs)[number];
36
-
37
- /**
38
- * The agent's page: the lower of the shell's two bars, carrying this one agent's four views.
39
- *
40
- * ⚠️ The name, the menu, the status line and the two head actions all live in the SAME title line —
41
- * the one `TitleRow` draws for every open thing (#53). The status and the actions are portalled
42
- * into its slots rather than drawn again here, because an agent with a header of its own would be
43
- * the one kind of file whose rename sits somewhere else.
44
- *
45
- * ⚠️ One state query stands for the whole page, and it is also how the page learns whether this
46
- * reader may drive this agent at all: `/session` carries identity and no capabilities on purpose, so
47
- * a 403 from the runtime door is the only place the answer exists (#178). It is then rendered as an
48
- * answer — a sentence in the two tabs that would have needed it — rather than as a red failure.
49
- */
50
- export function AgentPanel({ node }: { node: Node }) {
51
- const i18n = useI18n();
52
- const [tab, setTab] = useState<Tab>("profile");
53
- const agent = useAgentDefinition(node.id);
54
- const state = useAgentState(node.id);
55
- const denied = agentAccessDenied(state.error);
56
- const capabilities = useCapabilities();
57
-
58
- // An installation without an agent runtime (#190): the node from an earlier deployment still
59
- // opens, but it explains itself instead of rendering four views whose every call would end in a
60
- // 503. `/capabilities` is the answer; the probe's own `agent_runtime_not_configured` is the belt
61
- // to that brace. ⚠️ Only an explicit `false` takes the page away — while the answer is still
62
- // loading the tabs stand, because tearing down a rendered page over a pending request would blank
63
- // the screen for every agent on every load.
64
- if (capabilities.data?.agentRuntime === false || agentRuntimeMissing(state.error)) {
65
- return <NoRuntime />;
66
- }
67
-
68
- return (
69
- <>
70
- <ActionSlot name="title-meta">
71
- <AgentStatus agentId={node.id} definition={agent.definition} />
72
- </ActionSlot>
73
- <ActionSlot name="title-byline">
74
- <AgentByline node={node} />
75
- </ActionSlot>
76
- <ActionSlot name="title-actions">
77
- <AgentActions agentId={node.id} definition={agent.definition} />
78
- </ActionSlot>
79
- <Tabs
80
- value={tab}
81
- onValueChange={(value) => setTab(value as Tab)}
82
- className="flex min-h-0 flex-1 flex-col gap-0"
83
- >
84
- {/* The file-specific bar. It stands under the title line and above the view it switches —
85
- the order the flow screen's own switch already uses (#19). */}
86
- <div className="border-b px-6 py-2">
87
- <TabsList aria-label={i18n.t("agent.tabs")}>
88
- {tabs.map((name) => (
89
- <TabsTrigger key={name} value={name}>
90
- {i18n.t(`agent.tab.${name}`)}
91
- </TabsTrigger>
92
- ))}
93
- </TabsList>
94
- </div>
95
- <TabsContent value="profile" className="flex min-h-0 flex-col">
96
- <AgentProfile node={node} agent={agent} />
97
- </TabsContent>
98
- {/* ⚠️ Each of the three below is mounted only while its own tab is open. The chat is the
99
- reason: it holds a live SSE connection, and one kept open behind three other views is a
100
- stream nobody is reading. The other two follow so that a switch back re-reads rather
101
- than showing a run list from ten minutes ago. */}
102
- <TabsContent value="chat" className="flex min-h-0 flex-col">
103
- {tab === "chat" ? denied ? <NotForYou /> : <AgentChat agentId={node.id} /> : null}
104
- </TabsContent>
105
- <TabsContent value="calendar" className="flex min-h-0 flex-col">
106
- {tab === "calendar" ? (
107
- <AgentCalendar definition={agent.definition} now={new Date()} />
108
- ) : null}
109
- </TabsContent>
110
- <TabsContent value="log" className="flex min-h-0 flex-col">
111
- {tab === "log" ? denied ? <NotForYou /> : <AgentLog agentId={node.id} /> : null}
112
- </TabsContent>
113
- </Tabs>
114
- </>
115
- );
116
- }
117
-
118
- /**
119
- * Who made this agent — the first question anybody asks about one that works in folders with rights
120
- * of its own (#258).
121
- *
122
- * ⚠️ Provenance and nothing else. An agent does NOT run with its creator's rights: it has a Gate
123
- * Application of its own (D29), the assertion's subject is the agent, and Intel's grants on the
124
- * tree decide what a run may read and write. Only the TOOLS run on a human's connection, and that
125
- * is `delegatedBy` — a different field, in a different place on this page, on purpose. So this line
126
- * says "by", never "runs as", and it carries no permission wording that would imply otherwise.
127
- *
128
- * ⚠️ `ownerId` and not the current version's `createdBy`: the owner is written once, from the actor
129
- * who created the node, and never again — while `createdBy` moves to whoever saved last, which is a
130
- * different fact and belongs to a different question.
131
- *
132
- * ⚠️ Unresolvable means NO line, not "by —". Most ids are unresolvable today (see `useUserName`),
133
- * and an em dash under every foreign agent's name would be a row of furniture saying nothing.
134
- */
135
- function AgentByline({ node }: { node: Node }) {
136
- const i18n = useI18n();
137
- const name = useUserName(node.ownerId);
138
- if (name === null) return null;
139
-
140
- return (
141
- <span className="block truncate text-xs text-muted-foreground" title={name}>
142
- {i18n.t("agent.createdBy", { name })}
143
- </span>
144
- );
145
- }
146
-
147
- /**
148
- * What somebody without `agents/run` sees instead of the chat and the log.
149
- *
150
- * ⚠️ Muted text, not `role="alert"` and not the destructive colour. Nothing is broken and there is
151
- * nothing to retry: they may read this agent, they may not drive it. A red box would send them to
152
- * whoever runs the installation over a working system.
153
- */
154
- function NotForYou() {
155
- const i18n = useI18n();
156
- return (
157
- <p className="m-auto max-w-md px-6 py-5 text-center text-sm text-muted-foreground">
158
- {i18n.t("agent.notPermitted")}
159
- </p>
160
- );
161
- }
162
-
163
- /**
164
- * What an agent node from an earlier deployment shows where no runtime is configured (#190).
165
- *
166
- * ⚠️ The same muted sentence as `NotForYou`, and for the same reason: nothing is broken and there
167
- * is nothing to retry. The node and its definition are intact; the installation simply does not run
168
- * agents, which is the operator's choice, not the reader's problem to debug.
169
- */
170
- function NoRuntime() {
171
- const i18n = useI18n();
172
- return (
173
- <p className="m-auto max-w-md px-6 py-5 text-center text-sm text-muted-foreground">
174
- {i18n.t("agent.noRuntime")}
175
- </p>
176
- );
177
- }
178
-
179
- /**
180
- * Whether the agent is working, whether it is switched off, and when it next will be — as a dot
181
- * beside the name, and only when there is something to say (#253).
182
- *
183
- * ⚠️ A dot and not a sentence, and NOT ALWAYS a dot. What stood here was "Idle · no scheduled run"
184
- * — the absence of a state, spelled out twice, at the most prominent place on the page. `null` from
185
- * `agentStatus` is the ordinary case and renders nothing at all.
186
- *
187
- * ⚠️ The colour is never the only information. The state is the trigger's accessible NAME, the dot
188
- * itself is `aria-hidden`, and the shapes differ as well as the hues — see `statusDotClass`. A
189
- * reader who cannot separate two greys, and every reader who is listening rather than looking, gets
190
- * the same answer as everybody else.
191
- *
192
- * ⚠️ "Running" is read from the runtime's own run list, and its absence is NOT read as "idle" — a
193
- * runtime that cannot be reached says nothing about what the agent is doing.
194
- *
195
- * ⚠️ "Next run" is derived from the cron expressions rather than asked. The runtime arms one alarm
196
- * and publishes neither it nor a schedule, so this is the same reading `AgentCalendar` does — and
197
- * while the agent is paused it is not shown at all, because a paused agent will not run then.
198
- */
199
- export function AgentStatus({
200
- agentId,
201
- definition,
202
- }: {
203
- agentId: string;
204
- definition: AgentDefinition | null;
205
- }) {
206
- const { data } = useIntelRouterContext();
207
- const i18n = useI18n();
208
- const state = useAgentState(agentId);
209
- const runs = useQuery({
210
- queryKey: ["agent-runs", agentId],
211
- queryFn: () => data.listAgentRuns(agentId),
212
- // A status dot is not worth a red screen: the runtime being unreachable is reported by the
213
- // Log tab, which is the view that is actually about runs.
214
- retry: false,
215
- });
216
- const next = nextRunAt(definition, new Date());
217
- const status = agentStatus({
218
- runsUnreadable: runs.isError,
219
- runs: runs.data?.items ?? [],
220
- paused: state.data?.paused ?? false,
221
- scheduled: next !== null,
222
- });
223
- if (status === null) return null;
224
-
225
- const when =
226
- status.kind === "paused"
227
- ? i18n.t("agent.pausedNoRuns")
228
- : next
229
- ? // ⚠️ Formatted in the SCHEDULE's zone and named with it (#228), not in the reader's.
230
- // The agent fires at the hour its schedule says; showing that instant in the zone of
231
- // whoever happens to be looking would put a different hour on screen from the one the
232
- // agent keeps, and the difference only surfaces as "it ran at the wrong time".
233
- i18n.t("agent.nextRun", {
234
- when: new Intl.DateTimeFormat(i18n.locale, {
235
- dateStyle: "medium",
236
- timeStyle: "short",
237
- timeZone: next.timezone,
238
- }).format(next.at),
239
- zone: next.timezone,
240
- })
241
- : null;
242
- // ⚠️ A past instant, so it is read in the reader's own zone and names none: unlike a schedule,
243
- // which fires in the zone it carries, a finished run happened at one moment for everybody.
244
- const last =
245
- status.lastRunAt === null
246
- ? null
247
- : i18n.t("agent.lastRun", {
248
- when: new Intl.DateTimeFormat(i18n.locale, {
249
- dateStyle: "medium",
250
- timeStyle: "short",
251
- }).format(new Date(status.lastRunAt)),
252
- });
253
- const label = [i18n.t(`agent.status.${status.kind}`), last, when].filter(Boolean).join(" · ");
254
-
255
- return (
256
- <TooltipProvider delayDuration={300}>
257
- <Tooltip>
258
- {/* A real button, so the dot is reached by Tab and opened without a pointer. */}
259
- <TooltipTrigger
260
- type="button"
261
- aria-label={label}
262
- className="inline-flex items-center rounded-full p-1 outline-none focus-visible:ring-2 focus-visible:ring-ring"
263
- >
264
- <span
265
- aria-hidden="true"
266
- className={`block size-2.5 rounded-full ${statusDotClass[status.kind]}`}
267
- />
268
- </TooltipTrigger>
269
- <TooltipContent className="max-w-xs">{label}</TooltipContent>
270
- </Tooltip>
271
- </TooltipProvider>
272
- );
273
- }
274
-
275
- /**
276
- * Pause, and run now.
277
- *
278
- * ⚠️ Both are shown even where they cannot be used, against this repo's usual rule that an
279
- * inapplicable action is absent. The rule is about actions that do not APPLY to a kind — a folder
280
- * has no retrieval mode. These two apply to every agent, and a head that quietly dropped them would
281
- * hide from the reader that the agent can be stopped at all. Disabled always carries its reason on
282
- * the screen, pointed at by `aria-describedby`, so the keyboard hears it too.
283
- *
284
- * ⚠️ "Run now" fires a schedule the agent already has; it does not invent one. A run acts with the
285
- * AGENT's Gate token, so a free choice of target here would let anybody who may drive the agent read
286
- * with its grants instead of their own — the runtime refuses an undeclared target for that reason,
287
- * and this menu offers only what it would accept.
288
- */
289
- export function AgentActions({
290
- agentId,
291
- definition,
292
- }: {
293
- agentId: string;
294
- definition: AgentDefinition | null;
295
- }) {
296
- const { data } = useIntelRouterContext();
297
- const i18n = useI18n();
298
- const queryClient = useQueryClient();
299
- const state = useAgentState(agentId);
300
- const denied = agentAccessDenied(state.error);
301
- const paused = state.data?.paused ?? false;
302
-
303
- const toggle = useMutation({
304
- mutationFn: async () =>
305
- paused ? await data.resumeAgent(agentId) : await data.pauseAgent(agentId),
306
- // The answer is the new state, so the cache is set from it rather than refetched: a round trip
307
- // would leave a window in which the button still offers what was just done.
308
- onSuccess: (next) => queryClient.setQueryData(agentStateKey(agentId), next),
309
- });
310
- const runNow = useMutation({
311
- mutationFn: async (target: AgentScheduleTarget) => await data.runAgentNow(agentId, target),
312
- // The run is worked off the runtime's alarm, so it appears in the list rather than in this
313
- // answer. Invalidating is what puts it on the Log tab and in the status line.
314
- onSuccess: () => queryClient.invalidateQueries({ queryKey: ["agent-runs", agentId] }),
315
- });
316
-
317
- const targets = definition?.schedules.map((schedule) => schedule.target) ?? [];
318
- const reason = denied
319
- ? i18n.t("agent.notPermitted")
320
- : state.isError
321
- ? i18n.t("agent.status.unknown")
322
- : null;
323
- const runReason =
324
- reason ??
325
- (paused
326
- ? i18n.t("agent.runNowPaused")
327
- : targets.length === 0
328
- ? i18n.t("agent.runNowNoTarget")
329
- : null);
330
- const failure = toggle.error ?? runNow.error;
331
-
332
- return (
333
- <span className="flex items-center gap-2">
334
- {/* ⚠️ `sr-only` and no longer beside the buttons (#253): the reason belongs in the tooltip of
335
- the control it disables, not as a sentence in the title line. But it stays IN THE
336
- ACCESSIBILITY TREE — `hidden` or `display: none` would leave `aria-describedby` pointing at
337
- nothing, and Radix puts the tooltip's own content there only while it is open. So the words
338
- are here for the keyboard and on the hover for the pointer, and never in the bar. */}
339
- {runReason ? (
340
- <span id="agent-actions-reason" className="sr-only">
341
- {runReason}
342
- </span>
343
- ) : null}
344
- {/* ⚠️ An agent with no key in its Durable Object fails every run the same way forever, so
345
- "try again" is advice that cannot work (#200). The one refusal with a repair path says
346
- what the repair is, and names where it lives — the profile, not this bar (D29).
347
-
348
- ⚠️ Every other refusal is shown in its OWN words where it brought any (#201). `Resume`
349
- fails for reasons only the runtime knows, and "that did not work" turns each of them into
350
- the same shrug — which is how a resume that could not arm the alarm looked like one that
351
- simply needed another click. */}
352
- {failure ? (
353
- <span role="alert" className="text-xs text-destructive max-sm:sr-only sm:inline">
354
- {agentKeyMissing(failure)
355
- ? i18n.t("agent.keyMissing")
356
- : (agentProblemDetail(failure) ?? i18n.t("agent.actionFailed"))}
357
- </span>
358
- ) : null}
359
- <WhyDisabled reason={reason}>
360
- <button
361
- type="button"
362
- onClick={() => toggle.mutate()}
363
- disabled={reason !== null || state.isPending || toggle.isPending}
364
- aria-busy={toggle.isPending}
365
- {...(reason ? { "aria-describedby": "agent-actions-reason" } : {})}
366
- className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
367
- >
368
- {paused ? (
369
- <Play aria-hidden="true" className="size-4" />
370
- ) : (
371
- <Pause aria-hidden="true" className="size-4" />
372
- )}
373
- {i18n.t(paused ? "agent.resume" : "agent.pause")}
374
- </button>
375
- </WhyDisabled>
376
- <WhyDisabled reason={runReason}>
377
- <RunNow
378
- targets={targets}
379
- disabled={runReason !== null || runNow.isPending}
380
- busy={runNow.isPending}
381
- describedBy={runReason ? "agent-actions-reason" : undefined}
382
- start={(target) => runNow.mutate(target)}
383
- />
384
- </WhyDisabled>
385
- </span>
386
- );
387
- }
388
-
389
- /**
390
- * The reason a control is shut, on its own hover instead of beside it (#253).
391
- *
392
- * ⚠️ The wrapping span is not decoration. A disabled button fires no pointer events, so a trigger
393
- * bound directly to it never opens — the same trick the profile's disabled "Add contact" needs, and
394
- * the reason both keep an `sr-only` paragraph for `aria-describedby` beside the tooltip.
395
- *
396
- * ⚠️ The wrapper is ALWAYS here, and only the content comes and goes. A reason appears and vanishes
397
- * as the state query settles, and a version of this that returned the bare child when there was
398
- * nothing to say re-created the button on every such change — which throws away its focus and, in a
399
- * test, leaves whoever grabbed it holding a node that is no longer on screen. Without content Radix
400
- * opens nothing at all, so the empty case costs a span and shows no bubble.
401
- */
402
- function WhyDisabled({ reason, children }: { reason: string | null; children: React.ReactNode }) {
403
- return (
404
- <TooltipProvider delayDuration={300}>
405
- <Tooltip>
406
- <TooltipTrigger asChild>
407
- <span className="inline-flex">{children}</span>
408
- </TooltipTrigger>
409
- {reason === null ? null : <TooltipContent className="max-w-xs">{reason}</TooltipContent>}
410
- </Tooltip>
411
- </TooltipProvider>
412
- );
413
- }
414
-
415
- /**
416
- * ⚠️ One schedule runs straight away; several open a menu. A button that silently picked the first
417
- * of three targets would run the wrong thing at a moment somebody chose deliberately, and a menu in
418
- * front of a single obvious target is a click that answers nothing.
419
- */
420
- function RunNow({
421
- targets,
422
- disabled,
423
- busy,
424
- describedBy,
425
- start,
426
- }: {
427
- targets: AgentScheduleTarget[];
428
- disabled: boolean;
429
- busy: boolean;
430
- describedBy: string | undefined;
431
- start(target: AgentScheduleTarget): void;
432
- }) {
433
- const i18n = useI18n();
434
- const only = targets.length === 1 ? targets[0] : undefined;
435
- const className =
436
- "inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50";
437
-
438
- if (targets.length > 1) {
439
- return (
440
- <DropdownMenu>
441
- <DropdownMenuTrigger asChild>
442
- <button
443
- type="button"
444
- disabled={disabled}
445
- aria-busy={busy}
446
- {...(describedBy ? { "aria-describedby": describedBy } : {})}
447
- className={className}
448
- >
449
- <Play aria-hidden="true" className="size-4" />
450
- {i18n.t("agent.runNow")}
451
- </button>
452
- </DropdownMenuTrigger>
453
- <DropdownMenuContent align="end">
454
- {targets.map((target) => (
455
- <DropdownMenuItem key={`${target.kind}:${target.id}`} onSelect={() => start(target)}>
456
- <TargetLabel target={target} />
457
- </DropdownMenuItem>
458
- ))}
459
- </DropdownMenuContent>
460
- </DropdownMenu>
461
- );
462
- }
463
-
464
- return (
465
- <button
466
- type="button"
467
- onClick={() => only && start(only)}
468
- disabled={disabled || !only}
469
- aria-busy={busy}
470
- {...(describedBy ? { "aria-describedby": describedBy } : {})}
471
- className={className}
472
- >
473
- <Play aria-hidden="true" className="size-4" />
474
- {i18n.t("agent.runNow")}
475
- </button>
476
- );
477
- }
478
-
479
- function TargetLabel({ target }: { target: AgentScheduleTarget }) {
480
- const { title } = useEntryTitle(target.id, target.kind === "flow");
481
- return <span className="truncate">{title}</span>;
482
- }