@anchrd/intel-ui 0.8.7 → 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 (44) 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/app/user-footer/user-footer.tsx +7 -1
  20. package/src/archive/archive.tsx +154 -0
  21. package/src/components/ui/avatar.tsx +39 -0
  22. package/src/components/ui/select.tsx +163 -0
  23. package/src/components/ui/tabs.tsx +52 -0
  24. package/src/data/agent-runtime/agent-runtime.ts +93 -0
  25. package/src/data/intel-data-provider/intel-data-provider.ts +206 -120
  26. package/src/data/intel-data-provider/intel-data-provider.types.ts +112 -54
  27. package/src/entry-picker/entry-picker.tsx +53 -17
  28. package/src/flow-runs/flow-runs.tsx +1 -1
  29. package/src/flows/flows.tsx +20 -20
  30. package/src/folder-contents/folder-contents.tsx +7 -7
  31. package/src/graph-pane/graph-pane.tsx +1 -1
  32. package/src/hooks/use-capabilities.ts +22 -0
  33. package/src/i18n/en.json +156 -64
  34. package/src/kind-icon.ts +5 -2
  35. package/src/{knowledge-editor/knowledge-editor.tsx → node-editor/node-editor.tsx} +20 -20
  36. package/src/{knowledge-graph → node-graph}/graph-notice.tsx +1 -1
  37. package/src/{knowledge-graph/knowledge-graph.tsx → node-graph/node-graph.tsx} +4 -4
  38. package/src/{knowledge-table/knowledge-table.tsx → node-table/node-table.tsx} +14 -14
  39. package/src/{knowledge/knowledge.tsx → nodes/nodes.tsx} +46 -29
  40. package/src/resource-menu/resource-menu.tsx +105 -62
  41. package/src/router/router.tsx +17 -5
  42. package/src/title-row/title-row.tsx +13 -5
  43. package/vite.config.ts +4 -0
  44. /package/src/{knowledge-graph/knowledge-graph.ts → node-graph/node-graph.ts} +0 -0
@@ -1,17 +1,21 @@
1
1
  import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
2
2
  import { useNavigate, useRouterState } from "@tanstack/react-router";
3
+ import { zipSync } from "fflate";
3
4
  import {
4
5
  ChevronRight,
5
6
  CornerLeftUp,
7
+ FileArchive,
6
8
  FileText,
7
9
  Folder,
8
10
  FolderOpen,
11
+ FolderUp,
9
12
  Plus,
10
13
  Table,
11
14
  Upload,
12
15
  Workflow,
13
16
  } from "lucide-react";
14
17
  import { useRef, useState } from "react";
18
+ import { defaultModel } from "@/agent/agent-models/agent-models.ts";
15
19
  import {
16
20
  type MoveDestination,
17
21
  moveErrorKey,
@@ -35,17 +39,39 @@ import {
35
39
  } from "@/components/ui/sidebar";
36
40
  import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
37
41
  import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
42
+ import { useCapabilities } from "@/hooks/use-capabilities.ts";
38
43
  import { kindIcons } from "@/kind-icon.ts";
39
44
  import { Modal } from "@/modal/modal.tsx";
40
45
  import { useIntelRouterContext } from "@/router/router-context.ts";
41
46
  import { selectedFrom } from "@/router/selection-search.ts";
42
47
 
43
- type NewKind = "folder" | "document" | "table" | "flow";
48
+ type NewKind = "folder" | "document" | "table" | "flow" | "agent";
49
+ // The two ways a bundle arrives (#137): an existing zip, or a picked folder the browser hands over
50
+ // file by file and this component zips before it travels — one wire format, one server door.
51
+ type ImportKind = "importZip" | "importFolder";
44
52
  type Creating = { parentId: string | null; kind: NewKind };
45
53
 
46
54
  // Attachments are inlined as base64, so the browser holds the file twice while it uploads.
47
55
  const MaxUploadBytes = 15_000_000;
48
56
 
57
+ // The server refuses anything larger anyway (#137); refusing here saves uploading it first.
58
+ const MaxImportBytes = 64 * 1024 * 1024;
59
+
60
+ // A picked directory as the zip the import endpoint reads. `webkitRelativePath` carries the folder
61
+ // structure, with the picked folder itself as the first segment — kept, so the folder lands as a
62
+ // folder rather than spilling its contents into the target.
63
+ async function zipOfDirectory(files: readonly File[]): Promise<Blob> {
64
+ const entries: Record<string, Uint8Array> = {};
65
+ let total = 0;
66
+ for (const file of files) {
67
+ const path = file.webkitRelativePath || file.name;
68
+ total += file.size;
69
+ if (total > MaxImportBytes) throw new Error("Import exceeds the 64 MB limit");
70
+ entries[path] = new Uint8Array(await file.arrayBuffer());
71
+ }
72
+ return new Blob([zipSync(entries).slice().buffer], { type: "application/zip" });
73
+ }
74
+
49
75
  async function fileBase64(file: File): Promise<string> {
50
76
  if (file.size > MaxUploadBytes) throw new Error("Attachment exceeds the 15 MB upload limit");
51
77
  return await new Promise((resolve, reject) => {
@@ -87,6 +113,9 @@ export function AppTree() {
87
113
  const [creating, setCreating] = useState<Creating | null>(null);
88
114
  const [uploadTo, setUploadTo] = useState<string | null>(null);
89
115
  const uploadInput = useRef<HTMLInputElement>(null);
116
+ const [importTo, setImportTo] = useState<string | null>(null);
117
+ const importZipInput = useRef<HTMLInputElement>(null);
118
+ const importFolderInput = useRef<HTMLInputElement>(null);
90
119
  const [dragged, setDragged] = useState<TreeEntry | null>(null);
91
120
  const draggedRef = useRef<TreeEntry | null>(null);
92
121
  // Which target the pointer is over: `undefined` for none, `null` for the root strip, an id for a
@@ -111,8 +140,8 @@ export function AppTree() {
111
140
  // ⚠️ The whole point of #11: one query per level, and a level only exists while its folder is
112
141
  // open. A recursive load would put an N+1 on every page of the app, because the tree is now on
113
142
  // every page.
114
- // Ein aufgeklappter Flow zeigt, was er ruftmit demselben Pfeil-Versprechen eine Ebene tiefer:
115
- // ein gerufener Flow, der selbst ruft, lässt sich weiter aufklappen (#59).
143
+ // An expanded flow shows what it callswith the same arrow promise one level deeper: a called
144
+ // flow that calls flows of its own can be expanded further (#59).
116
145
  async function flowCallRows(flowId: string): Promise<TreeEntry[]> {
117
146
  const calls = await data.listFlowCalls(flowId);
118
147
  const openable = new Set(calls.withCalls);
@@ -144,11 +173,11 @@ export function AppTree() {
144
173
  }
145
174
 
146
175
  // ⚠️ The level a row appears in is not the only thing that goes stale. The header search reads
147
- // the whole flow list, and the graph is what the link picker and the flow editor's Knowledge step
176
+ // the whole flow list, and the graph is what the link picker and the flow editor's tree links
148
177
  // offer — a row created here has to reach those too, or another screen keeps showing yesterday.
149
178
  async function reveal(
150
179
  parentId: string | null,
151
- collection: "flows" | "knowledge-graph",
180
+ collection: "flows" | "node-graph",
152
181
  ): Promise<void> {
153
182
  if (parentId !== null) toggle({ id: parentId, type: "folder" }, true);
154
183
  await Promise.all([
@@ -176,7 +205,25 @@ export function AppTree() {
176
205
  });
177
206
  return { area: "/flows" as const, id: flow.id, parentId, collection: "flows" as const };
178
207
  }
179
- const node = await data.createKnowledge({
208
+ // ⚠️ Its own endpoint, not `createNodes`: an agent is created WITH its first definition
209
+ // version, because a definition names the model and there is no agent without one. The
210
+ // create dialog does not ask for it — `defaultModel()` answers, and the profile changes it.
211
+ if (kind === "agent") {
212
+ const created = await data.createAgent({
213
+ parentId,
214
+ title,
215
+ description: null,
216
+ definition: { references: [], schedules: [], model: defaultModel() },
217
+ idempotencyKey: crypto.randomUUID(),
218
+ });
219
+ return {
220
+ area: "/nodes" as const,
221
+ id: created.node.id,
222
+ parentId,
223
+ collection: "node-graph" as const,
224
+ };
225
+ }
226
+ const node = await data.createNodes({
180
227
  parentId,
181
228
  kind,
182
229
  title,
@@ -188,17 +235,17 @@ export function AppTree() {
188
235
  // there refusing every append, so the second call happens here rather than being left to the
189
236
  // person who created it.
190
237
  if (kind === "table") {
191
- await data.defineKnowledgeTable({
238
+ await data.defineTable({
192
239
  nodeId: node.id,
193
240
  columns,
194
241
  idempotencyKey: crypto.randomUUID(),
195
242
  });
196
243
  }
197
244
  return {
198
- area: "/knowledge" as const,
245
+ area: "/nodes" as const,
199
246
  id: node.id,
200
247
  parentId,
201
- collection: "knowledge-graph" as const,
248
+ collection: "node-graph" as const,
202
249
  };
203
250
  },
204
251
  onSuccess: async (created) => {
@@ -211,7 +258,7 @@ export function AppTree() {
211
258
  const upload = useMutation({
212
259
  mutationFn: async ({ parentId, file }: { parentId: string | null; file: File }) => {
213
260
  const contentBase64 = await fileBase64(file);
214
- const node = await data.createKnowledge({
261
+ const node = await data.createNodes({
215
262
  parentId,
216
263
  kind: "attachment",
217
264
  title: file.name,
@@ -219,7 +266,7 @@ export function AppTree() {
219
266
  idempotencyKey: crypto.randomUUID(),
220
267
  });
221
268
  try {
222
- await data.saveKnowledgeAttachment({
269
+ await data.saveNodeAttachment({
223
270
  nodeId: node.id,
224
271
  baseVersionId: null,
225
272
  contentBase64,
@@ -229,10 +276,10 @@ export function AppTree() {
229
276
  } catch (error) {
230
277
  // An attachment node without bytes is a row nobody can open. Take it back rather than
231
278
  // leaving it in the tree, but never let the cleanup hide the real failure.
232
- const current = await data.getKnowledge(node.id).catch(() => null);
279
+ const current = await data.getNode(node.id).catch(() => null);
233
280
  if (current?.version === null && current.node.updatedAt === node.updatedAt) {
234
281
  await data
235
- .archiveKnowledge({
282
+ .archiveNode({
236
283
  nodeId: node.id,
237
284
  baseUpdatedAt: current.node.updatedAt,
238
285
  archived: true,
@@ -245,8 +292,28 @@ export function AppTree() {
245
292
  return { id: node.id, parentId };
246
293
  },
247
294
  onSuccess: async (created) => {
248
- await reveal(created.parentId, "knowledge-graph");
249
- await navigate({ to: "/knowledge", search: { select: created.id } });
295
+ await reveal(created.parentId, "node-graph");
296
+ await navigate({ to: "/nodes", search: { select: created.id } });
297
+ },
298
+ });
299
+
300
+ // One import, one request, one answer (#137): the server creates the whole subtree or nothing,
301
+ // so this mutation never has a partial state to clean up after — unlike the attachment upload
302
+ // above, whose two steps can strand a node.
303
+ const importBundle = useMutation({
304
+ mutationFn: async ({ parentId, zip }: { parentId: string | null; zip: Blob }) => {
305
+ if (zip.size > MaxImportBytes) throw new Error("Import exceeds the 64 MB limit");
306
+ const result = await data.importNodeBundle({
307
+ nodeId: parentId,
308
+ zip,
309
+ idempotencyKey: crypto.randomUUID(),
310
+ });
311
+ return { parentId, result };
312
+ },
313
+ onSuccess: async (imported) => {
314
+ // A bundle can carry flows, so the flow collections go stale alongside the node ones.
315
+ await reveal(imported.parentId, "node-graph");
316
+ await queryClient.invalidateQueries({ queryKey: ["flows"] });
250
317
  },
251
318
  });
252
319
 
@@ -305,6 +372,11 @@ export function AppTree() {
305
372
  uploadInput.current?.click();
306
373
  }
307
374
 
375
+ function startImport(parentId: string | null, kind: ImportKind) {
376
+ setImportTo(parentId);
377
+ (kind === "importZip" ? importZipInput : importFolderInput).current?.click();
378
+ }
379
+
308
380
  function renderLevel(parent: Level, ancestors: ReadonlySet<string>, label?: string) {
309
381
  const level = levelFor(parent);
310
382
  if (!level || level.isPending) {
@@ -371,14 +443,14 @@ export function AppTree() {
371
443
  // from `parent_id`. A flow reused by three callers therefore shows up under all three, which is
372
444
  // the answer to "what do I run, and how" (ADR-0004 §3).
373
445
  const level: Level & { id: string } = { id: entry.id, type: isFolder ? "folder" : "flow" };
374
- // ⚠️ Nicht mehr „ist von einer Art, die sich aufklappen lässt", sondern „hat etwas zum
375
- // Aufklappen, das dieser Leser sehen darf" (#59). Der Unterschied ist ein Pfeil, der hält, was
376
- // er verspricht vorher stand er an jedem Ordner und jedem Flow, auch an leeren.
446
+ // ⚠️ No longer "is of a kind that can be expanded", but "has something to expand that this
447
+ // reader may see" (#59). The difference is an arrow that keeps its promise — before, it stood
448
+ // on every folder and every flow, empty ones included.
377
449
  const expandable = entry.openable;
378
450
  const isOpen =
379
451
  expandable && expanded.some((open) => open.id === entry.id && open.type === level.type);
380
452
  const Icon = isOpen && isFolder ? FolderOpen : kindIcons[entry.kind];
381
- const area = entry.type === "flow" ? "/flows" : "/knowledge";
453
+ const area = entry.type === "flow" ? "/flows" : "/nodes";
382
454
  const isActive = location.select === entry.id && location.pathname === area;
383
455
  // A row's plus files into that row's place: inside a folder, beside anything else.
384
456
  const target = isFolder ? entry.id : parentOf(entry);
@@ -484,6 +556,7 @@ export function AppTree() {
484
556
  label={i18n.t("tree.add", { title: entry.title })}
485
557
  onSelect={(kind) => {
486
558
  if (kind === "upload") startUpload(target);
559
+ else if (kind === "importZip" || kind === "importFolder") startImport(target, kind);
487
560
  else setCreating({ parentId: target, kind });
488
561
  }}
489
562
  />
@@ -538,9 +611,13 @@ export function AppTree() {
538
611
  {/* The tree is the navigation and carries no heading of its own (ADR-0004); the list is
539
612
  named for assistive technology instead. */}
540
613
  {renderLevel({ id: null, type: "folder" }, new Set(), i18n.t("tree.label"))}
541
- {create.isError || upload.isError ? (
614
+ {create.isError || upload.isError || importBundle.isError ? (
542
615
  <p role="alert" className="px-2 py-1.5 text-sm text-destructive">
543
- {upload.isError ? i18n.t("tree.uploadFailed") : i18n.t("tree.createFailed")}
616
+ {importBundle.isError
617
+ ? i18n.t("tree.importFailed")
618
+ : upload.isError
619
+ ? i18n.t("tree.uploadFailed")
620
+ : i18n.t("tree.createFailed")}
544
621
  </p>
545
622
  ) : null}
546
623
  {/* Four refusals, four sentences — and by the time one is read the row is already back where
@@ -564,6 +641,7 @@ export function AppTree() {
564
641
  variant="row"
565
642
  onSelect={(kind) => {
566
643
  if (kind === "upload") startUpload(null);
644
+ else if (kind === "importZip" || kind === "importFolder") startImport(null, kind);
567
645
  else setCreating({ parentId: null, kind });
568
646
  }}
569
647
  />
@@ -580,6 +658,38 @@ export function AppTree() {
580
658
  event.currentTarget.value = "";
581
659
  }}
582
660
  />
661
+ <input
662
+ ref={importZipInput}
663
+ type="file"
664
+ accept=".zip,application/zip"
665
+ className="sr-only"
666
+ aria-label={i18n.t("tree.new.importZip")}
667
+ onChange={(event) => {
668
+ const file = event.target.files?.[0];
669
+ if (file) importBundle.mutate({ parentId: importTo, zip: file });
670
+ event.currentTarget.value = "";
671
+ }}
672
+ />
673
+ {/* `webkitdirectory` is the one way a browser hands over a folder; React does not know the
674
+ attribute, so it is spread past the type checker rather than invented as a prop. */}
675
+ <input
676
+ ref={importFolderInput}
677
+ type="file"
678
+ multiple
679
+ {...({ webkitdirectory: "" } as object)}
680
+ className="sr-only"
681
+ aria-label={i18n.t("tree.new.importFolder")}
682
+ onChange={(event) => {
683
+ const files = Array.from(event.target.files ?? []);
684
+ const parentId = importTo;
685
+ if (files.length > 0) {
686
+ void zipOfDirectory(files)
687
+ .then((zip) => importBundle.mutate({ parentId, zip }))
688
+ .catch(() => importBundle.mutate({ parentId, zip: new Blob([]) }));
689
+ }
690
+ event.currentTarget.value = "";
691
+ }}
692
+ />
583
693
  {creating ? (
584
694
  <Modal title={i18n.t(`tree.new.${creating.kind}`)} close={() => setCreating(null)}>
585
695
  <CreateForm
@@ -606,15 +716,33 @@ function AddMenu({
606
716
  }: {
607
717
  label: string;
608
718
  variant?: "icon" | "row";
609
- onSelect(kind: NewKind | "upload"): void;
719
+ onSelect(kind: NewKind | "upload" | ImportKind): void;
610
720
  }) {
611
721
  const { i18n } = useIntelRouterContext();
612
- const items: Array<{ kind: NewKind | "upload"; labelKey: string; Icon: typeof Plus }> = [
722
+ // Whether this deployment runs an agent runtime (#190). Until the answer arrives the item is
723
+ // absent rather than provisional: a menu entry that vanished after being seen was a lie, one that
724
+ // appears a beat late was merely loading.
725
+ const agentRuntime = useCapabilities().data?.agentRuntime === true;
726
+ const items: Array<{
727
+ kind: NewKind | "upload" | ImportKind;
728
+ labelKey: string;
729
+ Icon: typeof Plus;
730
+ }> = [
613
731
  { kind: "folder", labelKey: "tree.new.folder", Icon: Folder },
614
732
  { kind: "document", labelKey: "tree.new.document", Icon: FileText },
615
733
  { kind: "table", labelKey: "tree.new.table", Icon: Table },
616
734
  { kind: "upload", labelKey: "tree.new.upload", Icon: Upload },
617
735
  { kind: "flow", labelKey: "tree.new.flow", Icon: Workflow },
736
+ // ⚠️ The same icon the tree draws for an agent row, taken from the one kind-icon map rather
737
+ // than named again here — a second `Bot` beside it is how the two start disagreeing.
738
+ // Offered only where a runtime exists to run what would be created (#190).
739
+ ...(agentRuntime
740
+ ? [{ kind: "agent" as const, labelKey: "tree.new.agent", Icon: kindIcons.agent }]
741
+ : []),
742
+ // The bundle round trip's second half (#137): what the export produced, or a plain folder from
743
+ // the machine, lands here as a new subtree.
744
+ { kind: "importZip", labelKey: "tree.new.importZip", Icon: FileArchive },
745
+ { kind: "importFolder", labelKey: "tree.new.importFolder", Icon: FolderUp },
618
746
  ];
619
747
  return (
620
748
  <DropdownMenu>
package/src/app/app.tsx CHANGED
@@ -16,7 +16,7 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/s
16
16
  import { useIntelRouterContext } from "@/router/router-context.ts";
17
17
 
18
18
  const sections = [
19
- { path: "/knowledge", labelKey: "nav.knowledge" },
19
+ { path: "/nodes", labelKey: "nav.nodes" },
20
20
  { path: "/flows", labelKey: "nav.flows" },
21
21
  { path: "/tools", labelKey: "nav.tools" },
22
22
  ] as const;
@@ -78,7 +78,7 @@ export function App() {
78
78
  <BreadcrumbList>
79
79
  <BreadcrumbItem>
80
80
  <BreadcrumbLink asChild>
81
- <Link to="/knowledge">{i18n.t("app.name")}</Link>
81
+ <Link to="/nodes">{i18n.t("app.name")}</Link>
82
82
  </BreadcrumbLink>
83
83
  </BreadcrumbItem>
84
84
  {current ? (
@@ -20,20 +20,20 @@ import {
20
20
  } from "@/components/ui/dialog";
21
21
  import { useIntelRouterContext } from "@/router/router-context.ts";
22
22
 
23
- const Kinds = ["knowledge", "flow", "tool"] as const;
23
+ const Kinds = ["node", "flow", "tool"] as const;
24
24
  type Kind = (typeof Kinds)[number];
25
25
 
26
26
  // Enough to answer "where was that again" without turning the dialog into a result page.
27
27
  const ResultLimit = 8;
28
28
 
29
- const areaFor = { knowledge: "/knowledge", flow: "/flows", tool: "/tools" } as const;
29
+ const areaFor = { node: "/nodes", flow: "/flows", tool: "/tools" } as const;
30
30
 
31
31
  function isMac(): boolean {
32
32
  return typeof navigator !== "undefined" && /mac/i.test(navigator.userAgent);
33
33
  }
34
34
 
35
35
  // ⚠️ The deliberate boundary of this ticket, named rather than hidden: Intel has a search endpoint
36
- // for Knowledge only, so Knowledge hits come from `searchKnowledge()` and the server decides both
36
+ // for nodes only, so node hits come from `searchNodes()` and the server decides both
37
37
  // what matches and what may be seen. Flows and Tools have no such endpoint — they are the same
38
38
  // authorized lists the screens render, narrowed here in the client.
39
39
  //
@@ -81,10 +81,10 @@ export function HeaderSearch() {
81
81
  const shows = (candidate: Kind) => kind === null || kind === candidate;
82
82
  // A filter that excludes a kind does not ask for it either: no request, and therefore no group
83
83
  // that could show up empty.
84
- const knowledge = useQuery({
85
- queryKey: ["knowledge-search", query],
86
- queryFn: () => data.searchKnowledge({ query, limit: ResultLimit }),
87
- enabled: open && shows("knowledge") && query.length > 0,
84
+ const nodes = useQuery({
85
+ queryKey: ["node-search", query],
86
+ queryFn: () => data.searchNodes({ query, limit: ResultLimit }),
87
+ enabled: open && shows("node") && query.length > 0,
88
88
  });
89
89
  // ⚠️ Deliberately the whole list, not one folder of the tree: a search that only saw the level
90
90
  // someone happened to have open would miss the flow they are looking for. The sidebar tree asks
@@ -101,14 +101,14 @@ export function HeaderSearch() {
101
101
  });
102
102
 
103
103
  const active = [
104
- ...(shows("knowledge") ? [knowledge] : []),
104
+ ...(shows("node") ? [nodes] : []),
105
105
  ...(shows("flow") ? [flows] : []),
106
106
  ...(shows("tool") ? [tools] : []),
107
107
  ];
108
108
  const searching = query.length > 0 && active.some((source) => source.isPending);
109
109
  const failed = query.length > 0 && active.some((source) => source.isError);
110
110
 
111
- const knowledgeHits = shows("knowledge") ? (knowledge.data?.items ?? []) : [];
111
+ const nodeHits = shows("node") ? (nodes.data?.items ?? []) : [];
112
112
  const flowHits: Flow[] = shows("flow")
113
113
  ? (flows.data?.items ?? [])
114
114
  .filter((flow) => matches(query, flow.title, flow.description))
@@ -119,7 +119,7 @@ export function HeaderSearch() {
119
119
  .filter((tool) => matches(query, tool.name, tool.title, tool.description))
120
120
  .slice(0, ResultLimit)
121
121
  : [];
122
- const total = knowledgeHits.length + flowHits.length + toolHits.length;
122
+ const total = nodeHits.length + flowHits.length + toolHits.length;
123
123
 
124
124
  function reach(hit: Kind, select: string) {
125
125
  setOpen(false);
@@ -163,7 +163,7 @@ export function HeaderSearch() {
163
163
  <DialogTitle>{i18n.t("search.title")}</DialogTitle>
164
164
  <DialogDescription>{i18n.t("search.description")}</DialogDescription>
165
165
  </DialogHeader>
166
- {/* Every source has already decided what matches — the server for Knowledge, the client
166
+ {/* Every source has already decided what matches — the server for nodes, the client
167
167
  filter above for the two lists. Letting cmdk filter again would hide rows the server
168
168
  returned, because a row's value is its identity rather than its text. */}
169
169
  <Command shouldFilter={false}>
@@ -223,13 +223,13 @@ export function HeaderSearch() {
223
223
  <CommandEmpty>{i18n.t("search.empty", { query })}</CommandEmpty>
224
224
  ) : null}
225
225
 
226
- {knowledgeHits.length > 0 ? (
227
- <CommandGroup heading={i18n.t("search.kind.knowledge")}>
228
- {knowledgeHits.map((citation) => (
226
+ {nodeHits.length > 0 ? (
227
+ <CommandGroup heading={i18n.t("search.kind.node")}>
228
+ {nodeHits.map((citation) => (
229
229
  <CommandItem
230
230
  key={`${citation.nodeId}:${citation.versionId}`}
231
- value={`knowledge:${citation.nodeId}:${citation.versionId}`}
232
- onSelect={() => reach("knowledge", citation.nodeId)}
231
+ value={`node:${citation.nodeId}:${citation.versionId}`}
232
+ onSelect={() => reach("node", citation.nodeId)}
233
233
  >
234
234
  <FileText aria-hidden="true" />
235
235
  <span className="min-w-0 flex-1">
@@ -12,10 +12,10 @@ export type MoveDestination = { id: string | null; title: string };
12
12
 
13
13
  export type MoveVerdict = "ok" | "self" | "descendant" | "same-place";
14
14
 
15
- // The parent a row is filed under. Knowledge and Flows keep their own record (ADR-0004), so the
15
+ // The parent a row is filed under. Nodes and Flows keep their own record (ADR-0004), so the
16
16
  // answer is read from whichever one this row is, never from a merged shape.
17
17
  export function parentOf(entry: TreeEntry): string | null {
18
- return entry.type === "knowledge" ? entry.node.parentId : entry.flow.parentId;
18
+ return entry.type === "node" ? entry.node.parentId : entry.flow.parentId;
19
19
  }
20
20
 
21
21
  // One level of the shared tree, as a query key. The move writes into two of them and the picker
@@ -27,14 +27,14 @@ export function treeLevelKey(parentId: string | null): readonly unknown[] {
27
27
  // The optimistic row, filed where it is about to land. Its own parent has to travel with it, or the
28
28
  // plus on the moved row would still file into the folder it just left.
29
29
  function withParent(entry: TreeEntry, parentId: string | null): TreeEntry {
30
- return entry.type === "knowledge"
30
+ return entry.type === "node"
31
31
  ? { ...entry, node: { ...entry.node, parentId } }
32
32
  : { ...entry, flow: { ...entry.flow, parentId } };
33
33
  }
34
34
 
35
35
  /**
36
36
  * ⚠️ The trap this ticket is built around: a node must not travel into its own descendants. The
37
- * service refuses it (`move_cycle`, `knowledge.ts:421`), but a refusal that only arrives once the
37
+ * service refuses it (`move_cycle`, `nodes.ts:421`), but a refusal that only arrives once the
38
38
  * mouse button is already up is the one answer worse than no dragging at all — the drop has to be
39
39
  * offered or withheld while the pointer is still moving.
40
40
  *
@@ -55,7 +55,7 @@ export function moveVerdict(input: {
55
55
  }
56
56
 
57
57
  // ⚠️ Four refusals, four sentences. The service tells them apart by `code`
58
- // (`knowledge.ts:331/335/338/361`, `flows.ts:205–217/752`), and so does this: one message for all
58
+ // (`nodes.ts:331/335/338/361`, `flows.ts:205–217/752`), and so does this: one message for all
59
59
  // of them would leave the reader guessing which of "you may not write there", "that is not a
60
60
  // folder", "that would be a loop" and "somebody else was faster" they have just hit.
61
61
  export function moveErrorKey(error: unknown): string {
@@ -64,7 +64,7 @@ export function moveErrorKey(error: unknown): string {
64
64
  ? String((error as { code: unknown }).code)
65
65
  : null;
66
66
  switch (code) {
67
- case "knowledge_forbidden":
67
+ case "node_forbidden":
68
68
  case "flow_edit_forbidden":
69
69
  return "tree.move.failed.forbidden";
70
70
  case "parent_not_folder":
@@ -88,8 +88,8 @@ export function moveErrorKey(error: unknown): string {
88
88
  * `nodeVerbQuery`), so moving a document is a change of who may read it — without the word "share"
89
89
  * appearing anywhere. That is why every move is confirmed rather than performed on drop.
90
90
  *
91
- * What this deliberately does not do is name the people. `listKnowledgeGrants` answers with the
92
- * grants sitting on that one folder, needs `share` on it (`knowledge.ts:459`), and says nothing
91
+ * What this deliberately does not do is name the people. `listGrants` answers with the
92
+ * grants sitting on that one folder, needs `share` on it (`nodes.ts:459`), and says nothing
93
93
  * about the grants above it that reach down. A list built from that would be short of exactly the
94
94
  * entries that matter and would read as complete — "nobody is on this folder" while an ancestor
95
95
  * opens it to the whole organization. Naming the mechanism truthfully beats enumerating it wrongly;
@@ -263,7 +263,7 @@ export function useTreeMove({
263
263
  idempotencyKey: crypto.randomUUID(),
264
264
  });
265
265
  } else {
266
- await data.updateKnowledge({
266
+ await data.updateNode({
267
267
  nodeId: entry.id,
268
268
  baseUpdatedAt: entry.node.updatedAt,
269
269
  parentId: destination.id,
@@ -303,7 +303,7 @@ export function useTreeMove({
303
303
  queryClient.invalidateQueries({ queryKey: treeLevelKey(parentOf(entry)) }),
304
304
  queryClient.invalidateQueries({ queryKey: treeLevelKey(destination.id) }),
305
305
  queryClient.invalidateQueries({
306
- queryKey: [entry.type === "flow" ? "flows" : "knowledge-graph"],
306
+ queryKey: [entry.type === "flow" ? "flows" : "node-graph"],
307
307
  }),
308
308
  queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
309
309
  ]);
@@ -1,6 +1,6 @@
1
1
  import { useMutation, useQuery } from "@tanstack/react-query";
2
2
  import { useNavigate, useRouterState } from "@tanstack/react-router";
3
- import { ChevronsUpDown, LogOut, User, Wrench } from "lucide-react";
3
+ import { Archive as ArchiveIcon, ChevronsUpDown, LogOut, User, Wrench } from "lucide-react";
4
4
  import {
5
5
  DropdownMenu,
6
6
  DropdownMenuContent,
@@ -76,6 +76,12 @@ export function UserFooter() {
76
76
  <Wrench aria-hidden="true" />
77
77
  {i18n.t("nav.tools")}
78
78
  </DropdownMenuItem>
79
+ {/* The archive lives here rather than in the tree: it is not a place things are IN,
80
+ it is where they went when they left the tree (#113). */}
81
+ <DropdownMenuItem onSelect={() => void navigate({ to: "/archive" })}>
82
+ <ArchiveIcon aria-hidden="true" />
83
+ {i18n.t("archive.title")}
84
+ </DropdownMenuItem>
79
85
  <DropdownMenuSeparator />
80
86
  <DropdownMenuItem
81
87
  disabled={logout.isPending}