@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,361 @@
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
+ agentRuntimeMissing,
14
+ agentStateKey,
15
+ useAgentState,
16
+ } from "@/agent/agent-state/agent-state.ts";
17
+ import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
18
+ import {
19
+ DropdownMenu,
20
+ DropdownMenuContent,
21
+ DropdownMenuItem,
22
+ DropdownMenuTrigger,
23
+ } from "@/components/ui/dropdown-menu";
24
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
25
+ import { useCapabilities } from "@/hooks/use-capabilities.ts";
26
+ import { useIntelRouterContext } from "@/router/router-context.ts";
27
+
28
+ const tabs = ["profile", "chat", "calendar", "log"] as const;
29
+ type Tab = (typeof tabs)[number];
30
+
31
+ /**
32
+ * The agent's page: the lower of the shell's two bars, carrying this one agent's four views.
33
+ *
34
+ * ⚠️ The name, the menu, the status line and the two head actions all live in the SAME title line —
35
+ * the one `TitleRow` draws for every open thing (#53). The status and the actions are portalled
36
+ * into its slots rather than drawn again here, because an agent with a header of its own would be
37
+ * the one kind of file whose rename sits somewhere else.
38
+ *
39
+ * ⚠️ One state query stands for the whole page, and it is also how the page learns whether this
40
+ * reader may drive this agent at all: `/session` carries identity and no capabilities on purpose, so
41
+ * a 403 from the runtime door is the only place the answer exists (#178). It is then rendered as an
42
+ * answer — a sentence in the two tabs that would have needed it — rather than as a red failure.
43
+ */
44
+ export function AgentPanel({ node }: { node: Node }) {
45
+ const { i18n } = useIntelRouterContext();
46
+ const [tab, setTab] = useState<Tab>("profile");
47
+ const agent = useAgentDefinition(node.id);
48
+ const state = useAgentState(node.id);
49
+ const denied = agentAccessDenied(state.error);
50
+ const capabilities = useCapabilities();
51
+
52
+ // An installation without an agent runtime (#190): the node from an earlier deployment still
53
+ // opens, but it explains itself instead of rendering four views whose every call would end in a
54
+ // 503. `/capabilities` is the answer; the probe's own `agent_runtime_not_configured` is the belt
55
+ // to that brace. ⚠️ Only an explicit `false` takes the page away — while the answer is still
56
+ // loading the tabs stand, because tearing down a rendered page over a pending request would blank
57
+ // the screen for every agent on every load.
58
+ if (capabilities.data?.agentRuntime === false || agentRuntimeMissing(state.error)) {
59
+ return <NoRuntime />;
60
+ }
61
+
62
+ return (
63
+ <>
64
+ <ActionSlot name="title-meta">
65
+ <AgentStatus agentId={node.id} definition={agent.definition} />
66
+ </ActionSlot>
67
+ <ActionSlot name="title-actions">
68
+ <AgentActions agentId={node.id} definition={agent.definition} />
69
+ </ActionSlot>
70
+ <Tabs
71
+ value={tab}
72
+ onValueChange={(value) => setTab(value as Tab)}
73
+ className="flex min-h-0 flex-1 flex-col gap-0"
74
+ >
75
+ {/* The file-specific bar. It stands under the title line and above the view it switches —
76
+ the order the flow screen's own switch already uses (#19). */}
77
+ <div className="border-b px-6 py-2">
78
+ <TabsList aria-label={i18n.t("agent.tabs")}>
79
+ {tabs.map((name) => (
80
+ <TabsTrigger key={name} value={name}>
81
+ {i18n.t(`agent.tab.${name}`)}
82
+ </TabsTrigger>
83
+ ))}
84
+ </TabsList>
85
+ </div>
86
+ <TabsContent value="profile" className="flex min-h-0 flex-col">
87
+ <AgentProfile node={node} agent={agent} />
88
+ </TabsContent>
89
+ {/* ⚠️ Each of the three below is mounted only while its own tab is open. The chat is the
90
+ reason: it holds a live SSE connection, and one kept open behind three other views is a
91
+ stream nobody is reading. The other two follow so that a switch back re-reads rather
92
+ than showing a run list from ten minutes ago. */}
93
+ <TabsContent value="chat" className="flex min-h-0 flex-col">
94
+ {tab === "chat" ? denied ? <NotForYou /> : <AgentChat agentId={node.id} /> : null}
95
+ </TabsContent>
96
+ <TabsContent value="calendar" className="flex min-h-0 flex-col">
97
+ {tab === "calendar" ? (
98
+ <AgentCalendar definition={agent.definition} now={new Date()} />
99
+ ) : null}
100
+ </TabsContent>
101
+ <TabsContent value="log" className="flex min-h-0 flex-col">
102
+ {tab === "log" ? denied ? <NotForYou /> : <AgentLog agentId={node.id} /> : null}
103
+ </TabsContent>
104
+ </Tabs>
105
+ </>
106
+ );
107
+ }
108
+
109
+ /**
110
+ * What somebody without `agents/run` sees instead of the chat and the log.
111
+ *
112
+ * ⚠️ Muted text, not `role="alert"` and not the destructive colour. Nothing is broken and there is
113
+ * nothing to retry: they may read this agent, they may not drive it. A red box would send them to
114
+ * whoever runs the installation over a working system.
115
+ */
116
+ function NotForYou() {
117
+ const { i18n } = useIntelRouterContext();
118
+ return (
119
+ <p className="m-auto max-w-md px-6 py-5 text-center text-sm text-muted-foreground">
120
+ {i18n.t("agent.notPermitted")}
121
+ </p>
122
+ );
123
+ }
124
+
125
+ /**
126
+ * What an agent node from an earlier deployment shows where no runtime is configured (#190).
127
+ *
128
+ * ⚠️ The same muted sentence as `NotForYou`, and for the same reason: nothing is broken and there
129
+ * is nothing to retry. The node and its definition are intact; the installation simply does not run
130
+ * agents, which is the operator's choice, not the reader's problem to debug.
131
+ */
132
+ function NoRuntime() {
133
+ const { i18n } = useIntelRouterContext();
134
+ return (
135
+ <p className="m-auto max-w-md px-6 py-5 text-center text-sm text-muted-foreground">
136
+ {i18n.t("agent.noRuntime")}
137
+ </p>
138
+ );
139
+ }
140
+
141
+ /**
142
+ * Whether the agent is working, whether it is switched off, and when it next will be.
143
+ *
144
+ * ⚠️ "Running" is read from the runtime's own run list, and its absence is NOT read as "idle" — a
145
+ * runtime that cannot be reached says nothing about what the agent is doing, and a status line that
146
+ * quietly reported "idle" for an unreachable runtime would be wrong exactly when it matters.
147
+ *
148
+ * ⚠️ "Next run" is derived from the cron expressions rather than asked. The runtime arms one alarm
149
+ * and publishes neither it nor a schedule, so this is the same reading `AgentCalendar` does — and
150
+ * while the agent is paused it is not shown at all, because a paused agent will not run then.
151
+ */
152
+ export function AgentStatus({
153
+ agentId,
154
+ definition,
155
+ }: {
156
+ agentId: string;
157
+ definition: AgentDefinition | null;
158
+ }) {
159
+ const { data, i18n } = useIntelRouterContext();
160
+ const state = useAgentState(agentId);
161
+ const runs = useQuery({
162
+ queryKey: ["agent-runs", agentId],
163
+ queryFn: () => data.listAgentRuns(agentId),
164
+ // A status line is not worth a red screen: the runtime being unreachable is reported by the
165
+ // Log tab, which is the view that is actually about runs.
166
+ retry: false,
167
+ });
168
+ const running = runs.data?.items.some((run) => run.status === "running") ?? false;
169
+ const paused = state.data?.paused ?? false;
170
+ const next = nextRunAt(definition, new Date());
171
+ const parts = [
172
+ runs.isError
173
+ ? i18n.t("agent.statusUnknown")
174
+ : paused
175
+ ? i18n.t("agent.statusPaused")
176
+ : running
177
+ ? i18n.t("agent.statusRunning")
178
+ : i18n.t("agent.statusIdle"),
179
+ paused
180
+ ? i18n.t("agent.pausedNoRuns")
181
+ : next
182
+ ? i18n.t("agent.nextRun", {
183
+ when: new Intl.DateTimeFormat(i18n.locale, {
184
+ dateStyle: "medium",
185
+ timeStyle: "short",
186
+ timeZone: "UTC",
187
+ }).format(next),
188
+ })
189
+ : i18n.t("agent.noNextRun"),
190
+ ];
191
+ return <span>{parts.join(" · ")}</span>;
192
+ }
193
+
194
+ /**
195
+ * Pause, and run now.
196
+ *
197
+ * ⚠️ Both are shown even where they cannot be used, against this repo's usual rule that an
198
+ * inapplicable action is absent. The rule is about actions that do not APPLY to a kind — a folder
199
+ * has no retrieval mode. These two apply to every agent, and a head that quietly dropped them would
200
+ * hide from the reader that the agent can be stopped at all. Disabled always carries its reason on
201
+ * the screen, pointed at by `aria-describedby`, so the keyboard hears it too.
202
+ *
203
+ * ⚠️ "Run now" fires a schedule the agent already has; it does not invent one. A run acts with the
204
+ * AGENT's Gate token, so a free choice of target here would let anybody who may drive the agent read
205
+ * with its grants instead of their own — the runtime refuses an undeclared target for that reason,
206
+ * and this menu offers only what it would accept.
207
+ */
208
+ export function AgentActions({
209
+ agentId,
210
+ definition,
211
+ }: {
212
+ agentId: string;
213
+ definition: AgentDefinition | null;
214
+ }) {
215
+ const { data, i18n } = useIntelRouterContext();
216
+ const queryClient = useQueryClient();
217
+ const state = useAgentState(agentId);
218
+ const denied = agentAccessDenied(state.error);
219
+ const paused = state.data?.paused ?? false;
220
+
221
+ const toggle = useMutation({
222
+ mutationFn: async () =>
223
+ paused ? await data.resumeAgent(agentId) : await data.pauseAgent(agentId),
224
+ // The answer is the new state, so the cache is set from it rather than refetched: a round trip
225
+ // would leave a window in which the button still offers what was just done.
226
+ onSuccess: (next) => queryClient.setQueryData(agentStateKey(agentId), next),
227
+ });
228
+ const runNow = useMutation({
229
+ mutationFn: async (target: AgentScheduleTarget) => await data.runAgentNow(agentId, target),
230
+ // The run is worked off the runtime's alarm, so it appears in the list rather than in this
231
+ // answer. Invalidating is what puts it on the Log tab and in the status line.
232
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ["agent-runs", agentId] }),
233
+ });
234
+
235
+ const targets = definition?.schedules.map((schedule) => schedule.target) ?? [];
236
+ const reason = denied
237
+ ? i18n.t("agent.notPermitted")
238
+ : state.isError
239
+ ? i18n.t("agent.statusUnknown")
240
+ : null;
241
+ const runReason =
242
+ reason ??
243
+ (paused
244
+ ? i18n.t("agent.runNowPaused")
245
+ : targets.length === 0
246
+ ? i18n.t("agent.runNowNoTarget")
247
+ : null);
248
+ const failure = toggle.error ?? runNow.error;
249
+
250
+ return (
251
+ <span className="flex items-center gap-2">
252
+ {/* ⚠️ `sr-only` rather than `hidden` where the bar is narrow. A disabled control whose reason
253
+ is `display: none` has no reason at all for a screen reader — the element it points at is
254
+ not in the accessibility tree. */}
255
+ {runReason ? (
256
+ <span
257
+ id="agent-actions-reason"
258
+ className="text-xs text-muted-foreground max-sm:sr-only sm:inline"
259
+ >
260
+ {runReason}
261
+ </span>
262
+ ) : null}
263
+ {failure ? (
264
+ <span role="alert" className="text-xs text-destructive max-sm:sr-only sm:inline">
265
+ {i18n.t("agent.actionFailed")}
266
+ </span>
267
+ ) : null}
268
+ <button
269
+ type="button"
270
+ onClick={() => toggle.mutate()}
271
+ disabled={reason !== null || state.isPending || toggle.isPending}
272
+ aria-busy={toggle.isPending}
273
+ {...(reason ? { "aria-describedby": "agent-actions-reason" } : {})}
274
+ 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"
275
+ >
276
+ {paused ? (
277
+ <Play aria-hidden="true" className="size-4" />
278
+ ) : (
279
+ <Pause aria-hidden="true" className="size-4" />
280
+ )}
281
+ {i18n.t(paused ? "agent.resume" : "agent.pause")}
282
+ </button>
283
+ <RunNow
284
+ targets={targets}
285
+ disabled={runReason !== null || runNow.isPending}
286
+ busy={runNow.isPending}
287
+ describedBy={runReason ? "agent-actions-reason" : undefined}
288
+ start={(target) => runNow.mutate(target)}
289
+ />
290
+ </span>
291
+ );
292
+ }
293
+
294
+ /**
295
+ * ⚠️ One schedule runs straight away; several open a menu. A button that silently picked the first
296
+ * of three targets would run the wrong thing at a moment somebody chose deliberately, and a menu in
297
+ * front of a single obvious target is a click that answers nothing.
298
+ */
299
+ function RunNow({
300
+ targets,
301
+ disabled,
302
+ busy,
303
+ describedBy,
304
+ start,
305
+ }: {
306
+ targets: AgentScheduleTarget[];
307
+ disabled: boolean;
308
+ busy: boolean;
309
+ describedBy: string | undefined;
310
+ start(target: AgentScheduleTarget): void;
311
+ }) {
312
+ const { i18n } = useIntelRouterContext();
313
+ const only = targets.length === 1 ? targets[0] : undefined;
314
+ const className =
315
+ "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";
316
+
317
+ if (targets.length > 1) {
318
+ return (
319
+ <DropdownMenu>
320
+ <DropdownMenuTrigger asChild>
321
+ <button
322
+ type="button"
323
+ disabled={disabled}
324
+ aria-busy={busy}
325
+ {...(describedBy ? { "aria-describedby": describedBy } : {})}
326
+ className={className}
327
+ >
328
+ <Play aria-hidden="true" className="size-4" />
329
+ {i18n.t("agent.runNow")}
330
+ </button>
331
+ </DropdownMenuTrigger>
332
+ <DropdownMenuContent align="end">
333
+ {targets.map((target) => (
334
+ <DropdownMenuItem key={`${target.kind}:${target.id}`} onSelect={() => start(target)}>
335
+ <TargetLabel target={target} />
336
+ </DropdownMenuItem>
337
+ ))}
338
+ </DropdownMenuContent>
339
+ </DropdownMenu>
340
+ );
341
+ }
342
+
343
+ return (
344
+ <button
345
+ type="button"
346
+ onClick={() => only && start(only)}
347
+ disabled={disabled || !only}
348
+ aria-busy={busy}
349
+ {...(describedBy ? { "aria-describedby": describedBy } : {})}
350
+ className={className}
351
+ >
352
+ <Play aria-hidden="true" className="size-4" />
353
+ {i18n.t("agent.runNow")}
354
+ </button>
355
+ );
356
+ }
357
+
358
+ function TargetLabel({ target }: { target: AgentScheduleTarget }) {
359
+ const { title } = useEntryTitle(target.id, target.kind === "flow");
360
+ return <span className="truncate">{title}</span>;
361
+ }
@@ -16,7 +16,7 @@ import {
16
16
  import { useIntelRouterContext } from "@/router/router-context.ts";
17
17
 
18
18
  // One tree is the whole navigation: documents and flows side by side in the folder they share, with
19
- // no Knowledge or Flows entry above them (ADR-0004). Tools stay below, next to the user, because
19
+ // no Intelligence or Flows entry above them (ADR-0004). Tools stay below, next to the user, because
20
20
  // they come live from the portal and have no tree.
21
21
  export function AppSidebar({
22
22
  width,
@@ -45,7 +45,7 @@ export function AppSidebar({
45
45
  <SidebarMenu>
46
46
  <SidebarMenuItem>
47
47
  <SidebarMenuButton size="lg" asChild>
48
- <Link to="/knowledge">
48
+ <Link to="/nodes">
49
49
  <AppLogo className="size-8" />
50
50
  <span className="font-semibold">{i18n.t("app.name")}</span>
51
51
  </Link>