@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
@@ -1,23 +1,29 @@
1
- import type { KnowledgeNode } from "@anchrd/intel-contract";
1
+ import type { Node } from "@anchrd/intel-contract";
2
2
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
3
  import { useNavigate, useRouterState } from "@tanstack/react-router";
4
4
  import { Download, History, Paperclip } from "lucide-react";
5
5
  import { lazy, Suspense, useState } from "react";
6
+ import { AgentAvatar } from "@/agent/agent-avatar/agent-avatar.tsx";
6
7
  import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
7
8
  import { ViewToggle } from "@/app/view-toggle/view-toggle.tsx";
8
9
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
9
10
  import { FolderContents } from "@/folder-contents/folder-contents.tsx";
10
11
  import { GraphPane } from "@/graph-pane/graph-pane.tsx";
11
- import { KnowledgeTablePanel } from "@/knowledge-table/knowledge-table.tsx";
12
+ import { NodeTablePanel } from "@/node-table/node-table.tsx";
12
13
  import { useIntelRouterContext } from "@/router/router-context.ts";
13
14
  import { graphViewFrom, selectedFrom } from "@/router/selection-search.ts";
14
15
  import { TitleRow } from "@/title-row/title-row.tsx";
15
16
 
16
- const KnowledgeEditor = lazy(async () => ({
17
- default: (await import("@/knowledge-editor/knowledge-editor.tsx")).KnowledgeEditor,
17
+ const NodeEditor = lazy(async () => ({
18
+ default: (await import("@/node-editor/node-editor.tsx")).NodeEditor,
19
+ }));
20
+ // Lazy for the same reason the editor is: assistant-ui and its stream client are the agent page's
21
+ // alone, and every other screen would otherwise pay for them on first paint.
22
+ const AgentPanel = lazy(async () => ({
23
+ default: (await import("@/agent/agent.tsx")).AgentPanel,
18
24
  }));
19
25
 
20
- export function Knowledge() {
26
+ export function Nodes() {
21
27
  const { data, i18n } = useIntelRouterContext();
22
28
  const queryClient = useQueryClient();
23
29
  const navigate = useNavigate();
@@ -32,8 +38,8 @@ export function Knowledge() {
32
38
  const selectedId = selection.id;
33
39
  const [versionsOpen, setVersionsOpen] = useState(false);
34
40
  const document = useQuery({
35
- queryKey: ["knowledge", selectedId],
36
- queryFn: () => data.getKnowledge(selectedId ?? ""),
41
+ queryKey: ["node", selectedId],
42
+ queryFn: () => data.getNode(selectedId ?? ""),
37
43
  enabled: Boolean(selectedId),
38
44
  });
39
45
  const selected = document.data?.node ?? null;
@@ -47,7 +53,7 @@ export function Knowledge() {
47
53
  });
48
54
  return (
49
55
  <div className="flex h-full min-h-0 flex-col">
50
- {/* The screen owns the one action that belongs to Knowledge as a whole. Creating things is
56
+ {/* The screen owns the one action that belongs to the tree as a whole. Creating things is
51
57
  the tree's, in the sidebar, at the place a new thing is meant to go.
52
58
 
53
59
  ⚠️ Mounted only where there is a graph to switch to. A switch on a document would point at
@@ -67,21 +73,21 @@ export function Knowledge() {
67
73
  query={relations}
68
74
  select={(node) =>
69
75
  void navigate({
70
- to: node.kind === "flow" ? "/flows" : "/knowledge",
76
+ to: node.kind === "flow" ? "/flows" : "/nodes",
71
77
  search: { select: node.id },
72
78
  })
73
79
  }
74
80
  />
75
81
  ) : !selectedId ? (
76
82
  <div className="grid flex-1 place-items-center p-8 text-center text-sm text-muted-foreground">
77
- {i18n.t("knowledge.select")}
83
+ {i18n.t("node.select")}
78
84
  </div>
79
85
  ) : document.isPending ? (
80
86
  <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
81
87
  ) : document.isError || !selected ? (
82
88
  <div role="alert" className="grid flex-1 place-items-center p-8 text-center text-sm">
83
89
  <div className="space-y-3">
84
- <p className="text-destructive">{i18n.t("knowledge.loadFailed")}</p>
90
+ <p className="text-destructive">{i18n.t("node.loadFailed")}</p>
85
91
  <button
86
92
  type="button"
87
93
  onClick={() => void document.refetch()}
@@ -106,7 +112,8 @@ export function Knowledge() {
106
112
  <TitleRow
107
113
  title={selected.title}
108
114
  description={selected.description}
109
- target={{ type: "knowledge", node: selected }}
115
+ target={{ type: "node", node: selected }}
116
+ leading={selected.kind === "agent" ? <AgentAvatar title={selected.title} /> : null}
110
117
  >
111
118
  {selected.kind !== "folder" && (
112
119
  <TooltipProvider delayDuration={300}>
@@ -114,13 +121,13 @@ export function Knowledge() {
114
121
  <TooltipTrigger
115
122
  type="button"
116
123
  onClick={() => setVersionsOpen((value) => !value)}
117
- aria-label={i18n.t("knowledge.versions")}
124
+ aria-label={i18n.t("node.versions")}
118
125
  aria-expanded={versionsOpen}
119
126
  className="inline-flex size-8 items-center justify-center rounded-md border bg-background outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
120
127
  >
121
128
  <History aria-hidden="true" className="size-4" />
122
129
  </TooltipTrigger>
123
- <TooltipContent>{i18n.t("knowledge.versions")}</TooltipContent>
130
+ <TooltipContent>{i18n.t("node.versions")}</TooltipContent>
124
131
  </Tooltip>
125
132
  </TooltipProvider>
126
133
  )}
@@ -130,7 +137,7 @@ export function Knowledge() {
130
137
  query={relations}
131
138
  select={(node) =>
132
139
  void navigate({
133
- to: node.kind === "flow" ? "/flows" : "/knowledge",
140
+ to: node.kind === "flow" ? "/flows" : "/nodes",
134
141
  search: { select: node.id },
135
142
  })
136
143
  }
@@ -140,20 +147,30 @@ export function Knowledge() {
140
147
  ) : selected.kind === "attachment" ? (
141
148
  <AttachmentPanel node={selected} />
142
149
  ) : selected.kind === "table" ? (
143
- <KnowledgeTablePanel node={selected} />
150
+ <NodeTablePanel node={selected} />
151
+ ) : selected.kind === "agent" ? (
152
+ // ⚠️ Before the editor branch, and never reaching it: an agent's version body is a
153
+ // definition document, not prose, and BlockNote would try to open JSON as a text.
154
+ <Suspense
155
+ fallback={
156
+ <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
157
+ }
158
+ >
159
+ <AgentPanel key={selected.id} node={selected} />
160
+ </Suspense>
144
161
  ) : document.data ? (
145
162
  <Suspense
146
163
  fallback={
147
164
  <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
148
165
  }
149
166
  >
150
- <KnowledgeEditor
167
+ <NodeEditor
151
168
  key={document.data.node.id}
152
169
  data={data}
153
170
  document={document.data}
154
171
  i18n={i18n}
155
172
  onSaved={(saved) => {
156
- queryClient.setQueryData(["knowledge", selected.id], saved);
173
+ queryClient.setQueryData(["node", selected.id], saved);
157
174
  }}
158
175
  />
159
176
  </Suspense>
@@ -168,10 +185,10 @@ export function Knowledge() {
168
185
  );
169
186
  }
170
187
 
171
- function AttachmentPanel({ node }: { node: KnowledgeNode }) {
188
+ function AttachmentPanel({ node }: { node: Node }) {
172
189
  const { data, i18n } = useIntelRouterContext();
173
190
  const download = useMutation({
174
- mutationFn: () => data.getKnowledgeAttachment(node.id),
191
+ mutationFn: () => data.getNodeAttachment(node.id),
175
192
  onSuccess: (blob) => {
176
193
  const url = URL.createObjectURL(blob);
177
194
  const anchor = document.createElement("a");
@@ -188,7 +205,7 @@ function AttachmentPanel({ node }: { node: KnowledgeNode }) {
188
205
  <div className="max-w-md rounded-xl border bg-background p-8 text-center shadow-sm">
189
206
  <Paperclip aria-hidden="true" className="mx-auto size-10 text-primary" />
190
207
  <h3 className="mt-4 font-semibold">{node.title}</h3>
191
- <p className="mt-2 text-sm text-muted-foreground">{i18n.t("knowledge.attachmentHelp")}</p>
208
+ <p className="mt-2 text-sm text-muted-foreground">{i18n.t("node.attachmentHelp")}</p>
192
209
  <button
193
210
  type="button"
194
211
  onClick={() => download.mutate()}
@@ -196,11 +213,11 @@ function AttachmentPanel({ node }: { node: KnowledgeNode }) {
196
213
  className="mt-5 inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
197
214
  >
198
215
  <Download aria-hidden="true" className="size-4" />
199
- {download.isPending ? i18n.t("common.loading") : i18n.t("knowledge.download")}
216
+ {download.isPending ? i18n.t("common.loading") : i18n.t("node.download")}
200
217
  </button>
201
218
  {download.isError ? (
202
219
  <p role="alert" className="mt-4 text-sm text-destructive">
203
- {i18n.t("knowledge.downloadFailed")}
220
+ {i18n.t("node.downloadFailed")}
204
221
  </p>
205
222
  ) : null}
206
223
  </div>
@@ -211,16 +228,16 @@ function AttachmentPanel({ node }: { node: KnowledgeNode }) {
211
228
  function VersionHistory({ nodeId, close }: { nodeId: string; close(): void }) {
212
229
  const { data, i18n } = useIntelRouterContext();
213
230
  const versions = useQuery({
214
- queryKey: ["knowledge-versions", nodeId],
215
- queryFn: () => data.listKnowledgeVersions(nodeId),
231
+ queryKey: ["node-versions", nodeId],
232
+ queryFn: () => data.listNodeVersions(nodeId),
216
233
  });
217
234
  return (
218
235
  <aside
219
- aria-label={i18n.t("knowledge.versions")}
236
+ aria-label={i18n.t("node.versions")}
220
237
  className="absolute bottom-0 right-0 top-0 z-10 w-80 overflow-y-auto border-l bg-card p-5 shadow-xl"
221
238
  >
222
239
  <div className="flex items-start justify-between gap-4">
223
- <h3 className="font-semibold">{i18n.t("knowledge.versions")}</h3>
240
+ <h3 className="font-semibold">{i18n.t("node.versions")}</h3>
224
241
  <button
225
242
  type="button"
226
243
  onClick={close}
@@ -235,14 +252,14 @@ function VersionHistory({ nodeId, close }: { nodeId: string; close(): void }) {
235
252
  ) : null}
236
253
  {versions.isError ? (
237
254
  <p role="alert" className="mt-4 text-sm text-destructive">
238
- {i18n.t("knowledge.operationFailed")}
255
+ {i18n.t("node.operationFailed")}
239
256
  </p>
240
257
  ) : null}
241
258
  <ol className="mt-4 space-y-3">
242
259
  {versions.data?.items.map((version) => (
243
260
  <li key={version.id} className="rounded-md border p-3 text-sm">
244
261
  <span className="font-medium">
245
- {i18n.t("knowledge.version", { sequence: version.sequence })}
262
+ {i18n.t("node.version", { sequence: version.sequence })}
246
263
  </span>
247
264
  <time className="mt-1 block text-xs text-muted-foreground" dateTime={version.createdAt}>
248
265
  {new Intl.DateTimeFormat(i18n.locale, {
@@ -1,9 +1,10 @@
1
1
  import type {
2
2
  Flow,
3
3
  FlowValidation,
4
- KnowledgeNode,
4
+ Node,
5
+ NodeKind,
5
6
  ResourceVerb,
6
- UnreadableKnowledge,
7
+ UnreadableNodes,
7
8
  } from "@anchrd/intel-contract";
8
9
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
9
10
  import { useNavigate, useRouterState } from "@tanstack/react-router";
@@ -11,6 +12,7 @@ import {
11
12
  Archive,
12
13
  CornerLeftUp,
13
14
  Ellipsis,
15
+ FolderDown,
14
16
  Link2,
15
17
  Pencil,
16
18
  Share2,
@@ -46,9 +48,7 @@ import { selectedFrom } from "@/router/selection-search.ts";
46
48
  * is shared through the folder it is filed in rather than on its own (ADR-0004 §2). A greyed-out row
47
49
  * still promises something is there.
48
50
  */
49
- export type ResourceTarget =
50
- | { type: "knowledge"; node: KnowledgeNode }
51
- | { type: "flow"; flow: Flow };
51
+ export type ResourceTarget = { type: "node"; node: Node } | { type: "flow"; flow: Flow };
52
52
 
53
53
  // The same thing as a tree row, because that is what a move works on: a row with a place in the
54
54
  // shared tree. The two shapes are the same record seen from two screens, so this is a re-labelling
@@ -57,26 +57,41 @@ function entryOf(target: ResourceTarget): TreeEntry {
57
57
  return target.type === "flow"
58
58
  ? flowEntry(target.flow)
59
59
  : {
60
- type: "knowledge",
60
+ type: "node",
61
61
  id: target.node.id,
62
62
  title: target.node.title,
63
63
  kind: target.node.kind,
64
- // Ein Menü verschiebt eine Zeile; ob sie sich aufklappen lässt, spielt dabei keine Rolle.
64
+ // A menu moves a row; whether it can be expanded plays no part in that.
65
65
  openable: false,
66
66
  node: target.node,
67
67
  };
68
68
  }
69
69
 
70
70
  function idOf(target: ResourceTarget): string {
71
- return target.type === "knowledge" ? target.node.id : target.flow.id;
71
+ return target.type === "node" ? target.node.id : target.flow.id;
72
72
  }
73
73
 
74
74
  function titleOf(target: ResourceTarget): string {
75
- return target.type === "knowledge" ? target.node.title : target.flow.title;
75
+ return target.type === "node" ? target.node.title : target.flow.title;
76
76
  }
77
77
 
78
78
  function parentOfTarget(target: ResourceTarget): string | null {
79
- return target.type === "knowledge" ? target.node.parentId : target.flow.parentId;
79
+ return target.type === "node" ? target.node.parentId : target.flow.parentId;
80
+ }
81
+
82
+ /**
83
+ * Which sentence explains a verb, for THIS kind of thing.
84
+ *
85
+ * ⚠️ "Run" does not mean the same thing twice. On a folder it grants starting the flows filed in
86
+ * it; on an agent it grants using the agent — through its chat, its schedules and its MCP address.
87
+ * Until #143 there was one sentence for both, and on an agent it named flows that have nothing to
88
+ * do with the grant being made. A key per kind, falling back to the general one, so a kind that
89
+ * needs no special wording needs no entry.
90
+ */
91
+ export function verbHintKey(verb: ResourceVerb, kind: NodeKind): string {
92
+ return verb === "execute" && kind === "agent"
93
+ ? "node.verbHint.execute.agent"
94
+ : `node.verbHint.${verb}`;
80
95
  }
81
96
 
82
97
  // ⚠️ The same reading `moveErrorKey` does for a move, for the changes this menu makes. A conflict is
@@ -91,7 +106,7 @@ export function resourceErrorKey(error: unknown): string {
91
106
  case "update_conflict":
92
107
  case "flow_update_conflict":
93
108
  return "resource.conflict";
94
- case "knowledge_forbidden":
109
+ case "node_forbidden":
95
110
  case "flow_edit_forbidden":
96
111
  return "resource.forbidden";
97
112
  default:
@@ -141,7 +156,7 @@ export function ResourceMenu({
141
156
 
142
157
  const id = idOf(target);
143
158
  const title = titleOf(target);
144
- const node = target.type === "knowledge" ? target.node : null;
159
+ const node = target.type === "node" ? target.node : null;
145
160
  // A folder holds things; it is not itself retrieved, and nothing is written into it that could
146
161
  // link anywhere. Both entries are therefore absent on one rather than offered and inert.
147
162
  const retrievable = node !== null && node.kind !== "folder";
@@ -155,10 +170,10 @@ export function ResourceMenu({
155
170
  await Promise.all([
156
171
  queryClient.invalidateQueries({ queryKey: ["tree", parentOfTarget(target)] }),
157
172
  queryClient.invalidateQueries({
158
- queryKey: target.type === "flow" ? ["flow", id] : ["knowledge", id],
173
+ queryKey: target.type === "flow" ? ["flow", id] : ["node", id],
159
174
  }),
160
175
  queryClient.invalidateQueries({
161
- queryKey: [target.type === "flow" ? "flows" : "knowledge-graph"],
176
+ queryKey: [target.type === "flow" ? "flows" : "node-graph"],
162
177
  }),
163
178
  queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
164
179
  ]);
@@ -177,7 +192,7 @@ export function ResourceMenu({
177
192
  });
178
193
  return;
179
194
  }
180
- await data.updateKnowledge({
195
+ await data.updateNode({
181
196
  nodeId: target.node.id,
182
197
  baseUpdatedAt: target.node.updatedAt,
183
198
  title: next,
@@ -204,7 +219,7 @@ export function ResourceMenu({
204
219
  });
205
220
  return;
206
221
  }
207
- await data.archiveKnowledge({
222
+ await data.archiveNode({
208
223
  nodeId: target.node.id,
209
224
  baseUpdatedAt: target.node.updatedAt,
210
225
  archived: true,
@@ -216,16 +231,36 @@ export function ResourceMenu({
216
231
  // The row is gone from the tree. If it was also what the screen was showing, the screen has
217
232
  // nothing left to show either.
218
233
  //
219
- // ⚠️ It drops the selection where it stands rather than always landing on /knowledge: a flow
234
+ // ⚠️ It drops the selection where it stands rather than always landing on /nodes: a flow
220
235
  // archived from the flow screen would otherwise throw the reader onto another screen for no
221
- // reason they could see. A flow reached through the shared tree stays on /knowledge, which is
236
+ // reason they could see. A flow reached through the shared tree stays on /nodes, which is
222
237
  // where it was being read.
223
238
  if (selected === id) {
224
- await navigate({ to: pathname === "/flows" ? "/flows" : "/knowledge", search: {} });
239
+ await navigate({ to: pathname === "/flows" ? "/flows" : "/nodes", search: {} });
225
240
  }
226
241
  },
227
242
  });
228
243
 
244
+ // The export as the user takes it home (#136): the streamed zip as a saved file, named after the
245
+ // thing itself. The same object-URL dance the attachment download does, because it is the same
246
+ // gesture — the data provider streams, the browser saves.
247
+ const exportBundle = useMutation({
248
+ mutationFn: async () =>
249
+ target.type === "flow"
250
+ ? await data.exportFlowBundle(target.flow.id)
251
+ : await data.exportNodeBundle(target.node.id),
252
+ onSuccess: (blob) => {
253
+ const url = URL.createObjectURL(blob);
254
+ const anchor = document.createElement("a");
255
+ anchor.href = url;
256
+ anchor.download = `${title}.zip`;
257
+ document.body.append(anchor);
258
+ anchor.click();
259
+ anchor.remove();
260
+ setTimeout(() => URL.revokeObjectURL(url), 0);
261
+ },
262
+ });
263
+
229
264
  const failure = rename.isError
230
265
  ? null // the rename dialog words its own refusal, beside the field that caused it
231
266
  : archive.isError
@@ -255,6 +290,13 @@ export function ResourceMenu({
255
290
  <CornerLeftUp aria-hidden="true" />
256
291
  {i18n.t("tree.move.action")}
257
292
  </DropdownMenuItem>
293
+ {/* Every kind exports: a folder takes its subtree along, everything else is a bundle of
294
+ one (#136). The entry sits with rename and move because it acts on the whole thing,
295
+ not on its content. */}
296
+ <DropdownMenuItem onSelect={() => exportBundle.mutate()}>
297
+ <FolderDown aria-hidden="true" />
298
+ {i18n.t("resource.export")}
299
+ </DropdownMenuItem>
258
300
  {/* Only a flow can be run, so only a flow can be asked whether it would. Rarely needed —
259
301
  which is why it is in the menu and not in the title line (#72). */}
260
302
  {node === null ? (
@@ -267,7 +309,7 @@ export function ResourceMenu({
267
309
  {linkable ? (
268
310
  <DropdownMenuItem onSelect={() => setLinksOpen(true)}>
269
311
  <Link2 aria-hidden="true" />
270
- {i18n.t("knowledge.links")}
312
+ {i18n.t("node.links")}
271
313
  </DropdownMenuItem>
272
314
  ) : null}
273
315
  {/* A flow has no share of its own: it is reached through the folder it is filed in, and a
@@ -276,7 +318,7 @@ export function ResourceMenu({
276
318
  {node ? (
277
319
  <DropdownMenuItem onSelect={() => setSharing(true)}>
278
320
  <Share2 aria-hidden="true" />
279
- {i18n.t("knowledge.share")}
321
+ {i18n.t("node.share")}
280
322
  </DropdownMenuItem>
281
323
  ) : null}
282
324
  {/* A flow is archived like a document (#109). It keeps its versions and its runs; what it
@@ -297,6 +339,9 @@ export function ResourceMenu({
297
339
  {failure !== undefined && failure !== null ? (
298
340
  <MenuFailure>{i18n.t(resourceErrorKey(failure))}</MenuFailure>
299
341
  ) : null}
342
+ {/* Its own sentence, not `resourceErrorKey`'s: nothing was changed, something failed to
343
+ arrive, and "the change was not saved" would send the reader looking for a change. */}
344
+ {exportBundle.isError ? <MenuFailure>{i18n.t("resource.exportFailed")}</MenuFailure> : null}
300
345
  {/* ⚠️ A move has four refusals of its own and they are told apart by `moveErrorKey`, not by
301
346
  `resourceErrorKey`: "that is not a folder" and "that would be a loop" have no equivalent
302
347
  among the changes above, and one shared sentence would leave the reader guessing. */}
@@ -341,8 +386,8 @@ export function ResourceMenu({
341
386
  submit={(next) => rename.mutate(next)}
342
387
  />
343
388
  ) : null}
344
- {sharing && node ? <ShareKnowledge node={node} close={() => setSharing(false)} /> : null}
345
- {linksOpen && node ? <KnowledgeLinks node={node} close={() => setLinksOpen(false)} /> : null}
389
+ {sharing && node ? <SharePanel node={node} close={() => setSharing(false)} /> : null}
390
+ {linksOpen && node ? <NodeLinksPanel node={node} close={() => setLinksOpen(false)} /> : null}
346
391
  </>
347
392
  );
348
393
  }
@@ -423,27 +468,27 @@ function RenameDialog({
423
468
  * the same predicate as every other read. A relationship reaching into a part of the tree they may
424
469
  * not enter is absent rather than shown without a name.
425
470
  */
426
- function KnowledgeLinks({ node, close }: { node: KnowledgeNode; close(): void }) {
471
+ function NodeLinksPanel({ node, close }: { node: Node; close(): void }) {
427
472
  const { data, i18n } = useIntelRouterContext();
428
473
  const navigate = useNavigate();
429
474
  const links = useQuery({
430
- queryKey: ["knowledge-links", node.id],
431
- queryFn: () => data.listKnowledgeLinks(node.id),
475
+ queryKey: ["node-links", node.id],
476
+ queryFn: () => data.listNodeLinks(node.id),
432
477
  });
433
478
  // The dialog needs the names of the documents at the other end, and the graph is the one
434
479
  // authorized list of them the UI can ask for without walking the whole tree again. It is asked
435
480
  // for here rather than by the screen, because here is where it is read.
436
- const knowledgeGraph = useQuery({
437
- queryKey: ["knowledge-graph"],
438
- queryFn: () => data.getKnowledgeGraph(),
481
+ const nodeGraph = useQuery({
482
+ queryKey: ["node-graph"],
483
+ queryFn: () => data.getNodeGraph(),
439
484
  });
440
485
  const titles = new Map(
441
- (knowledgeGraph.data?.nodes ?? []).map((candidate) => [candidate.id, candidate.title]),
486
+ (nodeGraph.data?.nodes ?? []).map((candidate) => [candidate.id, candidate.title]),
442
487
  );
443
488
 
444
489
  return (
445
- <Modal title={i18n.t("knowledge.links")} close={close}>
446
- <p className="mb-4 text-sm text-muted-foreground">{i18n.t("knowledge.linksHelp")}</p>
490
+ <Modal title={i18n.t("node.links")} close={close}>
491
+ <p className="mb-4 text-sm text-muted-foreground">{i18n.t("node.linksHelp")}</p>
447
492
  <ul className="max-h-72 space-y-2 overflow-y-auto">
448
493
  {links.data?.items.map((link) => {
449
494
  const outgoing = link.sourceNodeId === node.id;
@@ -454,7 +499,7 @@ function KnowledgeLinks({ node, close }: { node: KnowledgeNode; close(): void })
454
499
  type="button"
455
500
  onClick={() => {
456
501
  close();
457
- void navigate({ to: "/knowledge", search: { select: otherId } });
502
+ void navigate({ to: "/nodes", search: { select: otherId } });
458
503
  }}
459
504
  className="flex w-full items-center gap-3 rounded-md border p-3 text-left outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
460
505
  >
@@ -463,7 +508,7 @@ function KnowledgeLinks({ node, close }: { node: KnowledgeNode; close(): void })
463
508
  {titles.get(otherId) ?? otherId}
464
509
  </span>
465
510
  <span className="text-xs text-muted-foreground">
466
- {outgoing ? i18n.t("knowledge.linkOutgoing") : i18n.t("knowledge.linkIncoming")}
511
+ {outgoing ? i18n.t("node.linkOutgoing") : i18n.t("node.linkIncoming")}
467
512
  </span>
468
513
  </span>
469
514
  </button>
@@ -471,7 +516,7 @@ function KnowledgeLinks({ node, close }: { node: KnowledgeNode; close(): void })
471
516
  );
472
517
  })}
473
518
  {links.data?.items.length === 0 && (
474
- <li className="text-sm text-muted-foreground">{i18n.t("knowledge.noLinks")}</li>
519
+ <li className="text-sm text-muted-foreground">{i18n.t("node.noLinks")}</li>
475
520
  )}
476
521
  </ul>
477
522
  </Modal>
@@ -482,28 +527,28 @@ function KnowledgeLinks({ node, close }: { node: KnowledgeNode; close(): void })
482
527
  // flows, which is where a permission decision belongs (ADR-0004 §2). Which verbs it offers is the
483
528
  // server's answer, not this component's: `execute` never appears on a document, because a document
484
529
  // has nothing to run.
485
- function ShareKnowledge({ node, close }: { node: KnowledgeNode; close(): void }) {
530
+ function SharePanel({ node, close }: { node: Node; close(): void }) {
486
531
  const { data, i18n } = useIntelRouterContext();
487
532
  const queryClient = useQueryClient();
488
533
  const [email, setEmail] = useState("");
489
534
  const [verbs, setVerbs] = useState<ResourceVerb[]>(["read"]);
490
535
  // What the grant just made does not cover. It survives the form being cleared, because it is the
491
536
  // answer to the question the user just asked and they need a moment to read it.
492
- const [unreadable, setUnreadable] = useState<UnreadableKnowledge | null>(null);
537
+ const [unreadable, setUnreadable] = useState<UnreadableNodes | null>(null);
493
538
  const grants = useQuery({
494
- queryKey: ["knowledge-grants", node.id],
495
- queryFn: () => data.listKnowledgeGrants(node.id),
539
+ queryKey: ["node-grants", node.id],
540
+ queryFn: () => data.listGrants(node.id),
496
541
  });
497
542
  const share = useMutation({
498
543
  // One request per verb, because one grant is one verb. The keys differ so a retry of the whole
499
544
  // form replays each verb on its own rather than collapsing them into one.
500
545
  mutationFn: async () => {
501
- let last: UnreadableKnowledge | null = null;
546
+ let last: UnreadableNodes | null = null;
502
547
  for (const verb of verbs) {
503
548
  // The last verb's answer is the one kept: every request describes the access in force after
504
549
  // it, so the newest is the only one still true.
505
550
  last = (
506
- await data.shareKnowledge({
551
+ await data.shareNode({
507
552
  resourceId: node.id,
508
553
  principal: { type: "email", email },
509
554
  verb,
@@ -523,46 +568,44 @@ function ShareKnowledge({ node, close }: { node: KnowledgeNode; close(): void })
523
568
  // before, and the user would read a permission picture that is not the one in force. Of all the
524
569
  // things to be silently wrong about, access is the worst.
525
570
  onSettled: async () => {
526
- await queryClient.invalidateQueries({ queryKey: ["knowledge-grants", node.id] });
571
+ await queryClient.invalidateQueries({ queryKey: ["node-grants", node.id] });
527
572
  },
528
573
  });
529
574
  const revoke = useMutation({
530
575
  mutationFn: (grantId: string) =>
531
- data.revokeKnowledgeGrant({
576
+ data.revokeGrant({
532
577
  resourceId: node.id,
533
578
  grantId,
534
579
  idempotencyKey: crypto.randomUUID(),
535
580
  }),
536
581
  onSettled: async () => {
537
- await queryClient.invalidateQueries({ queryKey: ["knowledge-grants", node.id] });
582
+ await queryClient.invalidateQueries({ queryKey: ["node-grants", node.id] });
538
583
  },
539
584
  });
540
585
 
541
586
  return (
542
- <Modal title={i18n.t("knowledge.share")} close={close}>
587
+ <Modal title={i18n.t("node.share")} close={close}>
543
588
  {(share.isError || revoke.isError || grants.isError) && (
544
589
  <p role="alert" className="mb-4 text-sm text-destructive">
545
- {i18n.t("knowledge.shareFailed")}
590
+ {i18n.t("node.shareFailed")}
546
591
  </p>
547
592
  )}
548
593
  {/* ⚠️ `status`, not `alert`, and beside the grant rather than in place of it: the access was
549
- given. A knowledge reference across the folder edge is a possible failure, not a way around
594
+ given. A node reference across the folder edge is a possible failure, not a way around
550
595
  permissions, so nothing here blocks anything (ADR-0004 §4). */}
551
596
  {unreadable && (unreadable.titles.length > 0 || unreadable.hidden > 0) && (
552
597
  <div role="status" className="mb-4 rounded-md border bg-muted p-3 text-sm">
553
598
  {unreadable.titles.length > 0 ? (
554
599
  <p>
555
- {i18n.t("knowledge.shareUnreadable", { titles: unreadable.titles.join(", ") })}
600
+ {i18n.t("node.shareUnreadable", { titles: unreadable.titles.join(", ") })}
556
601
  {unreadable.hidden > 0
557
- ? ` ${i18n.t("knowledge.shareUnreadableMore", { count: unreadable.hidden })}`
602
+ ? ` ${i18n.t("node.shareUnreadableMore", { count: unreadable.hidden })}`
558
603
  : ""}
559
604
  </p>
560
605
  ) : (
561
- <p>{i18n.t("knowledge.shareUnreadableHidden", { count: unreadable.hidden })}</p>
606
+ <p>{i18n.t("node.shareUnreadableHidden", { count: unreadable.hidden })}</p>
562
607
  )}
563
- <p className="mt-1 text-xs text-muted-foreground">
564
- {i18n.t("knowledge.shareUnreadableHint")}
565
- </p>
608
+ <p className="mt-1 text-xs text-muted-foreground">{i18n.t("node.shareUnreadableHint")}</p>
566
609
  </div>
567
610
  )}
568
611
  <ul className="mb-5 max-h-44 space-y-2 overflow-y-auto">
@@ -576,15 +619,15 @@ function ShareKnowledge({ node, close }: { node: KnowledgeNode; close(): void })
576
619
  ? grant.principal.email
577
620
  : grant.principal.type === "user"
578
621
  ? grant.principal.id
579
- : i18n.t("knowledge.organization")}
622
+ : i18n.t("node.organization")}
580
623
  <span className="ml-2 text-xs text-muted-foreground">
581
- {i18n.t(`knowledge.verb.${grant.verb}`)}
624
+ {i18n.t(`node.verb.${grant.verb}`)}
582
625
  </span>
583
626
  </span>
584
627
  <button
585
628
  type="button"
586
629
  onClick={() => revoke.mutate(grant.id)}
587
- aria-label={i18n.t("knowledge.revokeShare")}
630
+ aria-label={i18n.t("node.revokeShare")}
588
631
  className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
589
632
  >
590
633
  <Trash2 aria-hidden="true" className="size-4" />
@@ -592,7 +635,7 @@ function ShareKnowledge({ node, close }: { node: KnowledgeNode; close(): void })
592
635
  </li>
593
636
  ))}
594
637
  {grants.data?.items.length === 0 && (
595
- <li className="text-sm text-muted-foreground">{i18n.t("knowledge.noGrants")}</li>
638
+ <li className="text-sm text-muted-foreground">{i18n.t("node.noGrants")}</li>
596
639
  )}
597
640
  </ul>
598
641
  <form
@@ -603,7 +646,7 @@ function ShareKnowledge({ node, close }: { node: KnowledgeNode; close(): void })
603
646
  className="space-y-4 border-t pt-5"
604
647
  >
605
648
  <label className="block text-sm font-medium">
606
- {i18n.t("knowledge.email")}
649
+ {i18n.t("node.email")}
607
650
  <input
608
651
  type="email"
609
652
  required
@@ -613,7 +656,7 @@ function ShareKnowledge({ node, close }: { node: KnowledgeNode; close(): void })
613
656
  />
614
657
  </label>
615
658
  <fieldset className="space-y-2">
616
- <legend className="text-sm font-medium">{i18n.t("knowledge.verbs")}</legend>
659
+ <legend className="text-sm font-medium">{i18n.t("node.verbs")}</legend>
617
660
  {(grants.data?.applicableVerbs ?? []).map((verb) => (
618
661
  <label key={verb} className="flex items-center gap-2 text-sm">
619
662
  <input
@@ -628,9 +671,9 @@ function ShareKnowledge({ node, close }: { node: KnowledgeNode; close(): void })
628
671
  }
629
672
  className="size-4 rounded border outline-none focus-visible:ring-2 focus-visible:ring-ring"
630
673
  />
631
- <span>{i18n.t(`knowledge.verb.${verb}`)}</span>
674
+ <span>{i18n.t(`node.verb.${verb}`)}</span>
632
675
  <span className="text-xs text-muted-foreground">
633
- {i18n.t(`knowledge.verbHint.${verb}`)}
676
+ {i18n.t(verbHintKey(verb, node.kind))}
634
677
  </span>
635
678
  </label>
636
679
  ))}
@@ -640,7 +683,7 @@ function ShareKnowledge({ node, close }: { node: KnowledgeNode; close(): void })
640
683
  disabled={share.isPending || verbs.length === 0}
641
684
  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"
642
685
  >
643
- {i18n.t("knowledge.shareAction")}
686
+ {i18n.t("node.shareAction")}
644
687
  </button>
645
688
  </form>
646
689
  </Modal>