@anchrd/intel-ui 0.8.8 → 0.9.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 (43) hide show
  1. package/README.md +2 -2
  2. package/package.json +5 -2
  3. package/src/agent/agent-avatar/agent-avatar.tsx +34 -0
  4. package/src/agent/agent-calendar/agent-calendar.tsx +79 -0
  5. package/src/agent/agent-chat/agent-chat.tsx +116 -0
  6. package/src/agent/agent-cron/agent-cron.ts +132 -0
  7. package/src/agent/agent-definition/agent-definition.ts +64 -0
  8. package/src/agent/agent-entry-title/agent-entry-title.ts +29 -0
  9. package/src/agent/agent-log/agent-log.tsx +159 -0
  10. package/src/agent/agent-models/agent-models.ts +63 -0
  11. package/src/agent/agent-profile/agent-profile.tsx +512 -0
  12. package/src/agent/agent-state/agent-state.ts +48 -0
  13. package/src/agent/agent.tsx +361 -0
  14. package/src/app/app-sidebar/app-sidebar.tsx +2 -2
  15. package/src/app/app-tree/app-tree.tsx +151 -23
  16. package/src/app/app.tsx +2 -2
  17. package/src/app/header-search/header-search.tsx +16 -16
  18. package/src/app/tree-move/tree-move.tsx +10 -10
  19. package/src/archive/archive.tsx +8 -8
  20. package/src/components/ui/avatar.tsx +39 -0
  21. package/src/components/ui/select.tsx +163 -0
  22. package/src/components/ui/tabs.tsx +52 -0
  23. package/src/data/agent-runtime/agent-runtime.ts +93 -0
  24. package/src/data/intel-data-provider/intel-data-provider.ts +201 -120
  25. package/src/data/intel-data-provider/intel-data-provider.types.ts +112 -54
  26. package/src/entry-picker/entry-picker.tsx +53 -17
  27. package/src/flow-runs/flow-runs.tsx +1 -1
  28. package/src/flows/flows.tsx +20 -20
  29. package/src/folder-contents/folder-contents.tsx +7 -7
  30. package/src/graph-pane/graph-pane.tsx +1 -1
  31. package/src/hooks/use-capabilities.ts +22 -0
  32. package/src/i18n/en.json +148 -64
  33. package/src/kind-icon.ts +5 -2
  34. package/src/{knowledge-editor/knowledge-editor.tsx → node-editor/node-editor.tsx} +20 -20
  35. package/src/{knowledge-graph → node-graph}/graph-notice.tsx +1 -1
  36. package/src/{knowledge-graph/knowledge-graph.tsx → node-graph/node-graph.tsx} +4 -4
  37. package/src/{knowledge-table/knowledge-table.tsx → node-table/node-table.tsx} +14 -14
  38. package/src/{knowledge/knowledge.tsx → nodes/nodes.tsx} +46 -29
  39. package/src/resource-menu/resource-menu.tsx +105 -62
  40. package/src/router/router.tsx +5 -5
  41. package/src/title-row/title-row.tsx +13 -5
  42. package/vite.config.ts +4 -0
  43. /package/src/{knowledge-graph/knowledge-graph.ts → node-graph/node-graph.ts} +0 -0
@@ -0,0 +1,63 @@
1
+ import { AgentModel } from "@anchrd/intel-contract";
2
+ import { z } from "zod";
3
+
4
+ /**
5
+ * Which models this installation offers.
6
+ *
7
+ * ⚠️ There is no endpoint to ask. The runtime's registry answers one question — "is this provider
8
+ * configured" — and only when a run is already starting; nothing anywhere lists what a deployment
9
+ * pays for. Rather than invent an API for one screen, the list is build configuration
10
+ * (`VITE_AGENT_MODELS`, a JSON array of `{provider, model}`), with the two the repository's own
11
+ * fixtures use as the default.
12
+ *
13
+ * ⚠️ The definition's current model is always offered, even when it is not in the list. A select
14
+ * that silently dropped it would turn "look at this agent" into "change this agent" for anyone who
15
+ * then saved — and the definition is written by MCP and by other installations too, not only here.
16
+ */
17
+ const ConfiguredModels = z.array(AgentModel);
18
+
19
+ const firstFallback: AgentModel = { provider: "anthropic", model: "claude-sonnet-4" };
20
+ const fallback: [AgentModel, ...AgentModel[]] = [
21
+ firstFallback,
22
+ { provider: "workers-ai", model: "@cf/meta/llama-3.3-70b-instruct-fp8-fast" },
23
+ ];
24
+
25
+ export function modelKey(model: AgentModel): string {
26
+ return `${model.provider}:${model.model}`;
27
+ }
28
+
29
+ export function parseModelKey(key: string): AgentModel | null {
30
+ const boundary = key.indexOf(":");
31
+ if (boundary <= 0) return null;
32
+ const parsed = AgentModel.safeParse({
33
+ provider: key.slice(0, boundary),
34
+ model: key.slice(boundary + 1),
35
+ });
36
+ return parsed.success ? parsed.data : null;
37
+ }
38
+
39
+ export function configuredModels(): AgentModel[] {
40
+ const raw = import.meta.env.VITE_AGENT_MODELS;
41
+ if (!raw) return fallback;
42
+ try {
43
+ const parsed = ConfiguredModels.safeParse(JSON.parse(raw));
44
+ // A malformed variable falls back rather than emptying the select: an agent whose model cannot
45
+ // be chosen is an agent that cannot be repaired from this screen.
46
+ return parsed.success && parsed.data.length > 0 ? parsed.data : fallback;
47
+ } catch {
48
+ return fallback;
49
+ }
50
+ }
51
+
52
+ // The offered list with the agent's own model folded in, deduplicated, order preserved.
53
+ export function selectableModels(current: AgentModel | null): AgentModel[] {
54
+ const models = configuredModels();
55
+ if (!current || models.some((model) => modelKey(model) === modelKey(current))) return models;
56
+ return [current, ...models];
57
+ }
58
+
59
+ // What a newly created agent gets. The first offered model, because a create dialog that asked for
60
+ // a provider before the agent has a name would be a settings screen wearing a form's clothes.
61
+ export function defaultModel(): AgentModel {
62
+ return configuredModels()[0] ?? firstFallback;
63
+ }
@@ -0,0 +1,512 @@
1
+ import {
2
+ type AgentDefinition,
3
+ type AgentReference,
4
+ AgentReferenceRole,
5
+ type AgentSchedule,
6
+ type Node,
7
+ } from "@anchrd/intel-contract";
8
+ import { useQuery } from "@tanstack/react-query";
9
+ import { Check, Copy, MessageSquare, Plus, Trash2, Wrench } from "lucide-react";
10
+ import { useState } from "react";
11
+ import type { AgentDefinitionHandle } from "@/agent/agent-definition/agent-definition.ts";
12
+ import { useEntryTitle } from "@/agent/agent-entry-title/agent-entry-title.ts";
13
+ import { modelKey, parseModelKey, selectableModels } from "@/agent/agent-models/agent-models.ts";
14
+ import {
15
+ Select,
16
+ SelectContent,
17
+ SelectItem,
18
+ SelectTrigger,
19
+ SelectValue,
20
+ } from "@/components/ui/select";
21
+ import { agentMcpAddress } from "@/data/agent-runtime/agent-runtime.ts";
22
+ import { EntryPicker, type PickerKind } from "@/entry-picker/entry-picker.tsx";
23
+ import { Modal } from "@/modal/modal.tsx";
24
+ import { useIntelRouterContext } from "@/router/router-context.ts";
25
+
26
+ function Section({
27
+ title,
28
+ hint,
29
+ children,
30
+ }: {
31
+ title: string;
32
+ hint?: string;
33
+ children: React.ReactNode;
34
+ }) {
35
+ return (
36
+ <section aria-label={title} className="border-b px-6 py-5 last:border-b-0">
37
+ <h3 className="text-sm font-semibold">{title}</h3>
38
+ {hint ? <p className="mt-1 max-w-2xl text-xs text-muted-foreground">{hint}</p> : null}
39
+ <div className="mt-3">{children}</div>
40
+ </section>
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Everything an agent is, on one page: how to reach it, what it reads, what it may call, when it
46
+ * acts on its own, and which model does the thinking.
47
+ *
48
+ * ⚠️ Four of the five sections write, and all four write the SAME way — a whole new definition
49
+ * version through `save` (see `agent-definition.ts`). Tools is the exception and stays read-only on
50
+ * purpose: the portal decides who reaches which tool, Intel stores none of it, and a control here
51
+ * would be a promise this product is not allowed to keep (ADR-0003).
52
+ */
53
+ export function AgentProfile({ node, agent }: { node: Node; agent: AgentDefinitionHandle }) {
54
+ const { i18n } = useIntelRouterContext();
55
+ const definition = agent.definition;
56
+
57
+ // The definition is `null` in exactly one window: the node exists and no version has been written
58
+ // yet. Nothing on this page can be edited until there is one, and saying so beats five sections
59
+ // of empty controls that all refuse.
60
+ if (agent.query.isPending) {
61
+ return <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>;
62
+ }
63
+ if (agent.query.isError) {
64
+ return (
65
+ <div role="alert" className="space-y-3 p-6 text-sm">
66
+ <p className="text-destructive">{i18n.t("agent.loadFailed")}</p>
67
+ <button
68
+ type="button"
69
+ onClick={() => void agent.query.refetch()}
70
+ className="rounded-md border px-3 py-2 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
71
+ >
72
+ {i18n.t("common.retry")}
73
+ </button>
74
+ </div>
75
+ );
76
+ }
77
+ if (!definition) {
78
+ return <p className="p-6 text-sm text-muted-foreground">{i18n.t("agent.noDefinition")}</p>;
79
+ }
80
+
81
+ return (
82
+ <div className="min-h-0 flex-1 overflow-y-auto">
83
+ {agent.save.isError ? (
84
+ <p role="alert" className="border-b bg-destructive/5 px-6 py-3 text-sm text-destructive">
85
+ {i18n.t("agent.saveFailed")}
86
+ </p>
87
+ ) : null}
88
+ <ContactSection node={node} />
89
+ <KnowledgeSection definition={definition} agent={agent} />
90
+ <ToolsSection />
91
+ <ScheduleSection definition={definition} agent={agent} />
92
+ <ModelSection definition={definition} agent={agent} />
93
+ </div>
94
+ );
95
+ }
96
+
97
+ /**
98
+ * How to reach this agent.
99
+ *
100
+ * ⚠️ The built-in chat is listed first and cannot be removed, because it is not a channel somebody
101
+ * configured — it is the runtime itself (ADR-0005 §5). Listing it beside the others, with no
102
+ * delete, is what says "built in, always there" without a sentence explaining it.
103
+ */
104
+ function ContactSection({ node }: { node: Node }) {
105
+ const { i18n } = useIntelRouterContext();
106
+ const [copied, setCopied] = useState(false);
107
+ // ⚠️ The runtime's own address, NOT intel's proxy. This one is dialled by a portal or another
108
+ // agent with its own credential (ADR-0005 §7); intel's door is for this page's browser calls and
109
+ // deliberately does not carry `/mcp` (#178).
110
+ const mcpAddress = agentMcpAddress(node.id);
111
+ // A relative path is what the page was built with when the runtime shares the origin, and a
112
+ // relative MCP address is useless to the client that has to dial it. Resolving against the page
113
+ // makes the copied value absolute wherever it came from.
114
+ const absolute =
115
+ typeof window === "undefined" ? mcpAddress : new URL(mcpAddress, window.location.origin).href;
116
+
117
+ return (
118
+ <Section title={i18n.t("agent.contact")} hint={i18n.t("agent.contactHint")}>
119
+ <ul className="space-y-2">
120
+ <li className="flex items-center gap-3 rounded-lg border bg-card px-4 py-3">
121
+ <MessageSquare aria-hidden="true" className="size-4 shrink-0 text-primary" />
122
+ <span className="min-w-0 flex-1">
123
+ <span className="block text-sm font-medium">{i18n.t("agent.contactChat")}</span>
124
+ <span className="block text-xs text-muted-foreground">
125
+ {i18n.t("agent.contactChatBuiltIn")}
126
+ </span>
127
+ </span>
128
+ </li>
129
+ <li className="flex items-center gap-3 rounded-lg border bg-card px-4 py-3">
130
+ <Wrench aria-hidden="true" className="size-4 shrink-0 text-primary" />
131
+ <span className="min-w-0 flex-1">
132
+ <span className="block text-sm font-medium">{i18n.t("agent.contactMcp")}</span>
133
+ <code className="mt-0.5 block truncate text-xs text-muted-foreground">{absolute}</code>
134
+ </span>
135
+ <button
136
+ type="button"
137
+ onClick={() => {
138
+ void navigator.clipboard.writeText(absolute).then(() => {
139
+ setCopied(true);
140
+ setTimeout(() => setCopied(false), 2_000);
141
+ });
142
+ }}
143
+ className="inline-flex shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
144
+ >
145
+ {copied ? (
146
+ <Check aria-hidden="true" className="size-3.5" />
147
+ ) : (
148
+ <Copy aria-hidden="true" className="size-3.5" />
149
+ )}
150
+ {i18n.t(copied ? "agent.copied" : "agent.copy")}
151
+ </button>
152
+ </li>
153
+ </ul>
154
+ {/* ⚠️ Disabled, and the reason is written rather than hovered. A mail contact is deployment
155
+ configuration (`AGENT_MAILBOXES`) and deliberately not part of the definition — ADR-0005
156
+ §4 — so a working button here would write a field the runtime never reads. */}
157
+ <div className="mt-3">
158
+ <button
159
+ type="button"
160
+ disabled
161
+ aria-describedby="agent-contact-add-reason"
162
+ className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm disabled:opacity-50"
163
+ >
164
+ <Plus aria-hidden="true" className="size-4" />
165
+ {i18n.t("agent.contactAdd")}
166
+ </button>
167
+ <p id="agent-contact-add-reason" className="mt-1.5 text-xs text-muted-foreground">
168
+ {i18n.t("agent.contactAddReason")}
169
+ </p>
170
+ </div>
171
+ </Section>
172
+ );
173
+ }
174
+
175
+ /**
176
+ * What the agent reads, and in which capacity.
177
+ *
178
+ * ⚠️ The role is a select with a visible chevron, not a coloured word. #143 asks for that
179
+ * explicitly, and the reason is that the three roles do very different things — a folder read as
180
+ * the system message becomes instructions, one read as memory is written back to. Something that
181
+ * changes what a document MEANS to an agent must look changeable.
182
+ */
183
+ function KnowledgeSection({
184
+ definition,
185
+ agent,
186
+ }: {
187
+ definition: AgentDefinition;
188
+ agent: AgentDefinitionHandle;
189
+ }) {
190
+ const { i18n } = useIntelRouterContext();
191
+ const [adding, setAdding] = useState(false);
192
+
193
+ function write(references: AgentReference[]) {
194
+ agent.save.mutate({ ...definition, references });
195
+ }
196
+
197
+ return (
198
+ <Section title={i18n.t("agent.knows")} hint={i18n.t("agent.knowsHint")}>
199
+ {definition.references.length === 0 ? (
200
+ <p className="text-sm text-muted-foreground">{i18n.t("agent.knowsEmpty")}</p>
201
+ ) : (
202
+ <ul className="space-y-2">
203
+ {/* ⚠️ The position IS the identity here: `references` is an ordered array with no ids,
204
+ the contract permits the same folder twice, and every edit below addresses a row by
205
+ its index. A key built from the contents alone would collide on exactly the duplicate
206
+ the array is allowed to hold. */}
207
+ {definition.references.map((reference, index) => (
208
+ <ReferenceRow
209
+ // biome-ignore lint/suspicious/noArrayIndexKey: the position is the identity — see above
210
+ key={`${reference.nodeId}:${index}`}
211
+ reference={reference}
212
+ setRole={(role) =>
213
+ write(
214
+ definition.references.map((current, position) =>
215
+ position === index ? { ...current, role } : current,
216
+ ),
217
+ )
218
+ }
219
+ remove={() =>
220
+ write(definition.references.filter((_, position) => position !== index))
221
+ }
222
+ />
223
+ ))}
224
+ </ul>
225
+ )}
226
+ <button
227
+ type="button"
228
+ onClick={() => setAdding(true)}
229
+ className="mt-3 inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
230
+ >
231
+ <Plus aria-hidden="true" className="size-4" />
232
+ {i18n.t("agent.addReference")}
233
+ </button>
234
+ {adding ? (
235
+ <Modal title={i18n.t("agent.addReference")} close={() => setAdding(false)}>
236
+ <EntryPicker
237
+ kind="folder"
238
+ value=""
239
+ label={i18n.t("agent.pickFolder")}
240
+ onSelect={(nodeId) => {
241
+ write([...definition.references, { nodeId, role: "semantic-context" }]);
242
+ setAdding(false);
243
+ }}
244
+ />
245
+ </Modal>
246
+ ) : null}
247
+ </Section>
248
+ );
249
+ }
250
+
251
+ /**
252
+ * One folder the agent reads, and what it counts as.
253
+ *
254
+ * ⚠️ Its own component so the select can be NAMED after the folder. Three rows carrying the same
255
+ * "What this folder counts as" are three identical controls to anybody listening rather than
256
+ * looking — the name is beside them on screen, and the screen is exactly what that reader does not
257
+ * have. It needs the resolved title, which is why the lookup is a hook rather than a component here.
258
+ */
259
+ function ReferenceRow({
260
+ reference,
261
+ setRole,
262
+ remove,
263
+ }: {
264
+ reference: AgentReference;
265
+ setRole(role: AgentReferenceRole): void;
266
+ remove(): void;
267
+ }) {
268
+ const { i18n } = useIntelRouterContext();
269
+ const { title, known } = useEntryTitle(reference.nodeId);
270
+ return (
271
+ <li className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3">
272
+ <span className={`min-w-0 flex-1 truncate text-sm${known ? "" : " text-muted-foreground"}`}>
273
+ {title}
274
+ </span>
275
+ <Select
276
+ value={reference.role}
277
+ onValueChange={(role) => {
278
+ const parsed = AgentReferenceRole.safeParse(role);
279
+ if (parsed.success) setRole(parsed.data);
280
+ }}
281
+ >
282
+ <SelectTrigger size="sm" aria-label={i18n.t("agent.roleFor", { title })}>
283
+ <SelectValue />
284
+ </SelectTrigger>
285
+ <SelectContent>
286
+ {AgentReferenceRole.options.map((role) => (
287
+ <SelectItem key={role} value={role}>
288
+ {i18n.t(`agent.role.${role}`)}
289
+ </SelectItem>
290
+ ))}
291
+ </SelectContent>
292
+ </Select>
293
+ <button
294
+ type="button"
295
+ onClick={remove}
296
+ aria-label={i18n.t("agent.removeReferenceOf", { title })}
297
+ className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
298
+ >
299
+ <Trash2 aria-hidden="true" className="size-4" />
300
+ </button>
301
+ </li>
302
+ );
303
+ }
304
+
305
+ function EntryTitle({ entryId, flow = false }: { entryId: string; flow?: boolean }) {
306
+ const { title, known } = useEntryTitle(entryId, flow);
307
+ return (
308
+ <span className={`min-w-0 flex-1 truncate text-sm${known ? "" : " text-muted-foreground"}`}>
309
+ {title}
310
+ </span>
311
+ );
312
+ }
313
+
314
+ /**
315
+ * What the agent may call.
316
+ *
317
+ * ⚠️ Read-only, and read live. Intel stores no tool permissions — the catalog is one `tools/list`
318
+ * with the signed-in user's own portal token (ADR-0003), and this section shows exactly what the
319
+ * Tools screen shows because it asks the same question through the same provider method. Mirroring
320
+ * the catalog into the definition is the one thing this product must not do.
321
+ */
322
+ function ToolsSection() {
323
+ const { data, i18n } = useIntelRouterContext();
324
+ const catalog = useQuery({ queryKey: ["tools"], queryFn: () => data.listTools() });
325
+
326
+ return (
327
+ <Section title={i18n.t("agent.tools")} hint={i18n.t("agent.toolsHint")}>
328
+ {catalog.isPending ? (
329
+ <p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
330
+ ) : catalog.isError ? (
331
+ <p role="alert" className="text-sm text-destructive">
332
+ {i18n.t("tools.unreachableHelp")}
333
+ </p>
334
+ ) : catalog.data && catalog.data.items.length === 0 ? (
335
+ <p className="text-sm text-muted-foreground">{i18n.t("tools.emptyHelp")}</p>
336
+ ) : (
337
+ <ul className="flex flex-wrap gap-2">
338
+ {catalog.data?.items.map((tool) => (
339
+ <li
340
+ key={tool.name}
341
+ className="rounded-full border bg-card px-3 py-1 text-xs text-muted-foreground"
342
+ >
343
+ {tool.name}
344
+ </li>
345
+ ))}
346
+ </ul>
347
+ )}
348
+ </Section>
349
+ );
350
+ }
351
+
352
+ /**
353
+ * When the agent acts without being asked.
354
+ *
355
+ * ⚠️ The target is a flow OR a document and nothing else — the contract says so, and the picker is
356
+ * told to offer both rather than being given a free text field. A schedule aimed at something that
357
+ * cannot be run is a run that fails at 3 a.m. with nobody watching.
358
+ */
359
+ function ScheduleSection({
360
+ definition,
361
+ agent,
362
+ }: {
363
+ definition: AgentDefinition;
364
+ agent: AgentDefinitionHandle;
365
+ }) {
366
+ const { i18n } = useIntelRouterContext();
367
+ const [adding, setAdding] = useState(false);
368
+
369
+ function write(schedules: AgentSchedule[]) {
370
+ agent.save.mutate({ ...definition, schedules });
371
+ }
372
+
373
+ return (
374
+ <Section title={i18n.t("agent.schedule")} hint={i18n.t("agent.scheduleHint")}>
375
+ {definition.schedules.length === 0 ? (
376
+ <p className="text-sm text-muted-foreground">{i18n.t("agent.scheduleEmpty")}</p>
377
+ ) : (
378
+ <ul className="space-y-2">
379
+ {definition.schedules.map((schedule, index) => (
380
+ <li
381
+ // biome-ignore lint/suspicious/noArrayIndexKey: same as the references above — an ordered array with no ids, edited by position
382
+ key={`${schedule.cron}:${schedule.target.id}:${index}`}
383
+ className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3"
384
+ >
385
+ <code className="rounded bg-muted px-2 py-1 text-xs">{schedule.cron}</code>
386
+ <EntryTitle entryId={schedule.target.id} flow={schedule.target.kind === "flow"} />
387
+ <span className="text-xs text-muted-foreground">
388
+ {i18n.t(`node.kind.${schedule.target.kind}`)}
389
+ </span>
390
+ <button
391
+ type="button"
392
+ onClick={() =>
393
+ write(definition.schedules.filter((_, position) => position !== index))
394
+ }
395
+ aria-label={i18n.t("agent.removeSchedule")}
396
+ className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
397
+ >
398
+ <Trash2 aria-hidden="true" className="size-4" />
399
+ </button>
400
+ </li>
401
+ ))}
402
+ </ul>
403
+ )}
404
+ <button
405
+ type="button"
406
+ onClick={() => setAdding(true)}
407
+ className="mt-3 inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
408
+ >
409
+ <Plus aria-hidden="true" className="size-4" />
410
+ {i18n.t("agent.addSchedule")}
411
+ </button>
412
+ {adding ? (
413
+ <ScheduleDialog
414
+ close={() => setAdding(false)}
415
+ add={(schedule) => {
416
+ write([...definition.schedules, schedule]);
417
+ setAdding(false);
418
+ }}
419
+ />
420
+ ) : null}
421
+ </Section>
422
+ );
423
+ }
424
+
425
+ function ScheduleDialog({ close, add }: { close(): void; add(schedule: AgentSchedule): void }) {
426
+ const { i18n } = useIntelRouterContext();
427
+ const [cron, setCron] = useState("0 8 * * *");
428
+ const [target, setTarget] = useState<{ id: string; kind: "document" | "flow" } | null>(null);
429
+
430
+ return (
431
+ <Modal title={i18n.t("agent.addSchedule")} close={close}>
432
+ <form
433
+ className="space-y-4"
434
+ onSubmit={(event) => {
435
+ event.preventDefault();
436
+ if (target && cron.trim()) add({ cron: cron.trim(), target });
437
+ }}
438
+ >
439
+ <label className="block text-sm font-medium">
440
+ {i18n.t("agent.cron")}
441
+ <input
442
+ required
443
+ value={cron}
444
+ onChange={(event) => setCron(event.target.value)}
445
+ aria-describedby="agent-cron-hint"
446
+ className="mt-2 w-full rounded-md border bg-background px-3 py-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
447
+ />
448
+ <span
449
+ id="agent-cron-hint"
450
+ className="mt-1 block text-xs font-normal text-muted-foreground"
451
+ >
452
+ {i18n.t("agent.cronHint")}
453
+ </span>
454
+ </label>
455
+ <EntryPicker
456
+ kind="document"
457
+ flows
458
+ value={target?.id ?? ""}
459
+ label={i18n.t("agent.scheduleTarget")}
460
+ onSelect={(entryId, entryKind: PickerKind) => {
461
+ if (entryKind !== "document" && entryKind !== "flow") return;
462
+ setTarget({ id: entryId, kind: entryKind });
463
+ }}
464
+ />
465
+ <button
466
+ type="submit"
467
+ disabled={!target || cron.trim().length === 0}
468
+ className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
469
+ >
470
+ {i18n.t("common.create")}
471
+ </button>
472
+ </form>
473
+ </Modal>
474
+ );
475
+ }
476
+
477
+ // Which model does the thinking. Changing it writes a new definition version like every other edit
478
+ // here — the runtime picks the provider up on its next read (its definition cache is 60 seconds).
479
+ function ModelSection({
480
+ definition,
481
+ agent,
482
+ }: {
483
+ definition: AgentDefinition;
484
+ agent: AgentDefinitionHandle;
485
+ }) {
486
+ const { i18n } = useIntelRouterContext();
487
+ const models = selectableModels(definition.model);
488
+
489
+ return (
490
+ <Section title={i18n.t("agent.model")} hint={i18n.t("agent.modelHint")}>
491
+ <Select
492
+ value={modelKey(definition.model)}
493
+ onValueChange={(key) => {
494
+ const model = parseModelKey(key);
495
+ if (model) agent.save.mutate({ ...definition, model });
496
+ }}
497
+ >
498
+ <SelectTrigger aria-label={i18n.t("agent.model")} className="w-full max-w-md">
499
+ <SelectValue />
500
+ </SelectTrigger>
501
+ <SelectContent>
502
+ {models.map((model) => (
503
+ <SelectItem key={modelKey(model)} value={modelKey(model)}>
504
+ {model.model}
505
+ <span className="text-xs text-muted-foreground">{model.provider}</span>
506
+ </SelectItem>
507
+ ))}
508
+ </SelectContent>
509
+ </Select>
510
+ </Section>
511
+ );
512
+ }
@@ -0,0 +1,48 @@
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
+ }