@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.
- package/README.md +2 -2
- package/package.json +5 -2
- package/src/agent/agent-avatar/agent-avatar.tsx +34 -0
- package/src/agent/agent-calendar/agent-calendar.tsx +79 -0
- package/src/agent/agent-chat/agent-chat.tsx +116 -0
- package/src/agent/agent-cron/agent-cron.ts +132 -0
- package/src/agent/agent-definition/agent-definition.ts +64 -0
- package/src/agent/agent-entry-title/agent-entry-title.ts +29 -0
- package/src/agent/agent-log/agent-log.tsx +159 -0
- package/src/agent/agent-models/agent-models.ts +63 -0
- package/src/agent/agent-profile/agent-profile.tsx +512 -0
- package/src/agent/agent-state/agent-state.ts +48 -0
- package/src/agent/agent.tsx +361 -0
- package/src/app/app-sidebar/app-sidebar.tsx +2 -2
- package/src/app/app-tree/app-tree.tsx +151 -23
- package/src/app/app.tsx +2 -2
- package/src/app/header-search/header-search.tsx +16 -16
- package/src/app/tree-move/tree-move.tsx +10 -10
- package/src/archive/archive.tsx +8 -8
- package/src/components/ui/avatar.tsx +39 -0
- package/src/components/ui/select.tsx +163 -0
- package/src/components/ui/tabs.tsx +52 -0
- package/src/data/agent-runtime/agent-runtime.ts +93 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +201 -120
- package/src/data/intel-data-provider/intel-data-provider.types.ts +112 -54
- package/src/entry-picker/entry-picker.tsx +53 -17
- package/src/flow-runs/flow-runs.tsx +1 -1
- package/src/flows/flows.tsx +20 -20
- package/src/folder-contents/folder-contents.tsx +7 -7
- package/src/graph-pane/graph-pane.tsx +1 -1
- package/src/hooks/use-capabilities.ts +22 -0
- package/src/i18n/en.json +148 -64
- package/src/kind-icon.ts +5 -2
- package/src/{knowledge-editor/knowledge-editor.tsx → node-editor/node-editor.tsx} +20 -20
- package/src/{knowledge-graph → node-graph}/graph-notice.tsx +1 -1
- package/src/{knowledge-graph/knowledge-graph.tsx → node-graph/node-graph.tsx} +4 -4
- package/src/{knowledge-table/knowledge-table.tsx → node-table/node-table.tsx} +14 -14
- package/src/{knowledge/knowledge.tsx → nodes/nodes.tsx} +46 -29
- package/src/resource-menu/resource-menu.tsx +105 -62
- package/src/router/router.tsx +5 -5
- package/src/title-row/title-row.tsx +13 -5
- package/vite.config.ts +4 -0
- /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
|
-
//
|
|
115
|
-
//
|
|
143
|
+
// An expanded flow shows what it calls — with 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
|
|
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" | "
|
|
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
|
-
|
|
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.
|
|
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: "/
|
|
245
|
+
area: "/nodes" as const,
|
|
199
246
|
id: node.id,
|
|
200
247
|
parentId,
|
|
201
|
-
collection: "
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
.
|
|
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, "
|
|
249
|
-
await navigate({ to: "/
|
|
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
|
-
// ⚠️
|
|
375
|
-
//
|
|
376
|
-
//
|
|
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" : "/
|
|
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
|
-
{
|
|
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
|
-
|
|
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: "/
|
|
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="/
|
|
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 = ["
|
|
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 = {
|
|
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
|
|
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
|
|
85
|
-
queryKey: ["
|
|
86
|
-
queryFn: () => data.
|
|
87
|
-
enabled: open && shows("
|
|
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("
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
{
|
|
227
|
-
<CommandGroup heading={i18n.t("search.kind.
|
|
228
|
-
{
|
|
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={`
|
|
232
|
-
onSelect={() => reach("
|
|
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.
|
|
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 === "
|
|
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 === "
|
|
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`, `
|
|
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
|
-
// (`
|
|
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 "
|
|
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. `
|
|
92
|
-
* grants sitting on that one folder, needs `share` on it (`
|
|
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.
|
|
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" : "
|
|
306
|
+
queryKey: [entry.type === "flow" ? "flows" : "node-graph"],
|
|
307
307
|
}),
|
|
308
308
|
queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
|
|
309
309
|
]);
|
package/src/archive/archive.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Flow,
|
|
1
|
+
import type { Flow, Node } from "@anchrd/intel-contract";
|
|
2
2
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
3
|
import { ArchiveRestore } from "lucide-react";
|
|
4
4
|
import { kindIcons } from "@/kind-icon.ts";
|
|
@@ -10,7 +10,7 @@ import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
|
10
10
|
interface ArchivedEntry {
|
|
11
11
|
id: string;
|
|
12
12
|
title: string;
|
|
13
|
-
kind:
|
|
13
|
+
kind: Node["kind"] | "flow";
|
|
14
14
|
archivedAt: string;
|
|
15
15
|
updatedAt: string;
|
|
16
16
|
restore(): Promise<unknown>;
|
|
@@ -23,12 +23,12 @@ export function Archive() {
|
|
|
23
23
|
const archived = useQuery({
|
|
24
24
|
queryKey: ["archive"],
|
|
25
25
|
queryFn: async (): Promise<ArchivedEntry[]> => {
|
|
26
|
-
const [
|
|
27
|
-
data.
|
|
26
|
+
const [nodes, flows] = await Promise.all([
|
|
27
|
+
data.listNodes({ archivedOnly: true }),
|
|
28
28
|
data.listFlows({ archivedOnly: true }),
|
|
29
29
|
]);
|
|
30
30
|
const entries: ArchivedEntry[] = [
|
|
31
|
-
...
|
|
31
|
+
...nodes.items.flatMap((node) =>
|
|
32
32
|
node.archivedAt
|
|
33
33
|
? [
|
|
34
34
|
{
|
|
@@ -38,7 +38,7 @@ export function Archive() {
|
|
|
38
38
|
archivedAt: node.archivedAt,
|
|
39
39
|
updatedAt: node.updatedAt,
|
|
40
40
|
restore: () =>
|
|
41
|
-
data.
|
|
41
|
+
data.archiveNode({
|
|
42
42
|
nodeId: node.id,
|
|
43
43
|
baseUpdatedAt: node.updatedAt,
|
|
44
44
|
archived: false,
|
|
@@ -86,7 +86,7 @@ export function Archive() {
|
|
|
86
86
|
// The tree gets it back, so every level has to be asked again — the entry may sit
|
|
87
87
|
// anywhere, and the archive does not know where.
|
|
88
88
|
queryClient.invalidateQueries({ queryKey: ["tree"] }),
|
|
89
|
-
queryClient.invalidateQueries({ queryKey: ["
|
|
89
|
+
queryClient.invalidateQueries({ queryKey: ["node-graph"] }),
|
|
90
90
|
queryClient.invalidateQueries({ queryKey: ["flows"] }),
|
|
91
91
|
]);
|
|
92
92
|
},
|
|
@@ -128,7 +128,7 @@ export function Archive() {
|
|
|
128
128
|
<Icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
|
|
129
129
|
);
|
|
130
130
|
})()}
|
|
131
|
-
<span className="sr-only">{i18n.t(`
|
|
131
|
+
<span className="sr-only">{i18n.t(`node.kind.${entry.kind}`)}</span>
|
|
132
132
|
<span className="grid min-w-0 flex-1 leading-tight">
|
|
133
133
|
<span className="truncate text-sm font-medium">{entry.title}</span>
|
|
134
134
|
<span className="truncate text-xs text-muted-foreground">
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Avatar as AvatarPrimitive } from "radix-ui";
|
|
2
|
+
import type * as React from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
|
|
6
|
+
function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
|
7
|
+
return (
|
|
8
|
+
<AvatarPrimitive.Root
|
|
9
|
+
data-slot="avatar"
|
|
10
|
+
className={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
|
|
11
|
+
{...props}
|
|
12
|
+
/>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
|
17
|
+
return (
|
|
18
|
+
<AvatarPrimitive.Image
|
|
19
|
+
data-slot="avatar-image"
|
|
20
|
+
className={cn("aspect-square size-full", className)}
|
|
21
|
+
{...props}
|
|
22
|
+
/>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function AvatarFallback({
|
|
27
|
+
className,
|
|
28
|
+
...props
|
|
29
|
+
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
|
30
|
+
return (
|
|
31
|
+
<AvatarPrimitive.Fallback
|
|
32
|
+
data-slot="avatar-fallback"
|
|
33
|
+
className={cn("flex size-full items-center justify-center rounded-full bg-muted", className)}
|
|
34
|
+
{...props}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export { Avatar, AvatarFallback, AvatarImage };
|